B6 operational energy — design spec (Phase-1 step 4)¶
Status: §5 implemented (Session 40, fork-(b) plumbing to the BLOCKED boundary; no carbon number). Written 2026-10-01 against origin/main @ 085c8b3.
Implementation notes (Session 40): (1) B6Result / B6Status live in schemas/compute.py, next to B6Input, not in a new
schemas/b6.py (§5.1.2): a separate module would either import-cycle with compute.py or move EnergyCarrier and rename its
OpenAPI component (it collides with schemas.a5.EnergyCarrier). (2) B6Status has no computed member yet; it lands with the
S5 multiply. (3) The services/interpolation.py docstring was fixed in the same PR by dispatch, overriding §5.1.7.
(4) The wizard mirrors S2 client-side (a kWh figure with carrier "Not set" is flagged and not sent), so the wizard does not send a body the backend would reject.
Sits under: the normative B6 operational-energy specification
(§4.9 → §4.10 → §4.11, fixtures §5). This document defines no formula of its own. It says how
that chain enters the POST /projects/compute envelope, which inputs it takes, and when the module
is ready or BLOCKED.
Tracks: AR-80 (energy source — undecided), AR-65 (EF_grid unsourced), AR-09 (workbook grid
value unverified), AR-12 / decision #33(b) (30 vs 50 a), AR-64 (EN ISO 52016-1 build), AR-54 / AR-56
(diesel precedent in A5), AR-72 (compute route).
Decisions this spec does NOT make: AR-80 (Marco's call), AR-12 / #33(b). It presents both
and picks neither.
(Status 2026-10-01: AR-80 has since been decided — fork (b) for Phase 1, fork (a) deferred to
Phase 2. The fork presentation below is kept as written.)
0. What the code does today (read pass, cited)¶
0.1 How a module plugs into the compute envelope (#59)¶
| Fact | Where |
|---|---|
POST /projects/compute is stateless. It calls one engine per module and gives every module an explicit state. |
backend/app/services/project_compute.py:4-17 |
A1-A3 is the pattern. compute_a1_a3_submitted(rows, project_name) returns an A1A3Result whose total is None whenever any row is not COMPUTED. _a1_a3_summary maps that to complete / incomplete and copies incomplete_reason. |
domains/lca/a1_a3.py:797; project_compute.py:118-127, :161-166 |
The "no partial total" rule: aggregate returns (None, reasons) if any row is not COMPUTED (decision #23). |
domains/lca/_common.py:59-83 |
Module states are ModuleStatus = complete / incomplete / not_provided / not_assessed. The module level has no blocked member. "BLOCKED" is expressed as incomplete plus an incomplete_reason naming the blocker. Row-level BLOCKED exists only as MaterialResultStatus. |
schemas/compute.py:140-144; schemas/a1_a3.py:60-72 |
ModuleSummary has total, share, blocked_count, incomplete_reason and secondary. secondary is documented as "B6: the submitted annual energy (kWh per year)". |
schemas/compute.py:147-165 |
Whole-life = A1-A3 + B4 + B6. It is null whenever any counted module is not complete. Per-m²·a is computed by dividing by fu.gia and fu.rsp_years. |
project_compute.py:54, :186-203 |
A4/A5 are not_assessed, in_whole_life=False — a boundary cut-off under decision #32, which names A4/A5 only. |
project_compute.py:44, :55, :167-175 |
0.2 B6 today — fork (b) is half-built¶
| Fact | Where |
|---|---|
B6Input has annual_energy: Quantity \| None, which must be in kWh, finite and ≥ 0. It also has carrier: EnergyCarrier \| None and basis: str ≤ 280. basis is required whenever annual_energy is set (AR-80). |
schemas/compute.py:74-97 |
EnergyCarrier = grid, diesel. Its docstring: "B6 GWP is BLOCKED either way (AR-65)". |
schemas/compute.py:41-46 |
VariantInput.b6 defaults to an empty B6Input. |
schemas/compute.py:105 |
_b6_summary: no energy → not_provided with [B6_NOT_PROVIDED, B6_NOT_BUILT]. Energy given → incomplete with [B6_NOT_BUILT], and secondary = the kWh. The carrier is ignored. |
project_compute.py:130-145 |
B6_NOT_BUILT already names AR-65: "EF_grid is unsourced". |
project_compute.py:48-51 |
The detail is B6Pending(ModuleNotBuilt): state="not_built", reason, and the echoed input. |
schemas/compute.py:185-197; project_compute.py:219 |
The frontend sends annual_energy only when kWh and basis are both valid. carrier defaults to "grid". |
frontend/src/wizard/draft.ts:85, :178-192, :241-245 |
Tests pin not_provided, the echo, kwh-without-basis → 422, and b6-not-kwh → 422. |
backend/tests/unit/test_project_compute.py:168-170, :205-224 |
0.3 Functional unit and RSP¶
| Fact | Where |
|---|---|
FunctionalUnit.rsp_years: int (1–200) = 30. The ORM column has the same default. This is decision #15 and it stands (PROJECT.md decision #33(b) defers 30 vs 50 and keeps #15 meanwhile). |
schemas/project.py:67-71; models/project.py:31; PROJECT.md:412-413; register AR-12 (action-register.md:97) |
The wizard always sends rsp_years. |
frontend/src/wizard/draft.ts:229-232 |
comfort_setpoint_celsius and occupancy_profile exist on the FU but are unused. They are fork-(a) inputs. |
schemas/project.py:70-71 |
ProjectIdentity.latitude / longitude are optional and the wizard sends null. Fork (a) needs them. |
schemas/project.py:59-60; draft.ts:222-223 |
0.4 Energy, thermal, climate and grid-factor code paths¶
| Fact | Where |
|---|---|
domains/lca/b6.py is a 5-line stub. Its docstring "simplified CLTD/CLF" is stale. AR-64 locked EN ISO 52016-1, and AR-31 rejected CLTD/CLF. Correct it in the impl PR. |
domains/lca/b6.py:4-5 |
domains/thermal and domains/energy are stubs. There is no thermal table in the repo — no λ / ρ / c / U-value data and no "Mass + U-value" record. The only thermal artefact is the unit W_PER_M_K. |
domains/thermal/__init__.py:4-5; domains/energy/__init__.py:4-5; schemas/units.py:36 |
services/interpolation.py is a stub. Its docstring is stale too: it still names an "RTS/Heat-Balance kernel", which AR-64 rejected. |
services/interpolation.py:13-14, :25 |
data/grid_factors/ and data/ipcc_efdb/ are empty. No grid factor is stored, and none may be (AR-65, IEA licence). |
backend/app/data/grid_factors/.gitkeep |
MethodologySettings.grid_factor_policy (ipcc_efdb_national default) is an enum with no values or lookup behind it. No code reads it. |
schemas/project.py:43-46, :117 |
The only kWh → CO₂ paths are A4 (0.26, BLOCKED) and A5 (0.26, BLOCKED, AR-54). A5's docstring names the workbook's B1!D5 Senegal value and the diesel-generator category error. |
domains/lca/a5.py:33-54, :120-133 |
The unverified workbook value is 2_lca_vn.xlsx B1!D5 = 1.1 kg CO₂/kWh (AR-09). It appears in this
spec only as a name. It is never an input, a default, a fixture or a test value.
1. The B6 chain (by reference, with units)¶
The normative chain is B6 methodology spec §4.9 → §4.10. This spec adds the fork-(b) entry point and the RSP step, in the envelope's units:
E_del,yr [kWh/a] delivered energy per year (whole building, grid carrier)
fork (a): = E_del,C + E_del,H (§4.9, from Q_C, Q_H, SCOP)
fork (b): = B6Input.annual_energy (supplied, with basis)
EF_grid [kg CO2e/kWh] runtime-supplied, with provenance (§4.11). NEVER stored, NEVER defaulted.
g_B6 [kg CO2e/a] = E_del,yr · EF_grid (§4.10 line 1)
G_B6 [kg CO2e] = g_B6 · RSP RSP = functional_unit.rsp_years [a]
(§4.10 line 3, before
dividing by area)
ModuleSummary.total for B6 is G_B6 in kg_CO2_eq, the same unit as A1-A3, so that the existing
whole-life sum (project_compute.py:197) and the per-m² / per-m²·a division (:200-203) apply
unchanged. B6 adds no per-m² normalisation of its own. (§4.10's g″ = g / A_f uses conditioned
floor area A_f; the envelope normalises by GIA. That difference matters only for fork (a). See §3.1, gap G9.)
GWP only. B6 reports one indicator (kg_CO2_eq), as A1-A3 does today.
Fork (b) needs a delivered-energy figure, not an energy need. §4.9's Q / SCOP step is skipped
because the supplier has already done it. A basis that describes energy need (thermal kWh) would
silently drop SCOP. The impl PR states this in the field description (§5).
2. RSP sensitivity (AR-12 / #33(b)) — information only¶
G_B6 = E_del,yr · EF_grid · RSP is linear in RSP with zero intercept:
G_B6(50) / G_B6(30) = 50/30 = 1.667 → +66.7 % B6 carbon going from 30 a to 50 a
∂G_B6/∂RSP = E_del,yr · EF_grid [kg CO2e per year of study period]
On the per-m²·a headline (total_per_m2_year = whole / GIA / RSP, project_compute.py:201-203), RSP
cancels for B6 (G_B6 / RSP = g_B6). It does not cancel for A1-A3, which is a one-time amount.
So a longer RSP leaves B6 per m²·a flat, shrinks A1-A3 per m²·a by 30/50, and raises B6's share of
the whole-life total. Choosing 30 or 50 therefore changes what the earth-vs-conventional
comparison says about embodied vs operational carbon. That is why it must be settled before results
are published.
- Not a blocker. B6 uses
functional_unit.rsp_yearsas submitted. Decision #15 (30 a) stands. This spec adds no RSP default of its own and no RSP gate. - Decide before publishing (AR-12 / #33(b)). The result already echoes
rsp_years(inputs_echo). The impl PR changes no RSP behaviour.
3. AR-80 — the energy-source fork (presented, not chosen)¶
Both forks end in the same E_del,yr [kWh/a] and the same §1 chain. They differ only in where
E_del,yr comes from. Either way, EF_grid keeps B6 carbon BLOCKED until AR-65 closes (§4).
3.1 Fork (a) — the tool computes kWh/yr — = the full AR-64 build (DEFERRED)¶
Code shape (B6 methodology spec §7): thermal (ISO 10456 / 6946 / 13786: U, f, Δt, node capacities)
→ energy (vendored NASA POWER + services.interpolation → EN ISO 52016-1 hourly kernel →
Q_C, Q_H, E_del) → lca.b6 (§1 chain). Not ASHRAE CLTD/CLF/SCL, and not RTS (AR-31, AR-64).
What exists vs what is missing (the "thermal table" in decision #33(a) is not in the repo, §0.4):
| # | Needed | In repo today | Gap / BLOCKED until |
|---|---|---|---|
| G1 | Thermal library: λ, ρ, c per material, with moisture state (ISO 10456; CRAterre / RILEM TC 274 for earth) | Unit enum only (units.py:36) |
Sourced under the seed-EPD provenance gate; earth values need Chiwara/CRAterre (cf. AR-47 density) |
| G2 | U-value per assembly (ISO 6946) and dynamic parameters f, Δt (ISO 13786) | Nothing | G1 + assembly build-ups |
| G3 | Envelope geometry: wall/roof/opening areas × orientation, A_f | Nothing. Option A takes material quantities, not geometry; Option C (geometry) is deferred (#33) | An input path that does not exist under Option A |
| G4 | Climate data: hourly/monthly dry-bulb, dew point, humidity, wind, GHI/DNI/DHI at the site | Nothing vendored (D-ENERGY-01 requires vendoring) | NASA POWER vendoring + §2.6 provenance sidecar |
| G5 | Spatial interpolation on two grids (0.5°×0.625° met, 1°×1° radiation) | Stub (services/interpolation.py), with a stale RTS docstring |
Build per B6 methodology spec §3 |
| G6 | Site coordinates | Optional fields; the wizard sends null |
Make them required for fork (a) |
| G7 | Set-points, occupancy schedule, internal gains, ventilation/infiltration | comfort_setpoint_celsius and occupancy_profile exist but are unused; the rest are absent |
Input design + sourced defaults (or no defaults) |
| G8 | SCOP_c / SCOP_h (§4.9) | Nothing | User-supplied; §4.9's "screening 3.0" is a methodology-spec default that would need its own provenance |
| G9 | Conditioned floor area A_f vs GIA | GIA only | A normalisation decision before fork (a) reports per-m² figures |
| G10 | The EN ISO 52016-1 hourly kernel + Tier-0 monthly cross-check | Nothing (domains/energy stub) |
AR-64 build; fixtures §5 (Dakar, Ouagadougou), mirror test §8.4 |
Until G1–G10 close, fork (a) produces no E_del,yr. It cannot ship partially. Without
geometry (G3) under Option A there is nothing to run the kernel on. Under fork (a), B6 would therefore
stay incomplete (energy demand BLOCKED and EF_grid BLOCKED) for all of Phase 1.
3.2 Fork (b) — Chiwara supplies kWh/yr — already half-built¶
Code shape: B6Input.annual_energy (kWh/a) + basis (who, method, date) + carrier → lca.b6 §1
chain. Data needs: one delivered-energy figure per variant, with its provenance. No thermal, climate
or kernel work.
| Already built (§0.2) | Missing to reach the BLOCKED boundary |
|---|---|
kWh-only, ≥ 0, finite; basis required with energy |
A domain function lca.b6. Today the summary is assembled inline in _b6_summary. |
carrier accepted (grid / diesel) |
The carrier is ignored. Grid vs diesel must take different paths (§4.3). |
not_provided when energy is absent |
carrier = null with energy present is accepted today, which is ambiguous. It must not be guessed. |
Echo via secondary and B6Pending.input |
The reason says "not built". It must instead say built, BLOCKED on AR-65. |
Fork (b) does not foreclose fork (a). Fork (a) is a second producer of the same E_del,yr. The
lca.b6 entry point takes E_del,yr plus provenance, whoever produced it.
4. Module-state contract¶
"ready" below means ModuleStatus.COMPLETE. "BLOCKED" means ModuleStatus.INCOMPLETE with a reason
naming the blocker. No new ModuleStatus member is added. That keeps B6 symmetric with A1-A3 (§0.1).
4.1 Inputs B6 needs¶
| Input | Source | Today |
|---|---|---|
E_del,yr [kWh/a] + basis |
Fork (b): B6Input. Fork (a): energy domain. |
Fork (b) field exists |
carrier |
B6Input.carrier |
Field exists, ignored |
EF_grid [kg CO₂e/kWh] + provenance: value, year, source/edition, location- vs market-based, life-cycle vs combustion-only (§4.11; AR-65) |
Runtime-supplied by the user. Never stored in the repo, never defaulted (IEA licence; decision #26). | No input path exists. Adding one is the AR-65 closure, not the near-term PR (§5). |
RSP [a] |
functional_unit.rsp_years (decision #15) |
Exists |
4.2 Decision table (evaluated in this order; first match wins)¶
| # | Condition | status |
total |
secondary |
incomplete_reason (substance) |
|---|---|---|---|---|---|
| S1 | annual_energy is null |
not_provided |
null | null | B6 annual energy not provided (+ AR-65 note, as today) |
| S2 | energy given, carrier null |
— | — | — | 422 at the request boundary: carrier is required with energy, never guessed |
| S3 | energy given, carrier = diesel |
incomplete |
null | kWh | Diesel carrier outside Phase-1 B6 scope. It needs a fuel-combustion factor, not EF_grid (mirrors AR-54/AR-56). Follow-up row. |
| S4 | energy given, carrier = grid, EF_grid absent |
incomplete |
null | kWh | EF_grid unsourced: B6 carbon BLOCKED (AR-65). The energy figure is reported. |
| S5 | energy given, carrier = grid, EF_grid present with complete provenance |
complete |
G_B6 kg CO₂e |
kWh | null |
- Ready (S5) requires both a provenanced
E_del,yrand a provenancedEF_grid. A missingEF_gridis always BLOCKED (S4), whatever else is present. - Zero energy.
annual_energy = 0(allowed:≥ 0) follows thework_impactzero-work rule (_common.py:140-155): with a factor present the total is exactly 0. Without a factor it is still BLOCKED (S4). This deliberately departs fromwork_impact, which makes zero work computable without a factor. Reason: B6 is the largest whole-life contributor, and a supplied "0 kWh" turning B6completewith no factor on file would make the whole-life total complete on an unprovenanced zero. (Flag for review. If the orchestrator prefers strictwork_impactsymmetry, change S4 → S5 only for0and record the rationale.) - Thermal/electricity figures are still reported when carbon is refused (B6 methodology spec §8.5).
In fork (b) that figure is
secondary. It is present in S3, S4 and S5. - No back-calculation.
EF_gridis never derived from a supplied total, from A5's0.26, or fromB1!D5.E_del,yris never derived from a carbon figure.
4.3 Diesel — symmetric cut-off from the Phase-1 calculation, not from the boundary¶
Phase-1 B6 covers the grid carrier only. Multiplying a diesel kWh by EF_grid is the category error
A5 already documents (a5.py:43-46; AR-54). So S3 returns incomplete with the kWh echoed. It is not
not_assessed. not_assessed sets in_whole_life=False, and decision #32 authorises that only for
A4/A5. Putting B6 outside the whole-life total would silently drop the largest contributor. The
per-carrier fuel-combustion factor is a new register row (§6).
5. Near-term implementable scope — ONE small PR, after AR-80 and AR-12 are recorded¶
What it does: finishes fork-(b) plumbing up to the BLOCKED boundary. It produces no carbon number. It closes the software gap so that the only thing standing between B6 and a number is AR-65.
Gating note. This plumbing fits fork (b). If Marco picks fork (a), the same PR is still valid: it is
the lca.b6 consumer that fork (a)'s energy domain will feed. It is not wasted work, but it is then not
the critical path.
5.1 Changes¶
domains/lca/b6.py: replace the stub, and fix the stale "CLTD/CLF" docstring (→ EN ISO 52016-1, AR-64; Phase-1 fork-(b) entry point). Addcompute_b6(b6: B6Input, *, rsp_years: int) -> B6Resultimplementing §4.2 S1/S3/S4. There is no EF_grid parameter: S5 is unreachable by construction in this PR, so the multiply stepE · EF · RSPis not written yet. It lands with the AR-65 input path, together with its test, so no untested arithmetic ships. Module-level constants hold the reason strings.schemas/b6.py(new):B6Resultwithstatus(computed/blocked/not_provided, reusingMaterialResultStatuswhere it fits),annual_energy,carrier,basis,rsp_years,total: None,blocked_reason, andef_grid: Nonewith a description pointing to AR-65.schemas/compute.py:B6Input._energy:carrierrequired whenannual_energyis set (S2 → 422). State "delivered energy, not thermal need" in theannual_energydescription (§1).VariantDetail.b6: B6ResultreplacesB6Pending. RemoveB6Pending(its only uses arecompute.py:194-203andproject_compute.py:219).- Correct the
EnergyCarrierdocstring: grid BLOCKED on AR-65; diesel out of Phase-1 scope. services/project_compute.py:_b6_summarydelegates tocompute_b6. ReplaceB6_NOT_BUILTwith the S3/S4 reasons. Passrsp_yearsfromrequest.project.functional_unit.frontend/src/wizard/draft.ts: no logic change is needed.carrieralready defaults to"grid". Regenerate the API types; the Results view reads the new detail shape.- Docs:
module-map.mdlca.b6row stub → implemented (fork-(b) plumbing, BLOCKED on AR-65). This is not parity-verified: there is no B1-sheet reconciliation yet (§5.3). Also updatedata-flow.md,ROADMAP.mdstep 4 and the register (§6). - Out of scope: the
EF_gridinput field (AR-65 closure), anyenergy/thermalcode (fork a), any RSP change, diesel factors, and correcting theinterpolation.pydocstring (record it under AR-64 instead).
5.2 Tests (unit, backend/tests/unit/)¶
- S1: no energy →
not_provided,totalnull,secondarynull. - S2: energy without carrier → 422 at the route boundary.
- S3: diesel + energy →
incomplete, kWh insecondary, reason names the diesel cut-off. Assert that noEF_gridterm appears. - S4: grid + energy →
incomplete,totalnull, kWh insecondary, reason cites AR-65. Same forannual_energy = 0. - Whole-life stays null with B6 in S4 even when A1-A3 and B4 are complete (extends
test_project_compute.py:228). - Guard:
grepfor any float literal assigned to a grid-factor name underbackend/app/fails the test, as does any file indata/grid_factors/other than.gitkeep(AR-65 / IEA licence). - RSP:
rsp_yearsis echoed intoB6Result. There is no RSP-dependent number to assert yet, so the linearity test (G(50)/G(30) = 5/3) ships with the AR-65 PR.
5.3 Reconciliation gate (CLAUDE.md two-axis verdict)¶
This PR implements no formula that produces a value, so it ships no parity or divergence test. The
follow-up PR that adds the S5 multiply must first have a reconciliation verdict
(VALIDATED / CORRECTED) for the workbook's B6 sheet (2_lca_vn.xlsx "B1", mislabelled;
module-map.md:202). That is a sub-chat task under CLAUDE.md. B1!D5 will be BLOCKED there under AR-09
whatever the formula verdict.
6. Register rows the impl PR must add or update¶
| Row | Change |
|---|---|
| AR-80 | Note: fork-(b) plumbing landed. The decision itself stays open (Marco). |
| AR-65 | Note: lca.b6 consumes EF_grid at S5. The input field and provenance schema are the closure path. |
| AR-12 | Note: B6 is linear in RSP. On per-m²·a, B6 is flat and A1-A3 scales by 1/RSP (§2): decide before publishing. |
| AR-64 | Add: stale "RTS/Heat-Balance" docstring in services/interpolation.py:13-14. (b6.py is fixed in this PR.) |
| New | B6 diesel carrier: needs a sourced fuel-combustion factor; Phase-1 B6 returns incomplete (S3). Owner: Track-B sourcing. Blocks: B6 for off-grid buildings. Links: AR-54, AR-56. |
| New | Fork-(a) normalisation: A_f (B6 methodology spec §4.0) vs GIA (envelope). Owner: Marco. Blocks: fork-(a) per-m² reporting only. |
| New (only if kept) | The zero-energy-without-factor rule (§4.2): a deliberate departure from work_impact. Owner: Marco to confirm. |
7. Open points for the orchestrator (do not block the spec)¶
- AR-80 — fork (a) or fork (b). (Marco.)
- AR-12 / #33(b) — 30 or 50 a, before publishing. Not before the PR.
- §4.2: should zero energy without a factor be BLOCKED (proposed) or
completeat 0 (strictwork_impactsymmetry)? - Structured provenance for
basis: it stays free text ≤ 280 here, matching the shipped UI. A structured{who, method, date, delivered_vs_need}block would be a separate UI + schema change.
8. Delta — the EF_grid runtime input and the S5 multiply (spec; written 2026-10-02 against origin/main @ ac08969)¶
Status: spec final, implemented in Session 43 (branch feat/phase1-b6-ef-grid-input). The
orchestrator ruled on P1–P3 and O2–O4 on 2026-10-02 (§8.0). T11 skips if the workbook is absent,
pending AR-96 (the reference workbooks' public-release status), per Marco's ruling of 2026-10-02.
Locked (orchestrator, not re-opened here): EF_grid is supplied at runtime with provenance and is
never stored or defaulted (decision #26, AR-65). It carries a generated or delivered basis. B6
uses the RSP from functional_unit.rsp_years (decision #15 = 30 a; AR-12's 30 vs 50 is a
"decide before publishing" flag, not a blocker). Only the grid carrier gets a number; diesel stays
incomplete (AR-88; decision #32 reserves cut-off for A4/A5). Inputs that arrive with provenance
are applied, not authored (LCI-first, decision #31).
8.0 Rulings (orchestrator, 2026-10-02)¶
| # | Finding (read pass) | Ruling |
|---|---|---|
| P1 | The sample's B6 reason (projects_compute_sample.json:383, :399, :459, from b6.py:37-40) says EF_grid "has no input path". That becomes false once this PR lands. |
Reword to "no EF_grid was supplied with this request" (§8.5). The golden and its generated mock move on those 3 strings only. input_hash and inputs_echo stay byte-identical, and the PR proves it (§8.7). |
| P2 | An all-f = 0 variant already makes B4 complete (b4.py:28, :171-176; test_b4.py:400-407). So A1-A3 complete + all rows at f = 0 + a usable EF reaches the first non-null whole-life total. |
Allow it. No suppression. The existing rule (project_compute.py:200-218) computes it; T9 covers it. |
| P3 | §5.3 and CLAUDE.md's two-axis gate need a verdict on the workbook's B6 sheet before the multiply ships. None existed. | Verdict given inline in §8.1, which satisfies the gate (decision #23). No separate reconciliation sub-chat. The parity test reads the workbook cells at test time and commits no number. |
| O2 | frontend/src/wizard/draft.ts:277-286 persists the whole wizard draft to localStorage. |
The EF may persist in the user's own localStorage draft only if the user entered it. It is never pre-filled or suggested, and there is no default (#26). This binds the step-2 UI PR. The backend PR does not touch the frontend logic. |
| O3 | Zero and market-based EFs. | Allow both. They are captured in provenance (value ≥ 0, scope) and never rejected (LCI-first, #31). |
| O4 | Combustion-only EFs understate B6 (AR-65). | The backend captures boundary (life-cycle vs combustion-only) in this PR. The visible combustion-only warning is a step-2 frontend item: register row AR-95 (§8.9). It is not in this PR. |
8.1 Reconciliation verdict — workbook sheet "B1" (the B6 sheet, mislabelled)¶
Read-only extract from reference/excel/2_lca_vn.xlsx, sheet B1. Formulas were read with
data_only=False and the cached value with data_only=True, on 2026-10-02. The sheet has one data
row (rows 4–5 of 10 used).
| Cell | Header (row 4) | Content | Cached |
|---|---|---|---|
B5 |
Energy Demand (kwh/year.area) | 49.8 |
— |
C5 |
Life Time (years) | 50 |
— |
D5 |
Emission Factor (kg CO2-eq/kwh) | 1.1 |
— |
E5 |
Area (m2) | =10.04*28.8 |
289.152 |
F5 |
Impact Emission (kg CO2-eq) | =D5*B5*C5*E5 |
791 987.328 |
Formula verdict
| ID | Item | Verdict | Basis |
|---|---|---|---|
| B6-F1 | F5 = D5·B5·C5·E5 |
VALIDATED | It is the §1 chain G_B6 = E_del,yr · EF · RSP, with E_del,yr = B5 · E5 (an intensity times an area gives kWh/a). This is EN 15978 B6 in the form of methodology spec §4.10. Multiplication commutes, so the factor order does not matter. Python must match within 0.1 %. |
Parameter verdicts
| ID | Cell | Verdict | Consequence |
|---|---|---|---|
| B6-P1 | D5 = 1.1 kg CO2/kWh |
BLOCKED (AR-09: uncited, unverified; label says CO2, not CO2e; basis unknown) | Never a default, an input, a fixture or a literal anywhere in the repo. The parity test reads it from the workbook at test time (§8.7 T11); that is the only use. |
| B6-P2 | C5 = 50 a |
Governed by AR-12 / decision #33(b) | Decision #15's 30 a stands for the product. The parity test reads C5 only to reproduce the cell. |
| B6-P3 | B5 = 49.8 kWh/m²·a |
Not a product parameter | Phase 1 is fork (b): the user supplies E_del,yr with a basis (AR-80). B5 has no source, and it is not clear whether it is delivered energy or energy need. It is never a default. |
| B6-P4 | E5 = 10.04 × 28.8 m² |
Not a product parameter | This is the Keur Songho area, a reference-case geometry. B6 takes whole-building kWh/a, so no area enters the B6 product chain. |
Status consequence: once T11 passes, the module-map.md lca.b6 row may advance to
parity-verified (formula B6-F1). D5 stays BLOCKED, so the workbook's number is never reproduced
by a product path. Only the formula is verified.
8.2 Input shape — B6Input.ef_grid (request only, never persisted server-side)¶
New optional field B6Input.ef_grid: GridFactorInput | None (schemas/compute.py:104-137).
| Field | Type | Rule |
|---|---|---|
value |
float | Finite, ≥ 0, in kg CO2e/kWh, on the stated basis. 0 is allowed (O3). |
basis |
"generated" \| "delivered" |
Required. B6 meters delivered energy (compute.py:108-115). |
year |
int | Required. The data year of the factor. |
source |
str ≤ NOTE_MAX |
Required, non-blank. Publisher, dataset or edition, and URL or DOI. |
scope |
"location_based" \| "market_based" |
Required. Both are accepted (O3). Echoed. |
boundary |
"life_cycle" \| "combustion_only" |
Required. Both are accepted and echoed. The UI warning is AR-95 (O4). |
loss_rate |
float | None | T&D loss as a fraction in [0, 1). Converts a generated EF. Must be null for delivered. |
loss_year / loss_source |
int | None / str | None | Required whenever loss_rate is set. |
422 at the boundary (same pattern as S2, compute.py:125-137):
- a missing or blank provenance field;
- a non-finite or negative value;
- loss_rate outside [0, 1);
- loss_rate without loss_year and loss_source;
- any loss field on a delivered EF (it would double-convert).
Not a 422: a generated EF with no loss fields. It is well-formed but unusable, so it gives
BLOCKED (S5b, §8.4).
Never persisted server-side. The route is stateless (routes/projects.py:4), and ef_grid sits on
VariantInput.b6, never on ProjectCreate or the ORM. It is echoed in inputs_echo
(compute.py:299) and hashed into input_hash (project_compute.py:113-116), so a changed EF changes
the hash (UI spec §3.3).
Golden stability: declare it as Field(None, exclude_if=lambda v: v is None) (pydantic 2.13.4,
installed, supports exclude_if). A request without an EF then serialises exactly as before.
No OpenAPI examples carrying a number on this model: an example value is a committed factor.
Client side (O2): governed by the step-2 UI PR. The EF is user-entered only, it may sit in the
user's own draft, and it is never pre-filled.
8.3 Result shape — B6Result¶
B6StatusgainsCOMPUTED = "computed"(compute.py:225-231).total: None→total: Quantity | None(kg_CO2_eq) =G_B6.ef_grid: None→ef_grid: GridFactorApplied | None. It is null unless S5a, so the sample's"ef_grid": null(projects_compute_sample.json:385) holds.GridFactorAppliedholds theGridFactorInputechoed back (includingscopeandboundary) plusvalue_delivered(the factor actually multiplied) andconversion: "none" | "generated_to_delivered".- No numeric field may be named so that it matches the guard's
_GRID_NAME(test_b6.py:178).value*andloss_ratenames do not match it. - No other new fields on
B6ResultorModuleSummary, so the golden shape holds.
8.4 The multiply, with units at each step¶
A pure helper in b6.py holds the arithmetic, b6_carbon(e_kwh_per_year, ef_delivered, rsp_years) -> float,
and compute_b6 calls it. Keeping it below the provenance boundary lets the parity test (T11) exercise
the formula without inventing provenance for the workbook's D5.
E [kWh/a] = B6Input.annual_energy.value (delivered, supplied, with basis)
EF_del [kg CO2e/kWh] = ef.value if ef.basis = delivered
= ef.value / (1 − ef.loss_rate) if ef.basis = generated and loss cited
→ BLOCKED (S5b) if ef.basis = generated and no loss
g_B6 [kg CO2e/a] = E · EF_del
G_B6 [kg CO2e] = g_B6 · RSP RSP = functional_unit.rsp_years [a] (project.py:69)
- The evaluation order is fixed (EF_del, then g, then G), so tests can use
rel=1e-12. - RSP enters only through the
rsp_yearsthatcompute_b6already receives (project_compute.py:175-176;b6.py:48). There is no RSP default. (1 − loss)is dimensionless. Delivered = generated · (1 − loss), so per delivered kWh the EF rises by 1/(1 − loss).
Decision table, replacing §4.2 S4/S5 (first match wins):
| # | Condition | B6Status |
ModuleStatus |
total |
reason |
|---|---|---|---|---|---|
| S1 | no energy (EF ignored) | not_provided | not_provided | null | B6_NOT_PROVIDED + B6_EF_GRID_NOT_SUPPLIED |
| S2 | energy, no carrier | — | — | — | 422, unchanged |
| S3 | diesel (EF ignored, ef_grid result null) |
blocked | incomplete | null | B6_DIESEL_OUT_OF_SCOPE, unchanged |
| S4 | grid, no ef_grid |
blocked | incomplete | null | B6_EF_GRID_NOT_SUPPLIED, including 0 kWh (AR-90) |
| S5b | grid, generated EF, no loss rate |
blocked | incomplete | null | B6_EF_NEEDS_LOSS_RATE, including 0 kWh |
| S5a | grid, usable EF (any scope or boundary, value ≥ 0) |
computed | complete | G_B6 |
null |
- 0 kWh with a usable EF → S5a at exactly 0.0 (the
work_impactzero rule,_common.py:140-155, with a factor present). The same holds for an EF of 0 with any energy (O3). - AR-90 still governs 0 kWh without a usable factor (S4, S5b → BLOCKED).
_b6_summary(project_compute.py:144-159) mapsCOMPUTED→completewithtotaland keepssecondary(the kWh). Its docstring "B6 is never complete yet" goes.- Whole-life (P2): the code is unchanged (
project_compute.py:200-218). The total is null while any of A1-A3, B4 or B6 is not complete, and computes when all three are. That includes the all-f = 0B4 path. Shares follow_with_shares(:162-171). A whole-life of exactly 0 leaves every share null, as today. The B6 number shows at module level (ModuleSummary.total,detail.b6.total) whatever A1-A3 and B4 are doing.
8.5 Reason strings (b6.py)¶
B6_EF_GRID_BLOCKEDis renamedB6_EF_GRID_NOT_SUPPLIED, with the text: "Carbon BLOCKED: no EF_grid (kg CO2e/kWh) was supplied with this request (AR-65). EF_grid is a runtime input with provenance; none is stored or defaulted. The supplied annual energy is reported." This is the only text that moves in the sample golden (P1, 3 occurrences).- New
B6_EF_NEEDS_LOSS_RATE: "Carbon BLOCKED: the supplied EF_grid is on a generated basis and no cited T&D loss rate was supplied to convert it to the delivered basis B6 meters (AR-65). The supplied annual energy is reported." - These are updated to match: the module docstring (
b6.py:16-27); theEnergyCarrier,B6StatusandB6Resultdocstrings; the route description (routes/projects.py:29-30); and theproject_compute.py:14-20docstring. - Docstrings and the route description reach OpenAPI, so
docs/api-reference/openapi.jsonandfrontend/src/api/schema.gen.tsare regenerated (CIgen:api,ci.yml:45-49). Neither is a golden.
8.6 Reference figure — methodology documentation and test inputs only¶
- Ember 0.540 kg CO2e/kWh (Senegal, 2024, generated basis, CC BY), through the WDI T&D loss of 13.06 % (2023), gives 0.621 kg CO2e/kWh delivered (0.540 / 0.8694 = 0.62112).
- Sensitivity: the Senelec loss of 18.78 % gives 0.665 (0.540 / 0.8122 = 0.66486).
- Triangulation: CDM 0.559, IFI 0.568.
- Ember's
scopeandboundaryare read from its methodology note when a user enters the record. This spec does not assert them.
These numbers may appear in METHODOLOGY.md, in the B6 methodology spec, and as test-local
inputs under backend/tests/. They must not appear under backend/app/, in data/grid_factors/, in
OpenAPI examples, or in the frontend, where pre-filling is barred by O2.
8.7 Test plan¶
Unit tests go in backend/tests/unit/test_b6.py, and T9/T10 also in test_project_compute.py. The
reference EF is a test-local constant under backend/tests/. The guard scans only APP_DIR
(test_b6.py:189-196). Expected values are computed in the test, not pasted.
| T | Case | Expected |
|---|---|---|
| T1 | grid 12 000 kWh, EF 0.621 delivered, RSP 30 |
computed; total = 12 000 · 0.621 · 30 = 223 560.0 kg; value_delivered = 0.621; conversion = none |
| T2 | grid 12 000 kWh, EF 0.540 generated, loss 0.1306 (+year, source), RSP 30 |
computed; value_delivered = 0.540/0.8694; total ≈ 223 602.48 kg; conversion = generated_to_delivered |
| T2b | same, loss 0.1878 (sensitivity) | total ≈ 239 349.91 kg |
| T3 | grid, generated EF, no loss |
blocked, B6_EF_NEEDS_LOSS_RATE; total and ef_grid null; kWh reported |
| T4 | grid, no EF | blocked, B6_EF_GRID_NOT_SUPPLIED. The existing parametrised test (test_b6.py:122-149) with the constant renamed |
| T5 | 0 kWh + usable EF; and 12 000 kWh + EF value = 0 |
computed, total == 0.0 exactly, module complete |
| T5b | 0 kWh with no EF, or with generated-no-loss | blocked (AR-90, unchanged) |
| T6 | diesel + energy + EF supplied | blocked, B6_DIESEL_OUT_OF_SCOPE; ef_grid null (EF not applied) |
| T7 | RSP linearity, replacing test_rsp_is_echoed_and_nothing_depends_on_it_yet (test_b6.py:107-112) |
G(50)/G(30) = 5/3 (rel=1e-12) |
| T8 | 422s: each required provenance field missing or blank; value < 0 / NaN / inf; loss ∉ [0,1); loss without year or source; loss on a delivered EF |
422 naming the field |
| T8b | scope = market_based and boundary = combustion_only accepted |
computed, both echoed in detail.b6.ef_grid (O3, O4) |
| T9 | route: A1-A3 complete, all rows f = 0 with basis, grid + delivered EF |
whole-life total = A1-A3 + 0 + G_B6; total_per_m2, total_per_m2_year per project_compute.py:212-216; shares non-null and sum to 1 (P2) |
| T10 | route: with an EF, inputs_echo carries it and input_hash differs from the no-EF hash; with no EF, inputs_echo.variants[0].b6 has no ef_grid key |
echo/hash contract (§8.2) |
| T11 | parity (B6-F1), backend/tests/regression/test_b6_keur_songho.py: open reference/excel/2_lca_vn.xlsx (openpyxl is a declared dependency, pyproject.toml:32); read B5, C5, D5, cached E5 and cached F5; assert b6_carbon(B5·E5, D5, C5) == approx(F5, rel=1e-3) |
Passes exactly (cached 791 987.328). No cell value is written as a literal in the test. The workbook is read-only and never modified. |
Guards, which must stay green and unedited: test_guard_pattern_catches_what_it_should,
test_no_numeric_grid_factor_under_backend_app and test_grid_factors_dir_holds_only_gitkeep
(test_b6.py:182-201), plus _NUMERIC_ASSIGNMENT. No committed EF: not under backend/app/, not
in grid_factors/, and not as a literal in T11.
Proof plan, in the PR body:
# 1. KS goldens, fixtures/, the request fixture, grid_factors/ and reference/ are byte-identical.
git diff --exit-code origin/main -- backend/tests/fixtures/ \
backend/tests/golden/a1_a3_keur_songho.json backend/tests/golden/lci_materials.json \
backend/tests/golden/projects_compute_request.json backend/app/data/grid_factors/ reference/
# 2. The sample golden moves ONLY on the 3 reason strings: 3 lines out, 3 lines in, all of them the reason.
git diff -U0 origin/main -- backend/tests/golden/projects_compute_sample.json \
| grep '^[-+] ' | grep -v 'EF_grid (kg CO2e/kWh)' # must print nothing
git diff -U0 origin/main -- backend/tests/golden/projects_compute_sample.json | grep -c '^- ' # = 3
git diff -U0 origin/main -- backend/tests/golden/projects_compute_sample.json | grep -c '^+ ' # = 3
# 3. input_hash and inputs_echo are explicitly unchanged.
git diff -U0 origin/main -- backend/tests/golden/projects_compute_sample.json | grep -c 'input_hash\|inputs_echo' # = 0
# 4. The generated mock moves on the same 3 strings (CI: npm run mocks:check).
git diff -U0 origin/main -- frontend/src/mocks/fixtures/projects_compute_sample.ts \
| grep '^[-+] ' | grep -v 'EF_grid (kg CO2e/kWh)' # must print nothing
# 5. The #62 guards are green and unedited.
git diff -U0 origin/main -- backend/tests/unit/test_b6.py \
| grep -E '^[-+].*(_NUMERIC_ASSIGNMENT|_GRID_NAME|def test_(guard|no_numeric|grid_factors_dir))' # must print nothing
pytest backend/tests/unit/test_b6.py -k "guard or gitkeep"
The full gate per CLAUDE.md: pytest, ruff check ., ruff format --check ., mypy backend/app,
and CI (gen:api, mocks:check) green on origin.
8.8 PR scope — one PR, feat/phase1-b6-ef-grid-input¶
Backend:
- schemas/compute.py: GridFactorInput, GridFactorApplied, B6Status.COMPUTED, the B6Result types.
- domains/lca/b6.py: b6_carbon, S5a and S5b, the reason strings.
- services/project_compute.py: _b6_summary and the docstring.
- api/routes/projects.py: the description.
Tests: test_b6.py and test_project_compute.py (T1–T10), plus the new
regression/test_b6_keur_songho.py (T11).
Regenerated, not hand-edited:
- projects_compute_sample.json (3 strings);
- frontend/src/mocks/fixtures/projects_compute_sample.ts (npm run mocks:sync);
- frontend/src/api/schema.gen.ts;
- docs/api-reference/openapi.json.
Docs:
- module-map.md: the lca.b6 row becomes parity-verified (formula B6-F1); EF runtime-supplied;
D5 BLOCKED (AR-09).
- data-flow.md: the EF enters via the request.
- ROADMAP.md: step 4, plus a Session Log entry.
- This doc: the status line.
- The register (§8.9).
Out of scope: the wizard EF entry UI and the combustion-only warning (step 2: O2, AR-95); diesel
(AR-88); fork (a); any RSP change; any edit under reference/.
8.9 Register (Step 6 of the impl PR)¶
| Row | Change |
|---|---|
| AR-65 | Note: the runtime input path, the provenance schema and the multiply have landed. It stays Open, because no sourced value has been procured or documented for users yet. |
| AR-90 | Note: 0 kWh with a usable EF is S5a at 0. 0 kWh without one stays BLOCKED (the "confirm" item is unchanged). |
| AR-12 | Note: RSP linearity is now tested (T7) and a B6 number can be produced, so "decide before publishing" is live. |
| AR-09 | Note: B6-F1 is VALIDATED. B1!D5 is BLOCKED and is read only by the T11 parity test, never committed as a value. |
| AR-95 (new) | UI: show a visible warning when a supplied EF_grid is combustion_only (it understates B6; AR-65), and also surface market_based. The wizard EF field is user-entered only, never pre-filled or suggested; it may persist in the user's own draft (O2, #26). Category: UI. Owner: UNASSIGNED — Marco to route. Blocks: the step-2 B6 EF entry UI. Link: this doc §8.0 O2/O4. |