Skip to content

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_years as 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,yr and a provenanced EF_grid. A missing EF_grid is always BLOCKED (S4), whatever else is present.
  • Zero energy. annual_energy = 0 (allowed: ≥ 0) follows the work_impact zero-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 from work_impact, which makes zero work computable without a factor. Reason: B6 is the largest whole-life contributor, and a supplied "0 kWh" turning B6 complete with no factor on file would make the whole-life total complete on an unprovenanced zero. (Flag for review. If the orchestrator prefers strict work_impact symmetry, change S4 → S5 only for 0 and 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_grid is never derived from a supplied total, from A5's 0.26, or from B1!D5. E_del,yr is 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

  1. 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). Add compute_b6(b6: B6Input, *, rsp_years: int) -> B6Result implementing §4.2 S1/S3/S4. There is no EF_grid parameter: S5 is unreachable by construction in this PR, so the multiply step E · EF · RSP is 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.
  2. schemas/b6.py (new): B6Result with status (computed / blocked / not_provided, reusing MaterialResultStatus where it fits), annual_energy, carrier, basis, rsp_years, total: None, blocked_reason, and ef_grid: None with a description pointing to AR-65.
  3. schemas/compute.py:
  4. B6Input._energy: carrier required when annual_energy is set (S2 → 422). State "delivered energy, not thermal need" in the annual_energy description (§1).
  5. VariantDetail.b6: B6Result replaces B6Pending. Remove B6Pending (its only uses are compute.py:194-203 and project_compute.py:219).
  6. Correct the EnergyCarrier docstring: grid BLOCKED on AR-65; diesel out of Phase-1 scope.
  7. services/project_compute.py: _b6_summary delegates to compute_b6. Replace B6_NOT_BUILT with the S3/S4 reasons. Pass rsp_years from request.project.functional_unit.
  8. frontend/src/wizard/draft.ts: no logic change is needed. carrier already defaults to "grid". Regenerate the API types; the Results view reads the new detail shape.
  9. Docs: module-map.md lca.b6 row stub → implemented (fork-(b) plumbing, BLOCKED on AR-65). This is not parity-verified: there is no B1-sheet reconciliation yet (§5.3). Also update data-flow.md, ROADMAP.md step 4 and the register (§6).
  10. Out of scope: the EF_grid input field (AR-65 closure), any energy / thermal code (fork a), any RSP change, diesel factors, and correcting the interpolation.py docstring (record it under AR-64 instead).

5.2 Tests (unit, backend/tests/unit/)

  • S1: no energy → not_provided, total null, secondary null.
  • S2: energy without carrier → 422 at the route boundary.
  • S3: diesel + energy → incomplete, kWh in secondary, reason names the diesel cut-off. Assert that no EF_grid term appears.
  • S4: grid + energy → incomplete, total null, kWh in secondary, reason cites AR-65. Same for annual_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: grep for any float literal assigned to a grid-factor name under backend/app/ fails the test, as does any file in data/grid_factors/ other than .gitkeep (AR-65 / IEA licence).
  • RSP: rsp_years is echoed into B6Result. 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)

  1. AR-80 — fork (a) or fork (b). (Marco.)
  2. AR-12 / #33(b) — 30 or 50 a, before publishing. Not before the PR.
  3. §4.2: should zero energy without a factor be BLOCKED (proposed) or complete at 0 (strict work_impact symmetry)?
  4. 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

  • B6Status gains COMPUTED = "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. GridFactorApplied holds the GridFactorInput echoed back (including scope and boundary) plus value_delivered (the factor actually multiplied) and conversion: "none" | "generated_to_delivered".
  • No numeric field may be named so that it matches the guard's _GRID_NAME (test_b6.py:178). value* and loss_rate names do not match it.
  • No other new fields on B6Result or ModuleSummary, 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_years that compute_b6 already 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_impact zero 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) maps COMPUTED → complete with total and keeps secondary (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 = 0 B4 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_BLOCKED is renamed B6_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); the EnergyCarrier, B6Status and B6Result docstrings; the route description (routes/projects.py:29-30); and the project_compute.py:14-20 docstring.
  • Docstrings and the route description reach OpenAPI, so docs/api-reference/openapi.json and frontend/src/api/schema.gen.ts are regenerated (CI gen: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 scope and boundary are 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.