B4 replacement — design spec (Phase-1 step 3)¶
Status: spec only. No code lands with this document.
Governing spec: lca-stages-b4-c3-c4-d-spec.md §3, the decision-#23 reconciliation of the B4 (Replacement) sheet. This document sits under it. It defines no competing formula. It turns §3.1's
Impact_B4 = GWP × replacements × finishes_fraction × weight into a module that obeys decision #24 (per-material RSL, provenance-gated) and the #59 module-state contract.
Model: (A) finishes fraction per row. No structural yes/no flag. A structural row is f = 0.
Scope of the implementation PR: the mechanism, the gated f and RSL inputs, and the BLOCKED boundary. No number is wired.
0. What the code does today (read pass, cited, origin/main = 805030b)¶
| # | Premise | Verified against | Holds |
|---|---|---|---|
| 1 | Per-row A1-A3 results already exist and are linear in quantity | compute_a1_a3_submitted (a1_a3.py:797-826) returns A1A3Result.materials: list[MaterialA1A3Result] (schemas/a1_a3.py:68). Every unit rule in _apply_factor (a1_a3.py:756-794) gives impact = k · quantity · factor, with k fixed by the record and the project (1, 1000, t/t_ref, 1/t_ref, 1/g). So N · f · row.impact equals the A1-A3 of N · f · quantity for every basis. |
Yes |
| 2 | No RSL, finishes-fraction or structural field exists | No such field in schemas/enums.py, a1_a3._Factor (a1_a3.py:77-109), compute.MaterialInput (compute.py:58) or lci.LciMaterial (lci.py:49). MaterialCategory.FINISH (enums.py:80) is a taxonomy member, not a fraction. domains/lca/b4.py is a 5-line TODO stub. The only RSL mentions are prose (interpolation.py:22, project_compute.py:50). |
Yes |
| 3 | EXCEL_HARDCODED_RSL = 10 is CORRECTED; f verdicts exist |
tests/fixtures/test_b4_c3_c4_d_fixtures.py:208 (# B4-F1 CORRECTED); per-material f verdicts in methodology spec §3.3 and B4_FINISHES_FRACTION (fixtures:181-201), with crude oil already CORRECTED to 0. Axis 2 (§8) marks every f and the RSL BLOCKED. |
Yes |
| 4 | Module state for B4 today | project_compute.py:176-181: incomplete, reason B4_NOT_BUILT; detail ModuleNotBuilt (:218). Whole-life total is null while any of A1-A3/B4/B6 is not complete (:186-205). |
Yes |
| 5 | RSP source | ProjectCreate.functional_unit.rsp_years, int, 1–200, default 30 (schemas/project.py:69, decision #15). compute_b6 already takes it as a keyword (b6.py:48). |
Yes |
Two facts the brief did not state, and which shape §6:
- The API contract moves with the detail model.
VariantDetail.b4is typedModuleNotBuilt(compute.py:247). Replacing it changesdocs/api-reference/openapi.json(CIopenapi-driftjob,ci.yml:61-78),frontend/src/api/schema.gen.ts(:1081), and the frontend's mirrored mockfrontend/src/mocks/fixtures/projects_compute_sample.ts(synced from the golden byfrontend/scripts/sync-mocks.mjs). None of these is a golden or a fixture, but each is a byte change. §5 lists them as the expected, and only, collateral. - UI spec §2.2.3 says "B4 needs no user input." That assumed
fis LCI-side. §2 argues it is not, so the UI spec needs a one-line amendment (open point §8.1). The wizard needs no change in this PR, because the new request field is optional.
1. Formula and units¶
Per submitted material row i of a variant:
N_i = ceil(RSP / RSL_i) − 1 [replacements, dimensionless integer ≥ 0]
Impact_B4,i = N_i × f_i × Impact_A1-A3,i [kg CO2-eq over the RSP]
B4 = Σ_i Impact_B4,i [kg CO2-eq] (only if every row is COMPUTED)
| Symbol | Meaning | Unit / type | Source |
|---|---|---|---|
RSP |
Reference study period | int years, 1–200 |
functional_unit.rsp_years (decision #15, default 30) |
RSL_i |
Reference service life of the material | int years, ≥ 1 |
LCI-side RSL record (§2.1); every record BLOCKED today |
f_i |
Share of the row's installed quantity that is a replaceable finish | float in [0, 1], finite |
Per submitted row (§2.2) |
Impact_A1-A3,i |
That row's MaterialA1A3Result.impact |
Quantity, kg CO2-eq |
Read, never recomputed |
- This is §3.1 rearranged. §3.1 has
H = C × F × E × B, which is GWP × replacements × fraction × weight. SinceC × Bis the A1-A3 row impact ('A1-A3'!E_row, §3.1 column D),H = F × E × (C × B). The parity relation is exact. Only F's derivation changes (B4-F1). Nuses integer arithmetic:N = -(-RSP // RSL) - 1. RSL is an integer number of years (ISO 15686-8 tables report whole years), so the ceiling has no float edge (for example,ceil(0.3/0.1)).Nis decision #24's formula, not the workbook'sROUNDUP(RSP/10 − 1, 0). The two agree only when RSL = 10 (§3.2), and 10 is never a default (B4-F1).- B4 does not call
_apply_factorand does not read_Factor. It takes the A1-A3 rows as given, so the A1-A3 status, verdicts, provenance and declared-unit rule all carry over unchanged.
1.1 Edge cases¶
| Case | Result | Why |
|---|---|---|
RSL ≥ RSP |
N = 0, row COMPUTED, impact 0 |
No replacement within the study period (EN 15978 §6.2.5). This is a real zero, not an exclusion. replacements = 0 is reported. |
RSL divides RSP exactly (30/10) |
N = 2 |
ceil(3) − 1. The final installation's end coincides with the end of the RSP, so it is not replaced. |
f = 0 |
Row excluded: COMPUTED, impact 0, excluded = true |
Covers the structural case. RSL and the A1-A3 status are not consulted, so a structural row never blocks on a missing RSL. |
f missing (null) |
Row BLOCKED | Missing is not zero. No default. |
f present, > 0, RSL missing/BLOCKED |
Row BLOCKED | Decision #24: no "never-replaced" default. |
f > 0, A1-A3 row BLOCKED or PROVISIONAL |
Row BLOCKED, reason quotes the A1-A3 reason | No B4 number from a non-defensible A1-A3 number (premise 1). |
f > 0, RSL and A1-A3 fine |
COMPUTED, N · f · impact |
— |
f ∉ [0, 1], NaN, inf |
422 at request validation | Shape check (§2.2). |
RSP < 1 |
Already a 422 (Field(ge=1)) |
Unchanged. |
When a row has several gaps (f and RSL both missing, or RSL missing and A1-A3 BLOCKED), the reason lists all of them, as the seam's "Two gaps" message does (a1_a3.py:772-774). The user then sees the whole fix at once.
2. Where RSL and f live, and their gates¶
The two parameters differ in kind, so they are stored in different places.
- RSL is a property of the material in its climate (decision #24, ISO 15686). It belongs on the LCI side, keyed by
MaterialCategory, next to the A1-A3 factor. This is UI spec §2.2.3's "RSL table held on the LCI side" and the futureGET /lci/rsl. fis a property of how this building uses the material. The share of cement that is render, not structure, depends on the design. The §3.3 fractions describe Keur Songho, not cement in general. A per-categoryftable on the LCI side would apply Keur Songho's design to every project, which is a default by another name. Sofis a per-row project input.
2.1 RSL record (LCI side), in domains/lca/b4.py¶
This mirrors the seam's pattern (_Factor.basis/basis_conversion): a frozen per-category record whose __post_init__ enforces its invariants, with no default value.
class ParamVerdict(StrEnum): # decision #24's three-state gate
SOURCED = "SOURCED"
EXPERT_JUDGEMENT = "EXPERT-JUDGEMENT"
BLOCKED = "BLOCKED"
@dataclass(frozen=True)
class _Rsl:
category: MaterialCategory
years: int | None
verdict: ParamVerdict
citation: str | None = None # required for SOURCED
attribution: str | None = None # required for EXPERT_JUDGEMENT: who, date, basis
blocked_reason: str | None = None # required for BLOCKED
# __post_init__: years is None ⇔ verdict is BLOCKED; years ≥ 1;
# SOURCED ⇒ citation; EXPERT_JUDGEMENT ⇒ attribution; BLOCKED ⇒ reason.
RSL_TABLE: Mapping[MaterialCategory, _Rsl]
- Every entry ships BLOCKED with
years=Noneand a reason naming AR-12 / attribution item 8. Glass and floor tiles also cite B4-F4 / B4-F6 (the CORRECTED "0 replacements" becomes BLOCKED, per §9 rows B4-C2 and B4-C3). - Keys: the table's keys are exactly
FACTOR_TABLE's keys, enforced by a test. A submitted category with no RSL entry is therefore impossible. As a backstop,KeyErroris raised, as_spec_fordoes. EXCEL_HARDCODED_RSLis never imported intobackend/app/. A test greps for it.a1_a3.pyis not edited, and_Factorgains no field. Putting RSL inb4.pykeeps the A1-A3 module and its goldens out of the diff.
2.2 f on the request row, in schemas/compute.py¶
class MaterialInput(BaseModel):
...
finishes_fraction: float | None = Field(
None,
description="B4: share of this row's quantity "
"that is a replaceable finish, 0–1. Null = not provided → B4 BLOCKED for this row. "
"0 = nothing replaced (structural). Never defaulted.",
)
finishes_basis: Annotated[str, Field(max_length=NOTE_MAX)] | None = Field(
None, description="Who, method, date for finishes_fraction. Required with it."
)
model_validator, following the B6Input._energy pattern (compute.py:100-114):
- If finishes_fraction is null, the row is accepted and B4 for it is BLOCKED.
- Otherwise it must be finite and in [0, 1], else 422.
- finishes_basis must be non-blank when finishes_fraction is set, else 422. A user-supplied f is an EXPERT-JUDGEMENT parameter under the CLAUDE.md gate, so it needs attribution. Structured attribution (named person, date) follows AR-91's B6 pattern and is out of scope here (§8.2).
- f = 0 also requires a basis. "Structural, not replaced" is still a claim someone makes.
The KS verdicts from §3.3 are not overwritten and not moved into app code. They remain the Keur Songho reference values in tests/fixtures/. When Keur Songho is run through the request path (GET /projects/examples/keur-songho, AR-72, later), its example request carries f per row with these verdicts: crude oil 0 (CORRECTED), and so on.
3. Module-state contract¶
3.1 Per-row decision table (first match wins; gaps collected as in §1.1)¶
| # | Condition | Row status | impact |
replacements |
|---|---|---|---|---|
| 1 | f null |
BLOCKED | null | null |
| 2 | f == 0 |
COMPUTED, excluded=true |
0 kg | null |
| 3 | RSL record BLOCKED | BLOCKED | null | null |
| 4 | A1-A3 row status ≠ COMPUTED | BLOCKED | null | N (RSL known) |
| 5 | otherwise | COMPUTED | N · f · impact |
N |
Row status reuses MaterialResultStatus, with only COMPUTED and BLOCKED used. B4 has no PROVISIONAL state: it multiplies a defended number or none at all. The module total therefore goes through lca._common.aggregate unchanged.
3.2 Result models (schemas/b4.py, new)¶
class MaterialB4Result(BaseModel): # frozen
material: str
category: MaterialCategory
finishes_fraction: float | None
finishes_basis: str | None # echoed
excluded: bool
rsl_years: int | None
rsl_verdict: ParamVerdict
rsl_citation: str | None
rsl_attribution: str | None
rsp_years: int
replacements: int | None
a1_a3_impact: Quantity | None # echoed from the A1-A3 row, for the audit trail
impact: Quantity | None
status: MaterialResultStatus
blocked_reason: str | None
class B4Result(BaseModel): # frozen
stage: LCModule = LCModule.B4
rsp_years: int
materials: list[MaterialB4Result] # same order as A1-A3 rows
total: Quantity | None
incomplete_reason: list[str] | None
3.3 Module level¶
compute_b4(a1_a3: A1A3Result, inputs: Sequence[MaterialInput], *, rsp_years: int) -> B4Result. It zips the rows by position. Lengths and categories must match, elseValueError, because that would be a programming error.- B4 COMPLETE ⇔
B4Result.total is not None⇔ every row is COMPUTED. That means every in-scope row (f > 0) hasf, a non-BLOCKED RSL and a COMPUTED A1-A3 row, and every out-of-scope row has an explicitf = 0. - Otherwise INCOMPLETE:
ModuleSummary.incomplete_reason = B4Result.incomplete_reason(one"{material}: {reason}"line per row, fromaggregate), andtotalis null. This mirrors_a1_a3_summaryand B6. ModuleSummary.secondarystays null for B4. Replacements are per row, not per module.VariantDetail.b4: B4ResultreplacesModuleNotBuilt.ModuleNotBuiltandB4_NOT_BUILTare deleted, since they have no other user (grep:compute.py:197,247,project_compute.py:35,49,180,218).- Whole-life total: unchanged code. It is null whenever B4 is not complete. It is also null today regardless of B4, because B6 is never complete until AR-65 closes.
What the user sees after the PR: every request gives B4 incomplete. With f omitted, the reasons are "finishes fraction not provided" plus the RSL gap. With f supplied, the reason is "RSL BLOCKED (AR-12)". An all-f = 0 variant is the only path to B4 COMPLETE, with total 0, and it is reachable only by a user explicitly attributing every row as structural. That is honest, not a fake zero.
4. RSP sensitivity (AR-12: 30 vs 50) — decide before publishing, not a blocker¶
N = ceil(RSP/RSL) − 1is a non-decreasing step function of RSP, so B4 rises with the study period. For RSL = 10: 30 a → N = 2, 50 a → N = 4. B4 doubles. For RSL = 15: 30 a → 1, 50 a → 3. B4 triples. For RSL = 25: 30 a → 1, 50 a → 1, no change. The sensitivity is per material and stepwise, unlike B6's linear +66.7 %.- On the per-m²·a headline (
÷ RSP), A1-A3 scales by 1/RSP and B4 by roughly(RSP/RSL − 1)/RSP. So the RSP choice moves B4's share of the whole-life total, and it does so differently for earth (render-heavy, short RSL) and conventional (paint) variants. The 30/50 choice can move the comparison, not just the absolute value. - Status: decision #15's 30 a stands. The 30 vs 50 choice (workbook
B1!C5) is deferred by decision #33 and remains open on AR-12. The impl PR adds a sentence to AR-12: "B4 is step-wise in RSP: N = ⌈RSP/RSL⌉−1. Decide before publishing." It does not block the PR, because no number is wired. B4Result.rsp_yearsechoes the value, so every B4 number carries its study period.
5. KS parity invariant¶
Claim: B4 is a no-op for every existing Keur Songho result until RSL and f are supplied. Only one golden moves.
Why:
- B4 is downstream-only. It reads A1A3Result and writes B4Result. a1_a3.py, quantities/, a4.py, a5.py, seed_epds/ and _common.aggregate are not edited.
- The KS endpoints (/quantities/keur-songho, /lca/a1-a3|a4|a5/keur-songho) do not call compute_project, so they never touch B4.
- GET /lci/materials is unchanged: LciMaterial gains no RSL field in this PR. GET /lci/rsl is a separate PR.
- MaterialA1A3Result gains no field.
- The new request fields are optional with default null. projects_compute_request.json (the committed input) is unchanged. The response's inputs_echo and input_hash change inside projects_compute_sample.json.
Expected diff, exhaustively:
| Path | Changes? |
|---|---|
backend/tests/golden/projects_compute_sample.json |
Yes, regenerated with UPDATE_GOLDEN=1. Per variant: detail.b4 goes from {state: not_built} to a B4Result with 4 BLOCKED rows (Cement, Steel Rebars, Sand, Glass: f not provided, RSL BLOCKED) and total: null. summary.modules[B4] stays incomplete / total: null with new reason lines. inputs_echo rows gain finishes_fraction: null, finishes_basis: null. input_hash changes. A1-A3 blocks are byte-identical. |
backend/tests/golden/{a1_a3,a4,a5,quantities}_keur_songho.json, lci_materials.json, projects_compute_request.json |
No |
backend/tests/fixtures/** |
No |
docs/api-reference/openapi.json, frontend/src/api/schema.gen.ts, frontend/src/mocks/fixtures/projects_compute_sample.ts |
Yes, mechanical regeneration (dump_openapi.py, openapi-typescript, sync-mocks). Not goldens. Named in the PR body. |
Proof plan (run before push and quote the output in the PR body):
git fetch origin
git diff --exit-code origin/main -- backend/tests/fixtures/
git diff --exit-code origin/main -- \
backend/tests/golden/a1_a3_keur_songho.json backend/tests/golden/a4_keur_songho.json \
backend/tests/golden/a5_keur_songho.json backend/tests/golden/quantities_keur_songho.json \
backend/tests/golden/lci_materials.json backend/tests/golden/projects_compute_request.json
git diff --name-only origin/main -- backend/tests/golden/ # must print exactly projects_compute_sample.json
The KS regression suites (backend/tests/regression/) and test_b4_c3_c4_d_fixtures.py run green with zero edits.
6. The one implementation PR¶
Branch: feat/phase1-b4-replacement, from a fresh origin/main.
Title: feat(lca): B4 replacement mechanism to the BLOCKED boundary (decision #24).
6.1 Changes¶
schemas/b4.py(new):ParamVerdict,MaterialB4Result,B4Result.domains/lca/b4.py: replace the stub with_Rsl,RSL_TABLE(all BLOCKED),replacements(rsp, rsl) -> int,compute_b4(...), and reason constants (B4_F_NOT_PROVIDED,B4_RSL_BLOCKED,B4_A1A3_NOT_COMPUTED).schemas/compute.py:MaterialInput.finishes_fraction/finishes_basiswith their validator.VariantDetail.b4: B4Result. DeleteModuleNotBuilt. Update the module docstring (B4 is no longer "not built").services/project_compute.py: callcompute_b4(a1_a3, variant.materials, rsp_years=…); add_b4_summary; deleteB4_NOT_BUILT; update the docstring.api/routes/projects.py:27-29: route description text.- Regenerate
projects_compute_sample.json,openapi.json,schema.gen.tsand the mock mirror.
6.2 Tests (backend/tests/unit/test_b4.py, plus an edit to test_project_compute.py:210-213)¶
replacements: (30,10)→2, (50,10)→4, (30,30)→0, (30,31)→0, (30,7)→4, (1,1)→0; RSL < 1 →ValueError.- Each decision-table row, 1–5, with in-memory
_Rslrecords (monkeypatchthe table). This is the only place a non-BLOCKED RSL exists. - Multi-gap reason:
fnull and RSL BLOCKED, so both are named. - Linearity: for a COMPUTED kg row,
compute_b4equalsN·f×compute_a1_a3_submitted([(c, q)])and also equalscompute_a1_a3_submitted([(c, N·f·q)]).impact(approx, rel 1e-12). - Parity (VALIDATED formula, §8 Axis 1): build in-memory COMPUTED A1-A3 rows whose impact is the workbook's
B × C(BOQ_WEIGHT_KG × A1A3_GWPfrom the fixture) for Sand, Cement, Bitumen, Wall paint and Anti-termite. Do not use Python's A1-A3 output, because cement and others are CORRECTED there. Use the fixturefand a test-only RSL = 10 at RSP = 50. MatchSPEC_B4_PER_MATERIAL_KG_CO2within 0.1 %. These inputs are test-local, imported fromtests/fixtures/, never from app code. - Divergence (CORRECTED): B4-C1, crude oil
f = 0, so excluded and the corrected total isCORRECTED_B4_TOTAL_KG_CO2. B4-F1: with a test RSL ≠ 10, N ≠EXCEL_REPLACEMENTS. B4-C2/C3: glass and floor-tile RSL records are BLOCKED with the B4-F4/F6 reasons. Rows go inbackend/tests/fixtures/only if the reconciliation doc demands it. Otherwise they stay intest_b4.py, sofixtures/is untouched (§5). - Gates:
RSL_TABLE.keys() == FACTOR_TABLE.keys(); everyRSL_TABLEvalue hasyears is None(the wire-no-number guard, deleted by the first PR that wires an RSL);_Rslinvariants raise;EXCEL_HARDCODED_RSLand the literal/ 10are absent frombackend/app/. - Request validation:
f∈ {−0.1, 1.1, NaN, inf} → 422;fwithout basis → 422;f = 0with basis → OK. - Envelope: B4 is
incompletefor the sample;detail.b4.total is None; whole-lifetotal is None; A1-A3 summary is unchanged.
6.3 Reconciliation gate (CLAUDE.md)¶
| Item | Verdict | Test |
|---|---|---|
H = C × F × E × B (as N·f·impact) |
VALIDATED | parity, test 5 |
| Replacements formula | CORRECTED (B4-F1): ⌈RSP/RSL⌉−1 per material |
divergence, test 6 |
| RSL per material | BLOCKED (all) | raises/BLOCKED rows, test 7 |
f per row |
Project input, EXPERT-JUDGEMENT with basis; KS values keep their §3.3 verdicts in fixtures | tests 6, 8 |
Crude oil f |
CORRECTED → 0 (B4-F2, AR-26 open) | divergence, test 6 |
6.4 Docs and register (step 6)¶
docs/architecture/module-map.md:lca.b4stub → implemented (not parity-verified: no RSL is wired).data-flow.md: A1-A3 rows → B4.docs/design/ui-spec-phase1.md§2.2.3: B4 takes an optional per-rowf. RSL stays LCI-side.action-register.md: AR-12 gets the step-function note (§4) and "mechanism landed, every RSL BLOCKED". New rows: (i)GET /lci/rsl+LciMaterialRSL exposure (DEV; it moveslci_materials.json, so it gets its own golden-regen PR); (ii) wizard input forf+ basis per row (DEV + UI); (iii) structuredfattribution, the AR-91 analogue (METHODOLOGY,UNASSIGNED — Marco to route).ROADMAP.mdsession log;pytest,ruff check .,ruff format --check .,mypy backend/app; push; watch CI green.
Out of this PR: any RSL value; GET /lci/rsl; wizard UI for f; the KS example request; climate-dependent RSL (interpolation.py:22); per-row RSL override.
7. What is deliberately not here¶
- No structural flag (model B, rejected).
f = 0does that job. - No RSL default, including 10 and "never replaced" (decision #24).
- No back-calculation of RSL from the workbook's N = 4, or of
ffrom the column G quantities. - No change to A1-A3, its schema, or its goldens.
8. Open points for the orchestrator (they do not block the spec, but §8.1 must be settled before the impl PR)¶
- Where
flives. This spec putsfon the request row and RSL on the LCI side (§2). The brief's "gated per-row inputs" supports this reading. The brief's "mirror the seam" (which stores on the record) could be read the other way. If the orchestrator wants both on the LCI side, §2.2 becomes a_Finishesrecord beside_Rsl. Then the request schema does not change andinputs_echodoes not move, but the KS-design-as-default risk in §2 applies and should be recorded. fattribution shape. This PR uses free-textfinishes_basis, following B6'sbasis(AR-80). Structured who/date/basis would follow AR-91.- Per-row RSL override (an attributed EXPERT-JUDGEMENT RSL from the user) is deferred. Until then, a user cannot unblock B4 at all, which is the intended Phase-1 state under AR-12.