Skip to content

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.b4 is typed ModuleNotBuilt (compute.py:247). Replacing it changes docs/api-reference/openapi.json (CI openapi-drift job, ci.yml:61-78), frontend/src/api/schema.gen.ts (:1081), and the frontend's mirrored mock frontend/src/mocks/fixtures/projects_compute_sample.ts (synced from the golden by frontend/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 f is 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. Since C × B is 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).
  • N uses 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)).
  • N is decision #24's formula, not the workbook's ROUNDUP(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_factor and 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 future GET /lci/rsl.
  • f is 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-category f table on the LCI side would apply Keur Songho's design to every project, which is a default by another name. So f is 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=None and 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, KeyError is raised, as _spec_for does.
  • EXCEL_HARDCODED_RSL is never imported into backend/app/. A test greps for it.
  • a1_a3.py is not edited, and _Factor gains no field. Putting RSL in b4.py keeps 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, else ValueError, 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) has f, a non-BLOCKED RSL and a COMPUTED A1-A3 row, and every out-of-scope row has an explicit f = 0.
  • Otherwise INCOMPLETE: ModuleSummary.incomplete_reason = B4Result.incomplete_reason (one "{material}: {reason}" line per row, from aggregate), and total is null. This mirrors _a1_a3_summary and B6.
  • ModuleSummary.secondary stays null for B4. Replacements are per row, not per module.
  • VariantDetail.b4: B4Result replaces ModuleNotBuilt. ModuleNotBuilt and B4_NOT_BUILT are 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) − 1 is 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_years echoes 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

  1. schemas/b4.py (new): ParamVerdict, MaterialB4Result, B4Result.
  2. 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).
  3. schemas/compute.py: MaterialInput.finishes_fraction / finishes_basis with their validator. VariantDetail.b4: B4Result. Delete ModuleNotBuilt. Update the module docstring (B4 is no longer "not built").
  4. services/project_compute.py: call compute_b4(a1_a3, variant.materials, rsp_years=…); add _b4_summary; delete B4_NOT_BUILT; update the docstring.
  5. api/routes/projects.py:27-29: route description text.
  6. Regenerate projects_compute_sample.json, openapi.json, schema.gen.ts and the mock mirror.

6.2 Tests (backend/tests/unit/test_b4.py, plus an edit to test_project_compute.py:210-213)

  1. replacements: (30,10)→2, (50,10)→4, (30,30)→0, (30,31)→0, (30,7)→4, (1,1)→0; RSL < 1 → ValueError.
  2. Each decision-table row, 1–5, with in-memory _Rsl records (monkeypatch the table). This is the only place a non-BLOCKED RSL exists.
  3. Multi-gap reason: f null and RSL BLOCKED, so both are named.
  4. Linearity: for a COMPUTED kg row, compute_b4 equals N·f × compute_a1_a3_submitted([(c, q)]) and also equals compute_a1_a3_submitted([(c, N·f·q)]).impact (approx, rel 1e-12).
  5. 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_GWP from 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 fixture f and a test-only RSL = 10 at RSP = 50. Match SPEC_B4_PER_MATERIAL_KG_CO2 within 0.1 %. These inputs are test-local, imported from tests/fixtures/, never from app code.
  6. Divergence (CORRECTED): B4-C1, crude oil f = 0, so excluded and the corrected total is CORRECTED_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 in backend/tests/fixtures/ only if the reconciliation doc demands it. Otherwise they stay in test_b4.py, so fixtures/ is untouched (§5).
  7. Gates: RSL_TABLE.keys() == FACTOR_TABLE.keys(); every RSL_TABLE value has years is None (the wire-no-number guard, deleted by the first PR that wires an RSL); _Rsl invariants raise; EXCEL_HARDCODED_RSL and the literal / 10 are absent from backend/app/.
  8. Request validation: f ∈ {−0.1, 1.1, NaN, inf} → 422; f without basis → 422; f = 0 with basis → OK.
  9. Envelope: B4 is incomplete for the sample; detail.b4.total is None; whole-life total 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.b4 stub → 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-row f. 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 + LciMaterial RSL exposure (DEV; it moves lci_materials.json, so it gets its own golden-regen PR); (ii) wizard input for f + basis per row (DEV + UI); (iii) structured f attribution, the AR-91 analogue (METHODOLOGY, UNASSIGNED — Marco to route).
  • ROADMAP.md session 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 = 0 does 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 f from 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)

  1. Where f lives. This spec puts f on 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 _Finishes record beside _Rsl. Then the request schema does not change and inputs_echo does not move, but the KS-design-as-default risk in §2 applies and should be recorded.
  2. f attribution shape. This PR uses free-text finishes_basis, following B6's basis (AR-80). Structured who/date/basis would follow AR-91.
  3. 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.