From ba2d6e1cbbdd32d2019c1e3bc3cd4afc8459c7ba Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 17:21:50 +0000 Subject: [PATCH 01/16] docs: handover post S0380.177..179 + CI/test-move infra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the corpus state (36 EXACT + 5 pinned community-heating variants), the SAP 302 CHP credit cluster as the highest-leverage remaining front, the unresolved 0.8523 / 0.1994 worksheet-factor mysteries to per-line-walk before hypothesising, and — importantly — the new test layout (tests/domain/sap10_calculator/) that changes every verification command. Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_POST_S0380_179.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_POST_S0380_179.md diff --git a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_179.md b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_179.md new file mode 100644 index 00000000..e13f4d56 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_179.md @@ -0,0 +1,172 @@ +# Handover — post Slices S0380.177..179 (+ infra/CI work) + +Branch: `feature/per-cert-mapper-validation`. **HEAD `af8e0d94`** +(post merge from main). Predecessor: +[`HANDOVER_POST_S0380_176.md`](HANDOVER_POST_S0380_176.md). + +## TL;DR + +The 41-variant heating-systems corpus is now **36 EXACT + 5 pinned**. +The only remaining residuals are the **5 community-heating (CH) variants** +— all `SAP code 302/301/304` heat-network systems. Everything else +(oil, electric, solid fuel, ASHP/GSHP, PCDB, "no system") is EXACT on +all four metrics (ΔSAP/Δcost/ΔCO2/ΔPE). + +Three closure slices + four infra changes landed this session: + +| Slice / change | HEAD | Scope | +|---|---|---| +| S0380.177 | `5276282d` | **oil 6 boiler interlock from room-thermostat absence.** Control code 2101 ("no thermostatic control of room temperature") ⇒ no room thermostat ⇒ per RdSAP 10 §3 NOT interlocked despite cylinderstat=Yes (P960 "Boiler Interlock: No") ⇒ SAP 10.2 Table 4c(2) −5pp Space+DHW. New `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES={2101,2102}`; `no_interlock` ORs room-thermostat absence with stored-HW cylinderstat absence; Space −5pp leg now fires for Table 4b non-PCDB boilers. | +| S0380.178 | `c054d712` | **oil 6 circulation pump ×1.3 for absent room thermostat.** SAP 10.2 Table 4f footnote a) (PDF p.175) "Multiply by 1.3 if room thermostat is absent" ⇒ 41 × 1.3 = 53.3 kWh = ws (230c). Closes oil 6 FULLY (same root cause as .177). | +| S0380.179 | `f2062a2f` | **RdSAP 10 §10.7 electric-immersion default for "no system".** Cert lodges water code 999 (NON) + "cylinder present: No", but §10.7 substitutes an electric immersion on a Table 28 row-1 110 L cylinder + Table 29 row-1 insulation. New `_apply_rdsap_no_water_heating_system_default(epc)` rebinds the epc at the top of `cert_to_inputs` when `water_heating_code==999`. One fix closed HW (−594 kWh storage loss) AND the downstream space residual (+228, a HW-gains→MIT artifact). Closes "no system" FULLY. | +| appliances+cooking | `2f039aeb` | Threaded `appliances_kwh_per_yr` + `cooking_kwh_per_yr` (Appendix L L13/L14/L16a + L20) onto `SapResult`/`CalculatorInputs` for ADR-0014 BillDerivation. **Output-only, zero rating drift.** | +| test fixes | `0e484aaa` | Fixed 11 pre-existing CI failures from an absorbed PR: `test_appendix_u.py` signature drift + mislabelled "SAP 10.3"→10.2; `test_table_32.py` re-pinned oil(4)=5.44 / FAME(73)=7.64 to the worksheet-canonical values the table actually uses. | +| corpus PDFs | `d1c87d84` | Committed the 82 heating-corpus PDF fixtures (`sap worksheets/heating systems examples/`) so CI can run the residual pins. | +| **test move** | `d7d5084f` | **Moved all 5 calculator test dirs → `tests/domain/sap10_calculator/`** so CI (which collects `tests/`) runs them. SEE "Test layout changed" below — it changes every command. | + +## ⚠ Test layout changed this session — commands are different now + +The calculator tests **moved** out of `domain/sap10_calculator/.../tests` +into `tests/domain/sap10_calculator/{,worksheet,rdsap,climate,validation}`. +Cross-imports were rewritten `domain.sap10_calculator.worksheet.tests` +→ `tests.domain.sap10_calculator.worksheet`. Any old handover command +that references `domain/sap10_calculator/worksheet/tests/...` is STALE. + +**New full verification command** (replaces the old extended suite): + +```bash +PYTHONPATH=/workspaces/model python -m pytest \ + tests/domain/sap10_calculator/ \ + backend/documents_parser/tests/ \ + --no-cov -q -p no:cacheprovider +``` + +Expected at HEAD: **~2221 pass, 1 skipped, 0 fail** (the 1 skip is the +corpus blocked-variant `skipif`). The cascade-pin / golden / e2e +conformance suites are all under `tests/domain/sap10_calculator/`. + +**Two gotchas:** +1. `load_cells` tests (`tests/domain/sap10_calculator/worksheet/test_{dimensions,ventilation,water_heating}.py`) pin against the gitignored `2026-05-19-17-18 RdSap10Worksheet.xlsx` at repo root. `_xlsx_loader.load_cells` `pytest.skip()`s when the xlsx is absent — so they run locally and skip in CI. If you're missing the xlsx locally, those skip (not fail). +2. **Uncommitted `pytest.ini` change** (came in with a main pull) REMOVES `tests/` + `domain/sap10_ml/tests` from `testpaths`. HEAD has them; the working tree strips them. This is NOT a slice change — confirm with the user before committing it, because removing `tests/` would un-collect the moved calculator tests. + +## Current residual state at HEAD `af8e0d94` + +### 36 variants EXACT (all four metrics < tolerance) + +``` +ashp, gshp, +electric 1, 2, 3, 5, 6, 7, 8, 9, 11, 12, 13, 14, +oil 1, oil 2, oil 3, oil 4, oil 5, oil 6, oil pcdb 1, oil pcdb 2, oil pcdb 3, +pcdb 1, pcdb 3, +solid fuel 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, +no system +``` + +### 5 community-heating variants pinned + +| Variant | SAP code | ΔSAP_c | Δcost | ΔCO2 | ΔPE | Closure driver | +|---|---:|---:|---:|---:|---:|---| +| CH6 (CHP/Coal) | 302 | −7.4942 | +£172.68 | −2939.67 | +7481.57 | SAP 302 CHP credit + DLF=1.0 P960 quirk | +| CH2 (CHP/Gas) | 302 | +0.5277 | −£12.16 | −1435.09 | +1123.01 | SAP 302 CHP credit (CO2 + PE) | +| CH4 (CHP/Oil) | 302 | +0.5277 | −£12.16 | −4401.85 | +111.58 | SAP 302 CHP credit (CO2) | +| CH3 (HP/Elec) | 304 | +0.0000 | −£0.00 | −98.92 | −457.54 | (372) electrical-distribution + HP COP | +| CH1 (Boilers/Gas) | 301 | +0.0000 | −£0.00 | −23.60 | −208.23 | (372) electrical-distribution factor | + +Blocked tier: **empty**. + +## Open fronts ranked by leverage + +### 1. SAP 302 CHP CO2/PE credit cascade (3 variants — CH2/CH4/CH6) — HIGHEST + +Closes the big CO2/PE residuals on CH2/CH4 AND the −7.49 SAP on CH6 +simultaneously. Spec: block 13b PE (PDF p.153) + 12b CO2 — the +displaced-electricity CHP credit lines (worksheet (363)-(366), +(464)/(466)/(468)): + +``` +Space heating from CHP (307a) × 100 ÷ (362) = ... (363) +less credit emissions −(307a)×(361) ÷ (362) = ... (364) +Water heated by CHP (310a) × 100 ÷ (362) = ... (365) +less credit emissions −(310a)×(361) ÷ (362) = ... (366) +Heat from heat source 2 [(307b)+(310b)] × 100 ÷ (467b) (468) +``` + +RdSAP 10 §C defaults (verified vs CH2/CH4/CH6 worksheet (461)/(462)): +CHP overall eff 75%, heat-to-power 2.0 → heat_eff 50% / electric_eff +25%; boiler eff 80%. The `.172` scaling helper already keys on +`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` — add code 302 there once the +split formula is in place; the `.173` predicate +`_is_community_heating_hw_from_main` auto-activates. + +**⚠ UNRESOLVED per-line caveat — walk before hypothesising.** The +Elmhurst worksheet (463) energy column = `spec_formula × 0.8523` +uniformly across non-CHP heat-network rows (the 0.8523 also shows in +CH1 (467)). It is NOT RdSAP 10 / SAP 10.2 spec-derived. Per +[[feedback-spec-floor-skepticism]] / [[feedback-software-no-special-handling]], +DUMP the worksheet per-line and reconcile 0.8523 before baking any CHP +formula into the cascade. Likely 2-3 slices. + +### 2. CH1/CH3 (372)/(472) electrical-distribution CO2/PE — DEFERRED + +CH1/CH3 are SAP + cost EXACT; only CO2/PE remain. Worksheet (372) CO2 +factor = 0.1994 (block 11a) / 0.2114 (block 11b); PE = 1.7591 / 2.1872. +These don't match ANY Table 12 / 12d / 12e weighting derivable from the +(307) or (307)+(310) heating-demand monthly profile. (313) annual = +0.01 × (307) ONLY (verified across 5 variants, NOT 0.01 × (307+310) as +the spec text says). **Don't guess** — reverse-engineer the 0.1994 +factor from a wider variant set or find BRE documentation first. + +### 3. CH6 DLF=1.0 P960 quirk — architectural, likely pin-forever + +P960 input lodges `Distribution Loss: Two adjoining dwellings...` + +`Distribution Loss Value: 0.0` → ws (306) = 1.0000, but the Summary +doesn't carry anything distinguishing CH6 from CH4. Per §C3.1 the +manual-DLF override is legal but not surfaced by the Summary. +Recommendation: pin + document once the CHP credit lands. + +## Discipline (carried from every prior handover) + +- **Per-line walk worksheet → spec → fix.** All 3 slices this session + landed via per-line P960 dumps. Don't form a spec hypothesis without + per-line data (the 0.8523 + 0.1994 factors are the live examples). +- **Spec-floor skepticism cuts BOTH ways** — a spec-correct fix often + EXPOSES the next residual (oil 6 .177→.178; "no system" HW→space). + Apply the spec uniformly; the surfaced residual is the next target. +- **SAP 10.2 ONLY, never 10.3.** +- **Don't conflate `main_heating_category` and `sap_main_heating_code`** + — the Elmhurst mapper leaves `category=None` on Table 4b liquid-fuel + boilers; cascade gates must check both. +- **Target is < 1e-4 vs worksheet** — ΔSAP=0.07 is NOT closed. Re-pin + smaller; never widen tolerance, never xfail. +- **One slice = one commit**, spec citation in the message, trailer + `Co-Authored-By: Claude Opus 4.8 `. + +## Memories to load (in order) + +``` +project-heating-systems-corpus # HEAD af8e0d94, 36 EXACT + 5 pinned +feedback-sap-10-2-only-never-10-3 +feedback-software-no-special-handling +feedback-spec-floor-skepticism +feedback-worksheet-not-api-reference +feedback-spec-citation-in-commits +feedback-verify-handover-claims +feedback-zero-error-strict +feedback-commit-per-slice +feedback-aaa-test-convention +feedback-e2e-validation-philosophy +feedback-abs-diff-over-pytest-approx +feedback-one-e-minus-4-across-the-board +reference-unmapped-sap-code +reference-unmapped-api-code +project-oil-price-spec-divergence +``` + +## Master doc + +Architecture + API + validation: [`SAP_CALCULATOR.md`](SAP_CALCULATOR.md) +(§8 "Elmhurst-mirrored spec divergences" carries .163 HW dual-rate +annual + .164 §12.4.4 summer-immersion). If the CHP 0.8523 multiplier +resolves to an Elmhurst-vs-spec divergence, add §8.3. + +## Good luck. From 8452cf9e2df9bedb9fee59146d968292a975349e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:04:16 +0000 Subject: [PATCH 02/16] =?UTF-8?q?S0380.180:=20heat-network=20distribution?= =?UTF-8?q?=20pumping=20electricity=20(=C2=A7C3.2)=20=E2=80=94=20closes=20?= =?UTF-8?q?CH1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix C §C3.2 (PDF p.51), verbatim: "CO2 emissions and Primary Energy associated with the electricity used for pumping water through the distribution system are allowed for by adding electrical energy equal to 1% of the energy required for space and water heating." Worksheet line (313) = 0.01 × [(307)+(310)]; its CO2 (372) and PE (472) bill on the Table 12d/12e monthly factors for fuel code 50 ("electricity for pumping in distribution network"), weighted by the monthly heat profile per worksheet footnote (a). (307)m/(310)m = (space_demand + hw_output) / efficiency (the cascade models a heat network's generator efficiency as 1/DLF). This un-defers the (372)/(472) front the post-S0380.179 handover flagged "don't guess until the factor source is identified": the source is §C3.2 + Table 12d/12e code 50, NOT an empirical constant. The apparent 0.1994/0.2114 "factor" is an Elmhurst DISPLAY artifact — the worksheet shows the (372) energy column as 0.01×(307) (space only) while computing emissions on 0.01×(307+310) per the §C3.2 text. Verified EXACT line-by- line against the CH2 corpus worksheet: (372)=23.6007 CO2 (rating), (472)=208.2267 PE (demand). New `_heat_network_distribution_electricity` helper (gated on `_is_heat_network_main`) precomputes the energy + effective CO2/PE factors; three new CalculatorInputs fields + calculator.py CO2/PE summation terms (0.0/None → no-op for individually-heated certs). Closures: CH1 (Boilers/Gas) CO2 −23.60→−0.00, PE −208.23→+0.00 — FULLY EXACT CH3 (HP/Elec) CO2 −98.92→−75.32, PE −457.54→−249.32 (distribution component closed; code-304 community-HP COP remains) CH2/CH4/CH6 gain their (372)/(472) component (CO2 +23.6, PE +208.2); dominant CHP displaced-electricity credit residual (Table 12f + block 12b/13b) is next slice. No regression on the other 36 corpus variants (helper returns None off heat-network mains) + golden + U985 fixtures. 2223 pass + 1 skip + 0 fail; pyright net-zero 43→43. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_heating_systems_corpus.py | 26 ++++- domain/sap10_calculator/calculator.py | 28 ++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 96 +++++++++++++++++++ .../rdsap/test_cert_to_inputs.py | 78 +++++++++++++++ 4 files changed, 223 insertions(+), 5 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 69c0b1ae..14ccc2d9 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -674,11 +674,27 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # distribution" line — 118.38 kWh billed at electricity factors # (CO2 0.1993, PE 1.760), not heat-network factors — the cascade # doesn't currently meter this. Next follow-up slice. - _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-23.6007, expected_pe_resid_kwh=-208.2267), - _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1435.0874, expected_pe_resid_kwh=+1123.0063), - _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-98.9235, expected_pe_resid_kwh=-457.5428), - _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4401.8456, expected_pe_resid_kwh=+111.5798), - _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2939.6683, expected_pe_resid_kwh=+7481.5658), + # Slice S0380.180 wired the SAP 10.2 Appendix C §C3.2 (PDF p.51) + # heat-network distribution pumping electricity (worksheet (313) = + # 0.01 × [(307)+(310)]; CO2 (372) / PE (472) on Table 12d/12e fuel- + # code-50 monthly factors weighted by the monthly heat profile). + # CH1 (Boilers/Gas) closes FULLY — the (372)/(472) line was its + # entire remaining residual (un-defers the front the predecessor + # handover flagged "don't guess"; the factor source is §C3.2 + + # Table 12f, not an empirical constant). CH3 (HP/Elec) closes its + # distribution component (CO2 −98.92→−75.32, PE −457.54→−249.32); + # the remainder is the code-304 community-HP COP cascade (separate + # follow-up). CH2/CH4/CH6 gain their (372)/(472) component (CO2 + # +23.6, PE +208.2/+208.2/+208.2); their dominant CHP displaced- + # electricity credit residual (Table 12f + block 12b/13b) remains + # for the next slice. Elmhurst DISPLAYS the (372) energy column as + # 0.01 × (307) (space only) but computes emissions on 0.01 × + # (307+310) per the §C3.2 text — verified EXACT line-by-line. + _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1411.4867, expected_pe_resid_kwh=+1331.2330), + _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-75.3228, expected_pe_resid_kwh=-249.3161), + _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4378.2449, expected_pe_resid_kwh=+319.8065), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2916.0676, expected_pe_resid_kwh=+7689.7925), ) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 364ad23d..6b33c3f7 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -236,6 +236,17 @@ class CalculatorInputs: pumps_fans_primary_factor: Optional[float] = None lighting_primary_factor: Optional[float] = None electric_shower_primary_factor: Optional[float] = None + # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution + # pumping electricity. For community-heating mains the network pump + # energy = 1% of (space + water) heat generated (worksheet (313)); + # its CO2 / PE (worksheet (372)/(472)) bill on Table 12d/12e monthly + # electricity factors (fuel code 50) weighted by the monthly heat + # profile. The energy + effective factors are precomputed in + # cert_to_inputs. 0.0 / None for individually-heated certs (no + # distribution loop) leaves the cascade unchanged. + heat_network_distribution_kwh_per_yr: float = 0.0 + heat_network_distribution_co2_factor_kg_per_kwh: Optional[float] = None + heat_network_distribution_primary_factor: Optional[float] = None # Generation offsets — applied as a cost credit against the ECF # numerator. SAP 10.2 Appendix M: PV self-consumption + export # collapse to a single credit at the export rate (Table 12 code 60). @@ -596,6 +607,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: electric_shower_co2 = ( inputs.electric_shower_kwh_per_yr * electric_shower_co2_factor ) + # SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (372) — electricity + # for pumping water through a heat network's distribution system. + # Zero for individually-heated certs (factor None → 0.0). + heat_network_distribution_co2 = ( + inputs.heat_network_distribution_kwh_per_yr + * (inputs.heat_network_distribution_co2_factor_kg_per_kwh or 0.0) + ) co2 = ( main_heating_co2 + secondary_heating_co2 @@ -603,6 +621,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + pumps_fans_co2 + lighting_co2 + electric_shower_co2 + + heat_network_distribution_co2 ) # SAP 10.2 Appendix M1 §7 — subtract PV CO2 credit. Onsite consumption # offsets grid imports at the IMPORT CO2 factor (Table 12d weighted @@ -662,6 +681,12 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + inputs.lighting_kwh_per_yr * lighting_primary_factor + inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor ) + # SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (472) — heat-network + # distribution pumping electricity primary energy (CO2 sister above). + heat_network_distribution_primary_kwh = ( + inputs.heat_network_distribution_kwh_per_yr + * (inputs.heat_network_distribution_primary_factor or 0.0) + ) # SAP 10.2 Appendix M1 §8: PV onsite consumption credits at IMPORT # PEF (offsets grid imports); PV exports credit at the EXPORT PEF # ("electricity sold to grid, PV" — Table 12 code 60 = 0.501). When @@ -696,6 +721,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: space_heating_primary_kwh + hot_water_primary_kwh + other_primary_kwh + + heat_network_distribution_primary_kwh - pv_primary_offset_kwh, ) primary_energy_per_m2 = primary_energy_kwh / tfa if tfa > 0 else 0.0 @@ -738,6 +764,8 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: "hot_water_co2_kg_per_yr": hot_water_co2, "pumps_fans_co2_kg_per_yr": pumps_fans_co2, "lighting_co2_kg_per_yr": lighting_co2, + "heat_network_distribution_co2_kg_per_yr": heat_network_distribution_co2, + "heat_network_distribution_pe_kwh_per_yr": heat_network_distribution_primary_kwh, "space_heating_pe_kwh_per_m2": space_heating_primary_kwh / tfa if tfa > 0 else 0.0, "hot_water_pe_kwh_per_m2": hot_water_primary_kwh / tfa if tfa > 0 else 0.0, "other_pe_kwh_per_m2": other_primary_kwh / tfa if tfa > 0 else 0.0, diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5e3f5a77..11877d8b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -913,6 +913,77 @@ def _heat_network_dlf(age_band: Optional[str]) -> float: raise UnmappedSapCode("heat_network_age_band", age_band) +# SAP 10.2 Table 12 fuel code 50 — "electricity for pumping in +# distribution network". Its CO2 / PE factors vary by month per Table +# 12d / 12e (= standard-electricity profile); worksheet (372)/(472) +# footnote (a) applies the monthly factors weighted by the heat profile. +_ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE: Final[int] = 50 +# SAP 10.2 Appendix C §C3.2 (PDF p.51) — pumping energy = 1% of the +# energy required for space and water heating. +_HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT: Final[float] = 0.01 + + +def _heat_network_distribution_electricity( + main: Optional[MainHeatingDetail], + space_heating_monthly_kwh: tuple[float, ...], + hot_water_output_monthly_kwh: tuple[float, ...], + efficiency: float, +) -> Optional[tuple[float, float, float]]: + """SAP 10.2 Appendix C §C3.2 (PDF p.51) — electricity for pumping + water through a heat network's distribution system. + + Spec verbatim: "CO2 emissions and Primary Energy associated with the + electricity used for pumping water through the distribution system + are allowed for by adding electrical energy equal to 1% of the + energy required for space and water heating." Worksheet line (313) = + 0.01 × [(307) + (310)]; its CO2 (372) and PE (472) bill on the + Table 12d / 12e monthly factors for fuel code 50 ("electricity for + pumping in distribution network"), weighted by the monthly heat + profile per worksheet footnote (a). + + (307)m = space-heating fuel and (310)m = water-heating fuel: for a + heat network the cascade models the heat-generator efficiency as + 1/DLF, so fuel = q_useful / efficiency = q_useful × DLF. The + monthly weighting of the Table 12d/12e factor is shape-only (the DLF + scalar cancels), and the energy carries the DLF. + + Returns (energy_kwh, co2_factor, pe_factor) for heat-network mains + (Table 4a 301-304 / category 6); None otherwise so the default + 0.0 / None fields leave individually-heated certs unchanged. + + NB Elmhurst's worksheet DISPLAYS the (372) energy column as 0.01 × + (307) (space only) but computes the EMISSIONS on 0.01 × (307+310) + per the §C3.2 text — verified line-by-line against the community- + heating corpus worksheets. We mirror the spec text (space + water). + """ + if not _is_heat_network_main(main) or efficiency <= 0.0: + return None + distribution_monthly_kwh = tuple( + _HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT * (sh + hw) / efficiency + for sh, hw in zip( + space_heating_monthly_kwh, hot_water_output_monthly_kwh + ) + ) + energy_kwh = sum(distribution_monthly_kwh) + if energy_kwh <= 0.0: + return None + co2_monthly = co2_monthly_factors_kg_per_kwh( + _ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE + ) + pe_monthly = pe_monthly_factors_kwh_per_kwh( + _ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE + ) + if co2_monthly is None or pe_monthly is None: + return None + co2_factor = sum( + kwh * f for kwh, f in zip(distribution_monthly_kwh, co2_monthly) + ) / energy_kwh + pe_factor = sum( + kwh * f for kwh, f in zip(distribution_monthly_kwh, pe_monthly) + ) / energy_kwh + return (energy_kwh, co2_factor, pe_factor) + + @dataclass(frozen=True) class PriceTable: """Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and @@ -6125,6 +6196,16 @@ def cert_to_inputs( tariff=_rdsap_tariff(epc), ) + _hw_extra_standing + # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution + # pumping electricity (worksheet (313)/(372)/(472)). None for + # individually-heated certs. + heat_network_distribution = _heat_network_distribution_electricity( + main, + space_heating_result.total_space_heating_monthly_kwh, + hw_monthly_kwh_for_factors, + eff, + ) + return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -6214,6 +6295,21 @@ def cert_to_inputs( epc, energy_requirements_result.secondary_fuel_monthly_kwh, ), hot_water_co2_factor_kg_per_kwh=hw_co2_factor, + # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution + # pumping electricity (worksheet (313)/(372)/(472)). 0.0 / None + # on individually-heated certs. + heat_network_distribution_kwh_per_yr=( + heat_network_distribution[0] + if heat_network_distribution is not None else 0.0 + ), + heat_network_distribution_co2_factor_kg_per_kwh=( + heat_network_distribution[1] + if heat_network_distribution is not None else None + ), + heat_network_distribution_primary_factor=( + heat_network_distribution[2] + if heat_network_distribution is not None else None + ), # SAP 10.2 Table 12a Grid 2 (p.191) + Table 12d (p.194): pumps, # lighting, and the electric-shower end-use all bill via the # "All other uses" row → on off-peak tariffs blend the high / diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index f086cd36..6c6602c8 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -41,6 +41,7 @@ from domain.sap10_calculator.exceptions import ( from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] + _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] @@ -153,6 +154,83 @@ def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> assert inputs.main_heating_efficiency == pytest.approx(1.0 / 1.41, abs=0.005) +def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None: + # Arrange — heat-network main (Table 4a code 301 = community heating, + # category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution + # pumping electricity = 1% of the (space + water) heat generated, + # i.e. 0.01 × [(307) + (310)] where (307)m/(310)m = (space_demand + + # hw_output) / efficiency. Its CO2 (372) / PE (472) bill on the + # Table 12d / 12e monthly factors for fuel code 50 ("electricity for + # pumping in distribution network"), weighted by the monthly heat + # profile per worksheet footnote (a). + from domain.sap10_calculator.tables.table_12 import ( + co2_monthly_factors_kg_per_kwh, + pe_monthly_factors_kwh_per_kwh, + ) + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, + sap_main_heating_code=301, + ) + space = (1000.0, 800.0, 600.0, 400.0, 200.0, 0.0, + 0.0, 0.0, 0.0, 300.0, 700.0, 1000.0) + hw = (200.0,) * 12 + efficiency = 1.0 / 1.45 # heat network models efficiency as 1/DLF + + # Act + result = _heat_network_distribution_electricity(main, space, hw, efficiency) + + # Assert — energy = 0.01 × Σ((space + hw) / eff) = 0.01 × 7400 × 1.45; + # factors = code-50 monthly weighted by the (space + hw) heat profile. + assert result is not None + energy_kwh, co2_factor, pe_factor = result + distribution_monthly = tuple( + 0.01 * (s + w) / efficiency for s, w in zip(space, hw) + ) + co2_monthly = co2_monthly_factors_kg_per_kwh(50) + pe_monthly = pe_monthly_factors_kwh_per_kwh(50) + assert co2_monthly is not None + assert pe_monthly is not None + expected_energy = 0.01 * (5000.0 + 2400.0) / efficiency + expected_co2_factor = sum( + d * f for d, f in zip(distribution_monthly, co2_monthly) + ) / expected_energy + expected_pe_factor = sum( + d * f for d, f in zip(distribution_monthly, pe_monthly) + ) / expected_energy + assert abs(energy_kwh - expected_energy) <= 1e-9 + assert abs(energy_kwh - 0.01 * 7400.0 * 1.45) <= 1e-9 + assert abs(co2_factor - expected_co2_factor) <= 1e-9 + assert abs(pe_factor - expected_pe_factor) <= 1e-9 + + +def test_heat_network_distribution_electricity_none_for_individual_main() -> None: + # Arrange — an individually-heated gas-boiler main (category 2, no + # heat-network SAP code). §C3.2 pumping electricity applies only to + # heat networks, so no distribution line should be emitted. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + ) + space = (1000.0,) * 12 + hw = (200.0,) * 12 + + # Act + result = _heat_network_distribution_electricity(main, space, hw, 0.85) + + # Assert + assert result is None + + def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: # Arrange — when main heating is a heat network AND water heating # inherits from main (water_heating_code=901), the HW also incurs From 02a89bcb397c52b2f81aa2920f33f27b0b8d8022 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:11:28 +0000 Subject: [PATCH 03/16] S0380.181: tighten heat-systems corpus residual tolerances to 1e-4 (all metrics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus residual-pin tolerances had drifted looser than the comment above them claimed ("pin at 1e-4 relative to lodged precision"): SAP was 1e-3, cost ±£0.01, CO2 ±0.1 kg, PE ±0.1 kWh. A ±0.1 kg CO2 band could silently mask a ~0.09 kg drift on a variant we report as EXACT. The worksheet pins are extracted from the P960 PDF text, which prints 4 d.p., so the hard residual floor is ~5e-5 (half a unit in the last printed digit) regardless of cascade precision. 1e-4 sits just above that floor. All 41 variants hold at uniform 1e-4 on continuous SAP, cost, CO2 AND PE — confirming the 37 EXACT variants are genuinely exact to PDF print-rounding and the looser bands were masking nothing. Aligns the guard with [[feedback-zero-error-strict]] / [[feedback-continuous-sap-tolerance]] (basically zero error across all four metrics). Test-only change; no cascade behaviour touched. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_heating_systems_corpus.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 14ccc2d9..a09e3ae7 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -68,13 +68,18 @@ _CORPUS_ROOT = ( # Per-pin absolute tolerances. Worksheet `SAP value` lodges 4 d.p., -# (255) total fuel cost 4 d.p., (272) total CO2 4 d.p., (286) Total -# Primary energy kWh/year 4 d.p. — pin at 1e-4 relative to lodged -# precision so any drift outside cascade float noise fires. -_SAP_RESID_ABS_TOLERANCE = 0.001 -_COST_RESID_ABS_TOLERANCE_GBP = 0.01 -_CO2_RESID_ABS_TOLERANCE_KG = 0.1 -_PE_RESID_ABS_TOLERANCE_KWH = 0.1 +# (255)/(355) total fuel cost 4 d.p., (272)/(383) total CO2 4 d.p., +# (286)/(483) Total Primary energy kWh/year 4 d.p. — so the hard floor +# on any residual is ~5e-5 (half a unit in the last printed digit), +# independent of cascade precision. Pin at 1e-4 on EVERY metric (per +# [[feedback-zero-error-strict]] / [[feedback-continuous-sap-tolerance]] +# — basically zero error across continuous SAP, cost, CO2 and PE) so +# any drift beyond PDF print-rounding fires loudly. All 41 variants hold +# at this tolerance; closures re-pin the smaller residual, never widen. +_SAP_RESID_ABS_TOLERANCE = 0.0001 +_COST_RESID_ABS_TOLERANCE_GBP = 0.0001 +_CO2_RESID_ABS_TOLERANCE_KG = 0.0001 +_PE_RESID_ABS_TOLERANCE_KWH = 0.0001 @dataclass(frozen=True) From 8e86de225721ca4e2a2b9180bae01c95f6d10223 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:22:51 +0000 Subject: [PATCH 04/16] =?UTF-8?q?S0380.182:=20community-heating=20CHP+boil?= =?UTF-8?q?ers=20CO2/PE=20credit=20(=C2=A712b/13b)=20=E2=80=94=20closes=20?= =?UTF-8?q?CH2/CH4=20CO2+PE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community heating "CHP and boilers" (SAP code 302). Per unit of network heat fuel H = (307)+(310) the effective generation factor is: chp×100/(362)×f_fuel − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f_fuel (363)/(463) CHP fuel = chp_frac × 100/heat_eff × f_fuel (364)/(464) less credit = −chp_frac × elec_eff/heat_eff × f_disp (368)/(468) boiler fuel = (1−chp_frac) × 100/boiler_eff × f_fuel f_fuel = Table 12 heat-network fuel factor (the CHP unit and the back-up boilers burn the same community fuel — verified vs CH2 gas / CH4 oil / CH6 coal worksheets (363)/(368)); f_disp = Table 12f (PDF p.196) credit for the CHP-generated electricity. RdSAP 10 §C (p.58) defaults: heat eff 50% (362), electrical eff 25% (361), boiler eff 80% (367); CHP heat frac 0.35 per-cert via community_heating_chp_fraction. New `_heat_network_code_302_effective_factor` + Table 12f flexible constants (0.420 CO2 / 2.369 PE) + RdSAP §C efficiency constants, wired into all four factor helpers (main + HW, CO2 + PE) ahead of the existing single-fuel / 1-over-heat-source-eff path. The worksheet (368)/(468) boiler emissions DISPLAY rounded/mis-aligned in the PDF, but the (373)/(473)/(386)/(486) totals reconcile only with the boiler at the full Table 12 factor — verified EXACT. Two spec citations applied: - Table 12f flexible-operation default for RdSAP community CHP is an Elmhurst engine choice (Table 12f notes make "standard" the default); mirrored per [[feedback-software-no-special-handling]] and documented in SAP_CALCULATOR.md §8.3. - Table 12 heat-network oil/biodiesel CO2 (codes 53/56) corrected 0.298 → 0.335 per Table 12 (p.189) "assumes 'gas oil'"; the code-302 oil cascade (CH4) was the first to exercise it. PE 1.180 was already correct. No other variant uses these codes (no regression). Closures (CO2 + PE only — the CHP credit does not touch cost/SAP): CH2 (CHP/Gas) CO2 −1411.49→+0.0000, PE +1331.23→+0.0000 EXACT CH4 (CHP/Oil) CO2 −4378.24→−0.0000, PE +319.81→−0.0000 EXACT CH6 (CHP/Coal) CO2/PE re-pinned (+2411.54 / +5023.48) — its worksheet lodges a manual DLF=1.0 the Summary doesn't carry, so cascade DLF=1.45 over-scales H; same root as the CH6 SAP −7.49 / cost +£172 (separate DLF front). CH2/CH4 are now CO2+PE-exact but still carry the heat-network cost/SAP residual (+0.5277 SAP / −£12.16 cost, exposed by S0380.175 — cost-side, untouched here). CH3 unchanged (code 304 community-HP COP front). Corpus state: 37 variants EXACT on all four metrics (incl. CH1); remaining residuals are CH2/CH4 cost+SAP, CH3 CO2+PE (HP COP), CH6 all-metric (DLF quirk). 2223 pass + 1 skip + 0 fail (tolerances 1e-4 all metrics per S0380.181); pyright net-zero 43→43. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_heating_systems_corpus.py | 27 +++- .../sap10_calculator/docs/SAP_CALCULATOR.md | 31 +++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 124 ++++++++++++++++++ domain/sap10_calculator/tables/table_12.py | 10 +- .../rdsap/test_cert_to_inputs.py | 54 ++++++++ 5 files changed, 241 insertions(+), 5 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index a09e3ae7..f008d0ee 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -695,11 +695,32 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # for the next slice. Elmhurst DISPLAYS the (372) energy column as # 0.01 × (307) (space only) but computes emissions on 0.01 × # (307+310) per the §C3.2 text — verified EXACT line-by-line. + # + # Slice S0380.182 wired the SAP 10.2 §12b/13b community-heating + # "CHP and boilers" (SAP code 302) CO2/PE cascade: per unit of + # network heat fuel H = (307)+(310), the effective generation factor + # = chp_frac × 100/(362) × f_fuel − chp_frac × (361)/(362) × f_disp + # + (1−chp_frac) × 100/(367) × f_fuel, where f_fuel is the Table 12 + # heat-network fuel factor (CHP + back-up boilers burn the same + # community fuel) and f_disp is the Table 12f credit factor for the + # CHP-generated electricity (Elmhurst uses "flexible operation" + # 0.420 CO2 / 2.369 PE). RdSAP 10 §C (p.58) defaults: heat eff 50% / + # electrical eff 25% / boiler eff 80%; CHP frac 0.35 per-cert. Also + # fixed Table 12 heat-network-oil CO2 (codes 53/56 0.298→0.335 per + # Table 12 p.189 — the code-302 oil cascade was the first to use it). + # CH2 (gas) + CH4 (oil) CO2 + PE now EXACT (<1e-4). CH6 (coal) CO2/PE + # shift sign: its worksheet lodges a manual DLF=1.0 (two adjoining + # dwellings) the Summary doesn't carry, so the cascade's DLF=1.45 + # over-scales H — pin + the CH6 SAP −7.49 / cost +£172 are the same + # DLF quirk (separate front, likely pin-forever). CH2/CH4 SAP +0.5277 + # / cost −£12.16 is the heat-network cost/standing residual exposed + # by S0380.175 (cost-side, untouched by this CO2/PE slice). CH3 + # unchanged (code 304 community-HP COP front). _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), - _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1411.4867, expected_pe_resid_kwh=+1331.2330), + _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-75.3228, expected_pe_resid_kwh=-249.3161), - _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4378.2449, expected_pe_resid_kwh=+319.8065), - _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2916.0676, expected_pe_resid_kwh=+7689.7925), + _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=+2411.5399, expected_pe_resid_kwh=+5023.4766), ) diff --git a/domain/sap10_calculator/docs/SAP_CALCULATOR.md b/domain/sap10_calculator/docs/SAP_CALCULATOR.md index cbb1d1df..b8db1853 100644 --- a/domain/sap10_calculator/docs/SAP_CALCULATOR.md +++ b/domain/sap10_calculator/docs/SAP_CALCULATOR.md @@ -495,3 +495,34 @@ shape (Table 12 annual where spec literal says monthly), so the gate is implemented under the same `dual-rate → annual on top of monthly` discipline. If a second §12.4.4-eligible cert worksheet diverges from this rule it should be raised against this row before re-tuning. + +### 8.3 Community-heating CHP uses Table 12f "flexible operation" by default + +**Slice S0380.182.** For RdSAP-defaulted community heating with CHP +(SAP code 302) that is **not** in the PCDB, the displaced-electricity +credit (worksheet (364)/(366) CO2 and (464)/(466) PE) needs a Table 12f +(PDF p.196) "fuel factor for electricity generated by CHP". Table 12f +offers three regimes per CHP vintage: + +| Regime | CO2 kg/kWh | PE | Note | +|---|---|---|---| +| export only | 0.394 | 2.345 | | +| **flexible operation** | **0.420** | **2.369** | needs assessor evidence | +| standard | 0.348 | 2.149 | "all other operating regimes" | + +Table 12f's own notes make **standard** the default ("Standard ... should +be used for all other operating regimes of gas CHP plants") and require +submitted evidence for **flexible**. Yet the BRE-approved Elmhurst rdSAP +engine emits **0.420 / 2.369 (flexible)** for these RdSAP-defaulted +community-CHP certs — verified line-by-line against the CH2 (gas) / CH4 +(oil) / CH6 (coal) corpus worksheets (364)/(366)/(464)/(466), all of +which carry 0.4200 CO2 and 2.3690 PE regardless of the community fuel. +RdSAP 10 §C (p.58) is silent on the Table 12f regime, so this is an +engine default not derivable from the spec text. + +Per [[feedback-software-no-special-handling]] / [[feedback-worksheet-not-api-reference]] +we mirror the engine: `_TABLE_12F_CHP_FLEXIBLE_{CO2,PE}` in +`cert_to_inputs`. CH2 + CH4 close to <1e-4 on both CO2 and PE with this +factor; "standard" (0.348/2.149) would leave a residual. If a future +PCDB-listed or evidence-backed CHP cert diverges, raise it against this +row before re-tuning. diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 11877d8b..7aa5ce36 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -984,6 +984,97 @@ def _heat_network_distribution_electricity( return (energy_kwh, co2_factor, pe_factor) +# SAP 10.2 Table 12 fuel code 302's worksheet path. Community heating +# "CHP and boilers" (Table 4a code 302). +_SAP_CODE_COMMUNITY_CHP_AND_BOILERS: Final[int] = 302 + +# SAP 10.2 Table 12f (PDF p.196) — fuel factors for the electricity +# GENERATED BY CHP (the displaced-grid credit, worksheet (364)/(464)). +# The BRE-approved Elmhurst rdSAP engine applies the "flexible +# operation" row (0.420 kg CO2/kWh, 2.369 PE) to RdSAP-defaulted +# community CHP that is not in the PCDB — verified line-by-line against +# the CH2/CH4/CH6 corpus worksheets (363)..(366) / (463)..(466). Table +# 12f's own notes make "standard" (0.348 / 2.149) the default and +# require assessor evidence for "flexible"; we mirror the certified +# engine per [[feedback-software-no-special-handling]] (documented as a +# spec divergence in SAP_CALCULATOR.md §8). +_TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH: Final[float] = 0.420 +_TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH: Final[float] = 2.369 + +# RdSAP 10 §C (PDF p.58) heat-network CHP defaults when the network is +# not in the PCDB: CHP overall efficiency 75% with heat-to-power ratio +# 2.0 → heat efficiency 50% (worksheet (362)) + electrical efficiency +# 25% (worksheet (361)); back-up boiler efficiency 80% (worksheet +# (367)). The CHP heat fraction (0.35 default) is per-cert via +# `community_heating_chp_fraction`. +_HEAT_NETWORK_CHP_HEAT_EFFICIENCY: Final[float] = 0.50 +_HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY: Final[float] = 0.25 +_HEAT_NETWORK_CHP_BOILER_EFFICIENCY: Final[float] = 0.80 + + +def _heat_network_code_302_effective_factor( + main: Optional[MainHeatingDetail], + *, + primary_energy: bool, +) -> Optional[float]: + """SAP 10.2 worksheet block 12b (CO2) / 13b (PE) for community + heating "CHP and boilers" (SAP code 302) — the effective per-kWh + factor to apply to the network heat fuel [(307) + (310)]. + + Per unit of network heat fuel H = (307) + (310), the worksheet sums: + + CHP fuel (363)/(463) = chp_frac × 100/(362) × f_fuel + less credit (364)/(464) = −chp_frac × (361)/(362) × f_disp + boiler fuel (368)/(468) = (1−chp_frac) × 100/(367) × f_fuel + + where f_fuel is the Table 12 heat-network fuel factor (the CHP unit + and the back-up boilers burn the same community fuel — verified vs + CH2 gas / CH4 oil / CH6 coal worksheets) and f_disp is the Table 12f + credit factor for the electricity the CHP generates. RdSAP 10 §C + defaults: (362) heat eff 50%, (361) electrical eff 25%, (367) boiler + eff 80%. + + Returns the blended factor for code-302 mains with the CHP-split + fields populated; None otherwise so callers fall through to the + existing single-fuel / heat-source-efficiency-scaling path. + + NB the worksheet PDF DISPLAYS the (368)/(468) boiler emissions + rounded/mis-aligned, but the (373)/(473)/(386)/(486) totals + reconcile only with the boiler at the FULL Table 12 fuel factor — + verified EXACT. + """ + if ( + main is None + or main.sap_main_heating_code != _SAP_CODE_COMMUNITY_CHP_AND_BOILERS + ): + return None + chp_fraction = main.community_heating_chp_fraction + boiler_fuel_code = main.community_heating_boiler_fuel_type + if chp_fraction is None or boiler_fuel_code is None: + return None + if primary_energy: + fuel_factor = primary_energy_factor(boiler_fuel_code) + displaced_factor = _TABLE_12F_CHP_FLEXIBLE_PE_KWH_PER_KWH + else: + fuel_factor = co2_factor_kg_per_kwh(boiler_fuel_code) + displaced_factor = _TABLE_12F_CHP_FLEXIBLE_CO2_KG_PER_KWH + boiler_fraction = 1.0 - chp_fraction + return ( + chp_fraction + * (1.0 / _HEAT_NETWORK_CHP_HEAT_EFFICIENCY) + * fuel_factor + - chp_fraction + * ( + _HEAT_NETWORK_CHP_ELECTRICAL_EFFICIENCY + / _HEAT_NETWORK_CHP_HEAT_EFFICIENCY + ) + * displaced_factor + + boiler_fraction + * (1.0 / _HEAT_NETWORK_CHP_BOILER_EFFICIENCY) + * fuel_factor + ) + + @dataclass(frozen=True) class PriceTable: """Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and @@ -2606,6 +2697,13 @@ def _main_heating_co2_factor_kg_per_kwh( - zero-fuel cases (sum monthly_kwh == 0 → effective factor None; annual factor is the safe degenerate value) """ + # SAP 10.2 §12b — community-heating CHP+boilers (code 302): the + # blended CHP-credit + boiler generation CO2 factor (S0380.182). + code_302_co2 = _heat_network_code_302_effective_factor( + main, primary_energy=False, + ) + if code_302_co2 is not None: + return code_302_co2 if not _is_electric_main(main): # Heat-network mains (SAP codes 301 / 304) are non-electric per # `_is_electric_main` but require a heat-source-efficiency scaling @@ -2671,6 +2769,13 @@ def _main_heating_primary_factor( Fallback to annual `primary_energy_factor` for non-electric mains and the same edge cases as the CO2 helper (no Table 12a row, unknown dual-rate codes, zero-fuel).""" + # SAP 10.2 §13b — community-heating CHP+boilers (code 302): the + # blended CHP-credit + boiler generation PE factor (S0380.182). + code_302_pe = _heat_network_code_302_effective_factor( + main, primary_energy=True, + ) + if code_302_pe is not None: + return code_302_pe fuel = _main_fuel_code(main) if not _is_electric_main(main): # PE-side mirror of `_main_heating_co2_factor_kg_per_kwh` @@ -2929,6 +3034,16 @@ def _hot_water_co2_factor_kg_per_kwh( monthly HW fuel kWh — the calculator uses an annual-flat HW efficiency so the SHAPE of fuel monthly is identical to demand monthly, and `_effective_monthly_co2_factor` is shape-only).""" + # SAP 10.2 §12b — community-heating CHP+boilers (code 302) HW from + # main: the same blended CHP-credit + boiler generation CO2 factor + # as SH (S0380.182). Gated on WHC ∈ {901, 902, 914} so immersion- + # heated DHW on a CHP network keeps the lodged electric factor. + if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES: + code_302_co2 = _heat_network_code_302_effective_factor( + _water_heating_main(epc), primary_energy=False, + ) + if code_302_co2 is not None: + return code_302_co2 # Community heating + WHC ∈ {901, 902, 914}: HW heat is delivered # through the heat-network main, so HW CO2 must read the same # Table 12 heat-network code factor as SH, scaled by 1/heat_source_ @@ -2977,6 +3092,15 @@ def _hot_water_primary_factor( exactly to match the Elmhurst worksheet's (278) annual factor. The 41-variant heating-systems corpus closes its HW PE residual +25/+48 → 0 with this gate.""" + # SAP 10.2 §13b — community-heating CHP+boilers (code 302) HW from + # main: same blended CHP-credit + boiler generation PE factor as SH + # (S0380.182). Gated on WHC ∈ {901, 902, 914}. + if epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES: + code_302_pe = _heat_network_code_302_effective_factor( + _water_heating_main(epc), primary_energy=True, + ) + if code_302_pe is not None: + return code_302_pe # Mirror of `_hot_water_co2_factor_kg_per_kwh` community-heating # branch (S0380.173): WHC ∈ {901, 902, 914} on a heat-network main # routes HW PE through the same Table 12 heat-network code as SH, diff --git a/domain/sap10_calculator/tables/table_12.py b/domain/sap10_calculator/tables/table_12.py index b6248317..2c884128 100644 --- a/domain/sap10_calculator/tables/table_12.py +++ b/domain/sap10_calculator/tables/table_12.py @@ -192,8 +192,14 @@ CO2_KG_PER_KWH: Final[dict[int, float]] = { 30: 0.136, 31: 0.136, 32: 0.136, 33: 0.136, 34: 0.136, 35: 0.136, 38: 0.136, 40: 0.136, 39: 0.136, 60: 0.136, 36: 0.136, # Heat networks - 51: 0.210, 52: 0.241, 53: 0.298, 54: 0.375, 55: 0.269, - 56: 0.298, 57: 0.036, 58: 0.018, + # Heat-network oil (code 53 "assumes 'gas oil'") and mineral-oil/ + # biodiesel boilers (code 56) carry 0.335 kg CO2/kWh per SAP 10.2 + # Table 12 (p.189) — NOT the individual-appliance heating-oil factor + # (code 4 = 0.298). (Fixed in S0380.182 when the code-302 CHP CO2 + # cascade first exercised heat-network oil; PE 1.180 was already + # correct.) + 51: 0.210, 52: 0.241, 53: 0.335, 54: 0.375, 55: 0.269, + 56: 0.335, 57: 0.036, 58: 0.018, 41: 0.136, 42: 0.015, 43: 0.029, 44: 0.024, 45: 0.015, 46: 0.011, 47: 0.011, 48: 0.136, 49: 0.136, 50: 0.0, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 6c6602c8..20d7abb2 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -41,6 +41,7 @@ from domain.sap10_calculator.exceptions import ( from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] + _heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage] _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] @@ -209,6 +210,59 @@ def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> assert abs(pe_factor - expected_pe_factor) <= 1e-9 +def test_heat_network_code_302_chp_effective_factor_per_sap_10_2_block_12b_13b() -> None: + # Arrange — community heating "CHP and boilers" (SAP code 302) on + # the RdSAP 10 §C (PDF p.58) defaults: CHP heat frac 0.35, heat eff + # 50% / electrical eff 25%, boiler eff 80%. CH2-style gas network + # (community_heating_boiler_fuel_type = 51 → Table 12 gas 0.210 CO2 + # / 1.130 PE). SAP 10.2 §12b/13b effective generation factor: + # chp×100/(362)×f − chp×(361)/(362)×f_disp + (1−chp)×100/(367)×f + # with f_disp = Table 12f flexible operation (0.420 CO2 / 2.369 PE). + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=302, + community_heating_chp_fraction=0.35, + community_heating_boiler_fuel_type=51, + ) + + # Act + co2 = _heat_network_code_302_effective_factor(main, primary_energy=False) + pe = _heat_network_code_302_effective_factor(main, primary_energy=True) + + # Assert — gas: 0.35×2.0×0.210 − 0.35×0.5×0.420 + 0.65×1.25×0.210 + # = 0.147 − 0.0735 + 0.170625 = 0.244125 (matches the + # CH2 worksheet (386) generation factor); PE mirror with 1.130 / + # 2.369 = 1.29455. + assert co2 is not None + assert pe is not None + assert abs(co2 - 0.244125) <= 1e-9 + assert abs(pe - 1.29455) <= 1e-9 + + +def test_heat_network_code_302_effective_factor_none_for_non_302_main() -> None: + # Arrange — a code-301 heat-network boiler main (no CHP split). The + # §12b/13b CHP+boilers blend applies only to code 302; code 301 + # routes through the 1/heat-source-eff scaling path instead. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=301, + ) + + # Act / Assert + assert _heat_network_code_302_effective_factor(main, primary_energy=False) is None + assert _heat_network_code_302_effective_factor(main, primary_energy=True) is None + + def test_heat_network_distribution_electricity_none_for_individual_main() -> None: # Arrange — an individually-heated gas-boiler main (category 2, no # heat-network SAP code). §C3.2 pumping electricity applies only to From 803da062a2270865e4f1e261ba949ca63ac803db Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:29:57 +0000 Subject: [PATCH 05/16] =?UTF-8?q?S0380.183:=20community-heating=20HW=20bil?= =?UTF-8?q?ls=20at=20heat-network=20rate=20(=C2=A710b)=20=E2=80=94=20close?= =?UTF-8?q?s=20CH2/CH4=20fully?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §10b: hot water for a community-heating dwelling bills at the heat-network rate, not the cert-lodged fuel. Elmhurst §15.0 lodges `water_heating_fuel_type = "Mains gas"` (3.48 p/kWh) as a placeholder on community certs; the worksheet (342) Water-heating cost = (310) × the S0380.171 CHP heat-fraction blend — the SAME rate as space heating (340). Per-line walk of the CH2 block 10b: (340) space = 11837.83 × 0.037955 = 449.3047 (cascade EXACT) (342) water = 3854.12 × 0.037955 = 146.2830 (cascade billed 3854.12 × 0.0348 = 134.12 → −£12.16, the whole residual) (350) lighting + (351) standing → (355) 754.1502. `_hot_water_fuel_cost_gbp_per_kwh`'s `inherit_main_for_community_heating` path already routes HW cost through `_fuel_cost_gbp_per_kwh(main)` (the CHP blend), but its gate `_is_community_heating_hw_from_main` excluded code 302. S0380.182 wired the 302 CO2/PE credit via `_heat_network_code_302_effective_factor`, which intercepts the HW CO2/PE helpers ABOVE this predicate's branch — so extending the predicate to include 302 now affects ONLY the cost path. Closures: CH2 (CHP/Gas) SAP +0.5277→−0.0000, cost −£12.16→−£0.00 — FULLY EXACT CH4 (CHP/Oil) SAP +0.5277→−0.0000, cost −£12.16→−£0.00 — FULLY EXACT CH6 (CHP/Coal) SAP −7.49→−8.02, cost +£172.68→+£184.84 — its HW now also bills the blend, compounding the DLF=1.0 quirk (cascade DLF=1.45); same separate CH6 DLF front. Corpus now 39 variants EXACT on all four metrics (CH2/CH4 join). Open: CH3 CO2/PE (code-304 community-HP COP), CH6 all-metric (DLF=1.0 manual override the Summary doesn't carry). 2225 pass + 1 skip + 0 fail (tolerances 1e-4 all metrics); pyright net-zero 32→32. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_heating_systems_corpus.py | 18 +++++++++-- .../sap10_calculator/rdsap/cert_to_inputs.py | 31 +++++++++++-------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index f008d0ee..6ec58ca7 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -716,11 +716,23 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # / cost −£12.16 is the heat-network cost/standing residual exposed # by S0380.175 (cost-side, untouched by this CO2/PE slice). CH3 # unchanged (code 304 community-HP COP front). + # + # Slice S0380.183 closed the CH2/CH4 HW cost residual: per SAP 10.2 + # §10b the community-heating HW bills at the heat-network rate, not + # the Elmhurst §15.0 "Mains gas" placeholder. Worksheet (342) = + # (310) × the S0380.171 CHP heat-fraction blend (= the same rate as + # space heating (340)), not (310) × 3.48 p/kWh gas. Extended + # `_is_community_heating_hw_from_main` to include code 302 — the + # S0380.182 CO2/PE interception sits above this predicate's branch, + # so it now affects only the cost path. CH2 + CH4 are FULLY EXACT + # on all four metrics. CH6 SAP −7.49→−8.02 / cost +£172.68→+£184.84 + # (its HW now also bills the blend, compounding the DLF=1.0 quirk — + # same root, still the separate CH6 DLF front). _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), - _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), + _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-75.3228, expected_pe_resid_kwh=-249.3161), - _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000), - _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=+2411.5399, expected_pe_resid_kwh=+5023.4766), + _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.0219, expected_cost_resid_gbp=+184.8376, expected_co2_resid_kg=+2411.5399, expected_pe_resid_kwh=+5023.4766), ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 7aa5ce36..2dd9fc04 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1496,24 +1496,26 @@ def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]: def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool: """True iff the cert's WHC routes HW from the main heating system - (codes 901 / 902 / 914) AND the main is a single-source heat - network with a registered heat-source efficiency - (`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` — currently SAP code 301 - boilers and 304 HP). + (codes 901 / 902 / 914) AND the main is a heat network the cascade + can cost/emission-rate: a registered single-source heat-source + efficiency (`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` — SAP code 301 + boilers / 304 HP) OR code 302 (CHP and boilers). Elmhurst Summary §15.0 lodges `water_heating_fuel_type = "Mains gas"` on community-heating certs regardless of the actual heat-network source — without this guard the HW cost / CO2 / PE bills via the Mains-gas Table 12 code (3.48 p/kWh / 0.21 / 1.13) instead of the - heat-network code (4.24 p/kWh / Table 12 code 41 / 51). + heat-network rate. - SAP code 302 (CHP+boilers) is excluded because the 35%/65% split - requires the displaced-electricity credit line per spec block 13b - (464)/(466) on the HW side — same constraint as `_main_heating_ - co2_factor_kg_per_kwh` (S0380.172). Routing HW through main for - SAP 302 without the credit cascade would regress CO2 / PE; both - the SH and HW paths converge in a single follow-up slice that - wires the CHP credit + boiler-side factor split. + SAP code 302 (CHP+boilers) was previously excluded because the + 35%/65% split needs the displaced-electricity credit line (spec + block 12b/13b (364)/(366)/(464)/(466)). S0380.182 wired that credit + via `_heat_network_code_302_effective_factor`, which intercepts the + HW CO2/PE helpers ABOVE this predicate's branch — so including 302 + here now affects only the COST path, routing HW cost through + `_fuel_cost_gbp_per_kwh(main)` = the S0380.171 CHP heat-fraction + blend (the same rate as space heating, worksheet (342) = (310) × + blend). Closes the CH2/CH4 HW cost residual (S0380.183). """ if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: return False @@ -1521,7 +1523,10 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool: if not _is_heat_network_main(main): return False code = main.sap_main_heating_code if main is not None else None - return isinstance(code, int) and code in _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY + return isinstance(code, int) and ( + code in _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY + or code == _SAP_CODE_COMMUNITY_CHP_AND_BOILERS + ) def _main_heating_efficiency(epc: EpcPropertyData) -> float: From 82f7315f8db4048540b99edf44746554de58bbfd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:43:16 +0000 Subject: [PATCH 06/16] =?UTF-8?q?S0380.184:=20community=20electric-HP=20ne?= =?UTF-8?q?twork=20CO2/PE=20uses=20monthly=20Table=2012d/12e=20=E2=80=94?= =?UTF-8?q?=20closes=20CH3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 worksheet block 12b/13b (367)/(467) for a community heating electric heat pump (Table 4a code 304 → Table 12 fuel 41 "heat from electric heat pump"). The HP meters grid electricity, so per Table 12 note (s)/(t) + block 12b/13b footnote (a) its emission/PE factor is the MONTHLY Table 12d/12e cascade (fuel 41 = standard-electricity profile), weighted by the network heat profile, then × 1/heat-source-eff (1/COP): (367)/(467) = [(307)+(310)] / COP × Σ((307+310)_m × factor_m)/Σ(...) Per-line walk of CH3 (the displayed (367) 0.1535 / (467) 1.5717 are PDF artifacts; the (373)/(473) totals reconcile only with): CO2 factor = 0.15040 (monthly Table 12d wtd) vs cascade annual 0.136 PE factor = 1.55692 (monthly Table 12e wtd) vs cascade annual 1.501 Pre-slice the cascade routed code 304 through the non-electric branch (`_co2_factor_kg_per_kwh(main) × 1/COP` = annual × scaling). New `_is_heat_network_electric_main` (heat-network main whose fuel has a Table 12d monthly set — i.e. fuel 41) routes all four factor helpers (main + HW, CO2 + PE) through the monthly cascade × 1/COP. Non-electric heat networks (gas 51 / oil 53 / coal 54) have no monthly set → annual path unchanged (CH1, CH6 untouched). Closure (CH3 was already SAP+cost EXACT): CH3 (HP/Elec) CO2 −75.32→+0.0000 (= [(307+310)/3]×(0.1504−0.136)), PE −249.32→−0.0000 (× (1.5569−1.501)) — FULLY EXACT Corpus now 40/41 EXACT on all four metrics. Only CH6 remains: its worksheet lodges a manual DLF=1.0 ("two adjoining dwellings") absent from the Summary PDF (byte-identical to CH4 bar fuel type) — an architectural limit, not a cascade gap. 2226 pass + 1 skip + 0 fail (tolerances 1e-4 all metrics); pyright net-zero 43→43. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_heating_systems_corpus.py | 14 +++- .../sap10_calculator/rdsap/cert_to_inputs.py | 78 +++++++++++++++---- .../rdsap/test_cert_to_inputs.py | 33 ++++++++ 3 files changed, 108 insertions(+), 17 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 6ec58ca7..60f363f8 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -728,9 +728,21 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # on all four metrics. CH6 SAP −7.49→−8.02 / cost +£172.68→+£184.84 # (its HW now also bills the blend, compounding the DLF=1.0 quirk — # same root, still the separate CH6 DLF front). + # + # Slice S0380.184 closed CH3 (HP/Elec, code 304) CO2 + PE: an + # electric-HP heat network meters grid electricity, so per SAP 10.2 + # Table 12 note (s)/(t) + block 12b/13b footnote (a) its (367)/(467) + # factor is the MONTHLY Table 12d/12e (fuel code 41) weighted by the + # network heat profile, then × 1/COP — not the annual 0.136/1.501. + # New `_is_heat_network_electric_main` routes the four factor helpers + # through the monthly cascade for code 304 (fuel 41). CH3 was + # SAP/cost EXACT; CO2 −75.32→+0.0000 (= (307+310)/3 × (0.1504−0.136)) + # and PE −249.32→−0.0000 (× (1.5569−1.501)) now EXACT. Non-electric + # heat networks (CH1 gas 51, CH6 coal 54) have no monthly factor set + # → unchanged. _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), - _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-75.3228, expected_pe_resid_kwh=-249.3161), + _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000), _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.0219, expected_cost_resid_gbp=+184.8376, expected_co2_resid_kg=+2411.5399, expected_pe_resid_kwh=+5023.4766), ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 2dd9fc04..6bcb71c7 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -898,6 +898,21 @@ def _heat_network_heat_source_efficiency_scaling( return 1.0 / eff +def _is_heat_network_electric_main(main: Optional[MainHeatingDetail]) -> bool: + """True when the main is a heat network whose generator runs on grid + electricity (Table 4a code 304 → Table 12 fuel code 41 "heat from + electric heat pump"). Such networks meter electricity, so SAP 10.2 + Table 12 note (s)/(t) + worksheet block 12b/13b footnote (a) require + the MONTHLY Table 12d/12e factors (not the annual average), weighted + by the network heat profile, before the 1/heat-source-eff (1/COP) + scaling. Non-electric heat networks (gas/oil/coal boilers, codes + 51/53/54) have no monthly factor set and keep the annual Table 12 + value.""" + if not _is_heat_network_main(main): + return False + return co2_monthly_factors_kg_per_kwh(_main_fuel_code(main)) is not None + + def _heat_network_dlf(age_band: Optional[str]) -> float: """RdSAP 10 §10.11 + SAP 10.2 Table 12c distribution loss factor by age band. Defaults to the K-or-newer value (1.50) when band missing. @@ -2716,10 +2731,18 @@ def _main_heating_co2_factor_kg_per_kwh( # heat_source_eff × Table 12 CO2 factor. The cascade meters # network_input directly so scale the factor by 1/eff to land at # the spec's fuel-input × factor. - return ( - _co2_factor_kg_per_kwh(main) - * _heat_network_heat_source_efficiency_scaling(main) - ) + scaling = _heat_network_heat_source_efficiency_scaling(main) + hn_fuel = _main_fuel_code(main) + if _is_heat_network_electric_main(main) and hn_fuel is not None: + # Electric-HP heat network (code 304 / fuel 41): the HP runs + # on grid electricity → MONTHLY Table 12d factors weighted by + # the network heat profile, then × 1/COP (S0380.184). + monthly = _effective_monthly_co2_factor( + main_fuel_monthly_kwh, hn_fuel, + ) + if monthly is not None: + return monthly * scaling + return _co2_factor_kg_per_kwh(main) * scaling if tariff is Tariff.STANDARD: monthly = _effective_monthly_co2_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, @@ -2788,10 +2811,17 @@ def _main_heating_primary_factor( # (467) = network_input × 100 / heat_source_eff × Table 12 PE # factor; cascade meters network_input directly so scale by # 1/eff at lookup time. - return ( - primary_energy_factor(fuel) - * _heat_network_heat_source_efficiency_scaling(main) - ) + scaling = _heat_network_heat_source_efficiency_scaling(main) + if _is_heat_network_electric_main(main) and fuel is not None: + # Electric-HP heat network (code 304 / fuel 41): MONTHLY + # Table 12e factors weighted by the network heat profile, + # then × 1/COP (S0380.184). + monthly = _effective_monthly_pe_factor( + main_fuel_monthly_kwh, fuel, + ) + if monthly is not None: + return monthly * scaling + return primary_energy_factor(fuel) * scaling if tariff is Tariff.STANDARD: monthly = _effective_monthly_pe_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, @@ -3056,10 +3086,18 @@ def _hot_water_co2_factor_kg_per_kwh( # gas" is an Elmhurst placeholder that mis-routes the lookup. if _is_community_heating_hw_from_main(epc): main = _water_heating_main(epc) - return ( - _co2_factor_kg_per_kwh(main) - * _heat_network_heat_source_efficiency_scaling(main) - ) + scaling = _heat_network_heat_source_efficiency_scaling(main) + hn_fuel = _main_fuel_code(main) + if _is_heat_network_electric_main(main) and hn_fuel is not None: + # Electric-HP heat network HW (code 304 / fuel 41): MONTHLY + # Table 12d factors weighted by the HW profile, × 1/COP + # (S0380.184) — mirror of the SH branch. + monthly = _effective_monthly_co2_factor( + hw_monthly_kwh, hn_fuel, + ) + if monthly is not None: + return monthly * scaling + return _co2_factor_kg_per_kwh(main) * scaling fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_CO2_KG_PER_KWH @@ -3112,10 +3150,18 @@ def _hot_water_primary_factor( # scaled by 1/heat_source_eff per spec block 13a (463)/(467). if _is_community_heating_hw_from_main(epc): main = _water_heating_main(epc) - return ( - primary_energy_factor(_main_fuel_code(main)) - * _heat_network_heat_source_efficiency_scaling(main) - ) + scaling = _heat_network_heat_source_efficiency_scaling(main) + hn_fuel = _main_fuel_code(main) + if _is_heat_network_electric_main(main) and hn_fuel is not None: + # Electric-HP heat network HW (code 304 / fuel 41): MONTHLY + # Table 12e factors weighted by the HW profile, × 1/COP + # (S0380.184) — mirror of the SH branch. + monthly = _effective_monthly_pe_factor( + hw_monthly_kwh, hn_fuel, + ) + if monthly is not None: + return monthly * scaling + return primary_energy_factor(_main_fuel_code(main)) * scaling fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_PEF diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 20d7abb2..ccfdee3e 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -45,6 +45,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] + _is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] _is_off_peak_meter, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] @@ -244,6 +245,38 @@ def test_heat_network_code_302_chp_effective_factor_per_sap_10_2_block_12b_13b() assert abs(pe - 1.29455) <= 1e-9 +def test_is_heat_network_electric_main_true_only_for_electric_hp_network() -> None: + # Arrange — code 304 community heat pump (Table 12 fuel 41 = "heat + # from electric heat pump", which HAS monthly Table 12d/12e factors) + # vs code 301 community gas boilers (fuel 51, annual-only). SAP 10.2 + # Table 12 note (s)/(t): grid-electricity factors vary monthly, so + # the HP network must use Table 12d/12e; the gas-boiler network keeps + # the annual factor. + hp_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=41, # Table 12 fuel 41 = heat from electric HP + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=304, + ) + gas_boiler_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=51, # Table 12 fuel 51 = heat from gas boilers + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2306, + main_heating_category=6, + sap_main_heating_code=301, + ) + + # Act / Assert + assert _is_heat_network_electric_main(hp_main) is True + assert _is_heat_network_electric_main(gas_boiler_main) is False + assert _is_heat_network_electric_main(None) is False + + def test_heat_network_code_302_effective_factor_none_for_non_302_main() -> None: # Arrange — a code-301 heat-network boiler main (no CHP split). The # §12b/13b CHP+boilers blend applies only to code 302; code 301 From 57241322eada8bb218ac786f4af458dc875966d5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 19:21:28 +0000 Subject: [PATCH 07/16] =?UTF-8?q?S0380.185:=20record=20CH6=20pin-forever?= =?UTF-8?q?=20proof=20=E2=80=94=20distribution-loss=20is=20a=20Summary-exp?= =?UTF-8?q?ort=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CH6's P960 worksheet input lodges Distribution Loss = "Two adjoining dwellings sharing a single heating system" → (306) DLF = 1.0000, vs CH4's "Calculated" → 1.5 → (306) = 1.4500. That DLF choice swings SAP/cost/CO2/PE materially, but it is NOT present in the Summary PDF that the corpus pipeline consumes (Summary → ElmhurstSiteNotesExtractor → mapper → calculator). Proven empirically with a user-supplied controlled pair (CH adjoined dwellings/Summary_001431 (1) vs (2)): the two Summaries are byte-identical across every RdSAP INPUT field, differing only in the derived header (SAP 80 vs 75, bill £954 vs £1237, emissions 5.407 vs 7.394 t). A case-insensitive scan of the CH6 Summary for "distribution"/"adjoin" returns 0 hits. Since CH4/CH6 Summaries are themselves identical bar fuel type, no Summary-derivable rule can yield CH4=1.45 AND CH6=1.0. Doc-only change (comment in _EXPECTATIONS); 20/20 community-heating corpus tests pass. Closes the CH6 re-litigation: pin held. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_heating_systems_corpus.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 60f363f8..ab7889e4 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -740,6 +740,24 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # and PE −249.32→−0.0000 (× (1.5569−1.501)) now EXACT. Non-electric # heat networks (CH1 gas 51, CH6 coal 54) have no monthly factor set # → unchanged. + # + # CH6 — PROVEN PIN-FOREVER (Summary-export gap, not a mapper miss). + # CH6's P960 *worksheet input* lodges Distribution Loss = "Two + # adjoining dwellings sharing a single heating system" → Value 0.0 → + # (306) DLF = 1.0000, whereas CH4 lodges "Calculated" → 1.5 → (306) = + # 1.4500. That DLF choice swings SAP / cost / CO2 / PE materially. + # But it is NOT in the Summary PDF: a controlled pair differing ONLY + # by the adjoining-dwellings setting (`CH adjoined dwellings/Summary_ + # 001431 (1) vs (2).pdf`) is byte-identical across every RdSAP INPUT + # field — the two Summaries differ solely in the derived header + # (SAP 80 vs 75, bill £954 vs £1237, emissions 5.407 vs 7.394 t). A + # case-insensitive scan of the CH6 Summary for "distribution"/"adjoin" + # returns 0 hits. Since CH4 and CH6 Summaries are themselves identical + # bar fuel type, no Summary-derivable rule can yield CH4=1.45 AND + # CH6=1.0. Closing CH6 would require the P960 worksheet as a mapper + # input or an Elmhurst Summary-export change — neither is available. + # Pin held; do not re-litigate (verified 2026-06-02 with the + # user-supplied adjoining-dwellings pair). _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), From 5f4a78e4c94de4fa9a9331d30818dc8ab5b64629 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 19:54:45 +0000 Subject: [PATCH 08/16] S0380.186: pin golden PE/CO2 against full-precision dr87 worksheets (47 certs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing golden test compares calc PE/CO2 against the integer-rounded lodged register values (energy_consumption_current / co2_emissions_current), which conflates real calculator gaps with register rounding. This adds a parallel pin against each cert's Elmhurst dr87 worksheet (286)/(272) at full precision — a clean calculator-vs-Elmhurst signal for the 47 worksheet-backed certs (9 ASHP + 38 cohort-2). Findings at capture (calc − worksheet, on the worksheet's own decimal TFA): - 37/47 exact on both PE (<0.05 kWh/m²) and CO2 (<0.02 kg). - 10 higher-consumption gas certs carry PE +0.5..+1.5 kWh/m² AND CO2 -0.5..-1.1 kg simultaneously. PE-over + CO2-under on the same certs is the fingerprint of a small gas→electricity fuel-split difference (elec PE 1.51 > gas 1.13, but elec CO2 0.136 < gas 0.21), not a factor-value error — next slice candidate. An earlier "41/47 PE gaps" reading was a JSON-integer-TFA division artifact; comparing on the worksheet's decimal TFA (which the calculator also uses) collapses it to the real 10. Worksheet values frozen as literals (the dr87 PDFs are untracked, so not parsed at test time) per the worksheet_unrounded_sap convention. Also replaced a pre-existing pytest.approx with abs-diff to keep the file at zero pyright errors (feedback_abs_diff_over_pytest_approx). 106 passed (was 59); pyright 0 errors. Co-Authored-By: Claude Opus 4.8 --- .../rdsap/test_golden_fixtures.py | 142 +++++++++++++++++- 1 file changed, 139 insertions(+), 3 deletions(-) diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 435df408..ed509c99 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -54,6 +54,13 @@ _SAP_ABS_TOLERANCE = 0 _PE_ABS_TOLERANCE_KWH_PER_M2 = 0.01 _CO2_ABS_TOLERANCE_TONNES = 0.001 +# Worksheet-pin tolerances (calc − Elmhurst dr87 worksheet, full precision). +# These are deterministic so the tolerances are tight; they lock the +# current residual against the worksheet's full-precision (286)/(272) +# rather than the integer-rounded lodged register values. +_WS_PE_ABS_TOLERANCE_KWH_PER_M2 = 0.01 +_WS_CO2_ABS_TOLERANCE_KG = 0.01 + @dataclass(frozen=True) class _GoldenExpectation: @@ -473,6 +480,96 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( ) +@dataclass(frozen=True) +class _WorksheetPin: + """Full-precision PE / CO2 targets read from a cert's Elmhurst dr87 + worksheet (the "CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY + ENERGY" block of the *current* dwelling), plus the recorded calc + residual against them. + + Unlike `_GoldenExpectation` — which compares against the integer- + rounded lodged register values (`energy_consumption_current` / + `co2_emissions_current`) — these pin against the worksheet's + unrounded `(286)` primary energy and `(272)` CO2. That makes the + residual a *calculator-vs-Elmhurst* signal, free of register + rounding: a non-zero `expected_pe_resid` here is a genuine calc gap, + not lodged noise. + + `ws_pe_kwh_per_m2` = worksheet (286) / worksheet (4) total floor area + (the worksheet's own decimal TFA, not the JSON's integer); the + calculator uses the same decimal TFA, so the comparison is + apples-to-apples. `ws_co2_kg_per_yr` = worksheet (272) total CO2. + """ + + cert_number: str + ws_pe_kwh_per_m2: float + ws_co2_kg_per_yr: float + expected_pe_resid: float + expected_co2_resid_kg: float + + +# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). Findings at +# capture (HEAD post-S0380.185), calc − worksheet: +# - CO2: exact on 37/47 (<0.02 kg); the 10 higher-consumption gas certs +# carry a small −0.5..−1.1 kg under-count. +# - PE : exact on 37/47 (<0.05 kWh/m²); the SAME 10 carry a +0.5..+1.5 +# kWh/m² over-count. +# PE-over + CO2-under on the same certs is the fingerprint of a small +# gas→electricity fuel-split difference (electricity PE 1.51 > gas 1.13, +# but electricity CO2 0.136 < gas 0.21), not a factor-value error — the +# next slice candidate. Values frozen from the dr87 PDFs (untracked, so +# not parsed at test time) per the worksheet_unrounded_sap convention. +_WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( + _WorksheetPin(cert_number="0036-6325-1100-0063-1226", ws_pe_kwh_per_m2=213.4019, ws_co2_kg_per_yr=2125.4851, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="0100-5141-0522-4696-3463", ws_pe_kwh_per_m2=53.4939, ws_co2_kg_per_yr=427.6895, expected_pe_resid=+0.0235, expected_co2_resid_kg=+0.0195), + _WorksheetPin(cert_number="0200-3155-0122-2602-3563", ws_pe_kwh_per_m2=192.4660, ws_co2_kg_per_yr=2191.4589, expected_pe_resid=+1.1381, expected_co2_resid_kg=-1.0649), + _WorksheetPin(cert_number="0300-2403-2650-2206-0235", ws_pe_kwh_per_m2=224.9069, ws_co2_kg_per_yr=2445.3496, expected_pe_resid=+1.2239, expected_co2_resid_kg=-1.0351), + _WorksheetPin(cert_number="0310-2763-5450-2506-3501", ws_pe_kwh_per_m2=233.8452, ws_co2_kg_per_yr=1715.8602, expected_pe_resid=+1.4339, expected_co2_resid_kg=-0.8667), + _WorksheetPin(cert_number="0320-2126-2150-2326-6161", ws_pe_kwh_per_m2=177.7940, ws_co2_kg_per_yr=2312.8161, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0263, expected_co2_resid_kg=+0.0247), + _WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0189, expected_co2_resid_kg=+0.0093), + _WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0164, expected_co2_resid_kg=+0.0146), + _WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.6841, expected_co2_resid_kg=-0.7413), + _WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0387, expected_co2_resid_kg=+0.0199), + _WorksheetPin(cert_number="0380-2530-6150-2326-4161", ws_pe_kwh_per_m2=174.9107, ws_co2_kg_per_yr=2368.5251, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0390-2066-4250-2026-4555", ws_pe_kwh_per_m2=176.7478, ws_co2_kg_per_yr=2500.4581, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="0464-3032-0205-4276-3204", ws_pe_kwh_per_m2=179.2365, ws_co2_kg_per_yr=1845.9475, expected_pe_resid=+0.9242, expected_co2_resid_kg=-0.8342), + _WorksheetPin(cert_number="0652-3022-1205-2826-1200", ws_pe_kwh_per_m2=251.0214, ws_co2_kg_per_yr=2828.3691, expected_pe_resid=+0.9740, expected_co2_resid_kg=-0.7228), + _WorksheetPin(cert_number="1536-9325-5100-0433-1226", ws_pe_kwh_per_m2=180.8432, ws_co2_kg_per_yr=2054.3609, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2007-3011-9205-8136-3204", ws_pe_kwh_per_m2=172.6227, ws_co2_kg_per_yr=2567.5298, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="2031-3007-0205-1296-3204", ws_pe_kwh_per_m2=191.4198, ws_co2_kg_per_yr=2257.9561, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2102-3018-0205-7886-5204", ws_pe_kwh_per_m2=228.1961, ws_co2_kg_per_yr=4104.7798, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="2130-3018-4205-4686-5204", ws_pe_kwh_per_m2=181.4083, ws_co2_kg_per_yr=2364.3480, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="2225-3062-8205-2856-7204", ws_pe_kwh_per_m2=52.6750, ws_co2_kg_per_yr=389.8819, expected_pe_resid=+0.0272, expected_co2_resid_kg=+0.0209), + _WorksheetPin(cert_number="2336-3124-3600-0517-1292", ws_pe_kwh_per_m2=68.1077, ws_co2_kg_per_yr=458.6131, expected_pe_resid=+0.0171, expected_co2_resid_kg=+0.0085), + _WorksheetPin(cert_number="2536-2525-0600-0788-2292", ws_pe_kwh_per_m2=87.5683, ws_co2_kg_per_yr=375.6003, expected_pe_resid=+0.0107, expected_co2_resid_kg=+0.0045), + _WorksheetPin(cert_number="2590-3025-7205-9066-0200", ws_pe_kwh_per_m2=171.8691, ws_co2_kg_per_yr=2396.4327, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="2636-0525-2600-0401-2296", ws_pe_kwh_per_m2=52.5660, ws_co2_kg_per_yr=395.4880, expected_pe_resid=+0.0212, expected_co2_resid_kg=+0.0168), + _WorksheetPin(cert_number="2699-3025-5205-8066-0200", ws_pe_kwh_per_m2=168.4755, ws_co2_kg_per_yr=2498.3764, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2800-7999-0322-4594-3563", ws_pe_kwh_per_m2=89.2727, ws_co2_kg_per_yr=395.0757, expected_pe_resid=+0.0141, expected_co2_resid_kg=+0.0067), + _WorksheetPin(cert_number="3136-7925-4500-0246-6202", ws_pe_kwh_per_m2=238.6376, ws_co2_kg_per_yr=1752.3516, expected_pe_resid=+1.4560, expected_co2_resid_kg=-0.8858), + _WorksheetPin(cert_number="3336-2825-9400-0512-8292", ws_pe_kwh_per_m2=84.7840, ws_co2_kg_per_yr=458.0332, expected_pe_resid=+0.0099, expected_co2_resid_kg=+0.0058), + _WorksheetPin(cert_number="3800-8515-0922-3398-3563", ws_pe_kwh_per_m2=58.7712, ws_co2_kg_per_yr=440.6740, expected_pe_resid=+0.0195, expected_co2_resid_kg=+0.0156), + _WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0207, expected_co2_resid_kg=+0.0176), + _WorksheetPin(cert_number="4536-8325-3100-0409-1222", ws_pe_kwh_per_m2=181.7206, ws_co2_kg_per_yr=2109.2633, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="4800-3992-0422-0599-3563", ws_pe_kwh_per_m2=66.4814, ws_co2_kg_per_yr=259.3652, expected_pe_resid=+0.0417, expected_co2_resid_kg=+0.0188), + _WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0360, expected_co2_resid_kg=-0.0013), + _WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="7836-3125-0600-0526-2202", ws_pe_kwh_per_m2=183.0794, ws_co2_kg_per_yr=1817.2248, expected_pe_resid=+0.8789, expected_co2_resid_kg=-0.7461), + _WorksheetPin(cert_number="9036-0824-3500-0420-8222", ws_pe_kwh_per_m2=56.7016, ws_co2_kg_per_yr=433.6372, expected_pe_resid=+0.0192, expected_co2_resid_kg=+0.0155), + _WorksheetPin(cert_number="9285-3062-0205-7766-7200", ws_pe_kwh_per_m2=56.9079, ws_co2_kg_per_yr=454.7771, expected_pe_resid=+0.0185, expected_co2_resid_kg=+0.0156), + _WorksheetPin(cert_number="9370-3060-1205-3546-4204", ws_pe_kwh_per_m2=51.9889, ws_co2_kg_per_yr=494.0023, expected_pe_resid=+0.0242, expected_co2_resid_kg=+0.0229), + _WorksheetPin(cert_number="9380-2957-7490-2595-3141", ws_pe_kwh_per_m2=207.1976, ws_co2_kg_per_yr=2176.1656, expected_pe_resid=+0.7198, expected_co2_resid_kg=-0.5344), + _WorksheetPin(cert_number="9418-3062-8205-3566-7200", ws_pe_kwh_per_m2=58.5508, ws_co2_kg_per_yr=394.3858, expected_pe_resid=+0.0201, expected_co2_resid_kg=+0.0124), + _WorksheetPin(cert_number="9421-3045-3205-1646-6200", ws_pe_kwh_per_m2=59.6459, ws_co2_kg_per_yr=295.3567, expected_pe_resid=+0.0288, expected_co2_resid_kg=+0.0145), + _WorksheetPin(cert_number="9501-3059-8202-7356-0204", ws_pe_kwh_per_m2=182.3673, ws_co2_kg_per_yr=3554.1642, expected_pe_resid=+0.4570, expected_co2_resid_kg=-0.7517), + _WorksheetPin(cert_number="9796-3058-6205-0346-9200", ws_pe_kwh_per_m2=53.6467, ws_co2_kg_per_yr=198.7122, expected_pe_resid=+0.0432, expected_co2_resid_kg=+0.0183), + _WorksheetPin(cert_number="9836-7525-9500-0575-1202", ws_pe_kwh_per_m2=253.8868, ws_co2_kg_per_yr=3101.1029, expected_pe_resid=+0.0366, expected_co2_resid_kg=+0.0026), +) + + def _load_cert(cert_number: str) -> dict[str, Any]: """Load one frozen cert document from the fixtures directory.""" path = _FIXTURES_DIR / f"{cert_number}.json" @@ -530,6 +627,47 @@ def test_golden_cert_residual_matches_pin(expectation: _GoldenExpectation) -> No ) +@pytest.mark.parametrize( + "pin", + _WORKSHEET_PE_CO2, + ids=lambda p: p.cert_number, +) +def test_golden_cert_pe_co2_matches_worksheet(pin: _WorksheetPin) -> None: + """Pin the demand cascade's PE / CO2 against the cert's Elmhurst dr87 + worksheet at full precision — the calculator-vs-Elmhurst signal that + the lodged-register residual (`test_golden_cert_residual_matches_pin`) + can't give, because lodged values are integer-rounded. + + The worksheet's published *Current* PE `(286)` and CO2 `(272)` come + from its postcode-climate "CALCULATION OF EPC COSTS, EMISSIONS AND + PRIMARY ENERGY" block — so we drive the same `cert_to_demand_inputs` + (postcode climate) cascade the EPC publishes, not the UK-average SAP + cascade. + """ + # Arrange + doc = _load_cert(pin.cert_number) + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act + demand = calculate_sap_from_inputs( + cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + pe_resid = demand.primary_energy_kwh_per_m2 - pin.ws_pe_kwh_per_m2 + co2_resid_kg = demand.co2_kg_per_yr - pin.ws_co2_kg_per_yr + + # Assert + assert abs(pe_resid - pin.expected_pe_resid) <= _WS_PE_ABS_TOLERANCE_KWH_PER_M2, ( + f"PE residual vs worksheet {pe_resid:+.4f} kWh/m² drifted from pin " + f"{pin.expected_pe_resid:+.4f} (tolerance " + f"±{_WS_PE_ABS_TOLERANCE_KWH_PER_M2})." + ) + assert abs(co2_resid_kg - pin.expected_co2_resid_kg) <= _WS_CO2_ABS_TOLERANCE_KG, ( + f"CO2 residual vs worksheet {co2_resid_kg:+.4f} kg/yr drifted from " + f"pin {pin.expected_co2_resid_kg:+.4f} (tolerance " + f"±{_WS_CO2_ABS_TOLERANCE_KG})." + ) + + # Cert 0390 lodges Firebird Boilers S 150-200 oil boiler at PCDB index_number # 9005 (Table 105 winter eff 86.4%). End-to-end mapper → cert_to_inputs chain # must surface that PCDB winter efficiency on `inputs.main_heating_efficiency` @@ -579,6 +717,4 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( main = epc.sap_heating.main_heating_details[0] assert main.main_heating_index_number == expected_pcdb_id if expected_winter_eff is not None: - assert inputs.main_heating_efficiency == pytest.approx( - expected_winter_eff, abs=1e-3 - ) + assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3 From a5d886187c22d0bc21d2e1b0724c4ca946fd4a92 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 20:28:50 +0000 Subject: [PATCH 09/16] =?UTF-8?q?S0380.187:=20include=20electric=20seconda?= =?UTF-8?q?ry=20heating=20in=20Appendix=20M1=20D=5FPV,m=20=E2=80=94=20clos?= =?UTF-8?q?es=20gas+PV=20PE/CO2=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PV onsite/export β-split (SAP 10.2 Appendix M1 §3a, p.93) divides PV generation by the monthly PV-eligible electricity demand D_PV,m. The cascade included main and water electricity (when those fuels are electric) but had no term for SECONDARY space heating. For the 10 cohort-2 gas-main + electric-secondary + PV certs, the (215)m secondary electric fuel was dropped from D_PV,m — understating demand in the heating months only, depressing the monthly β, and under-crediting onsite PV primary energy. Spec: Appendix M1 §3a counts E_space,m as the dwelling's TOTAL electric space-heating demand; for a gas-main/electric-secondary dwelling that is the secondary fuel. Diagnosis was decisive: E_PV (generation) matched the worksheet exactly every month, the onsite (233a) split diverged ONLY in heating months (Jun-Sep near-exact), and all 10 affected certs have PV while all clean gas certs have none. Empirically adding (215)m to D_PV closed cert 3136 onsite 726.9 → 790.3 (worksheet 792.1). Impact (calc − full-precision dr87 worksheet), the 10 certs: PE +0.5..+1.5 → +0.02..+0.046 kWh/m²; CO2 −0.5..−1.1 → +0.002..+0.0095 kg. The whole 47-cert cohort now matches at PE <0.05 / CO2 <0.025. SAP integers unchanged; chain SAP 1e-4 pins intact (164 pass). The uniform ~0.03 PE remnant on PV certs is the separate (233a)/(233b) summer-month D_PV discrepancy. Re-pinned the 10 worksheet + 9 lodged golden residuals (improvements). 2273 pass, 0 regressions; pyright net-zero (file's 32 errors pre-existing). Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 31 ++++++++-- .../rdsap/test_golden_fixtures.py | 62 ++++++++++--------- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 6bcb71c7..84dd2281 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2291,24 +2291,39 @@ def _pv_eligible_demand_monthly_kwh( electric_shower_monthly_kwh: tuple[float, ...], pumps_fans_monthly_kwh: tuple[float, ...], main_1_fuel_monthly_kwh: tuple[float, ...], + secondary_fuel_monthly_kwh: tuple[float, ...], hot_water_monthly_kwh: tuple[float, ...], main_fuel_code_table_12: Optional[int], + secondary_fuel_code_table_12: Optional[int], water_heating_fuel_code_table_12: Optional[int], ) -> tuple[float, ...]: """SAP 10.2 Appendix M1 §3a (p.93) — monthly PV-eligible demand D_PV,m. Always includes lighting + appliances + cooking + electric - shower + pumps & fans. Includes E_space,m only when the main - heating fuel is electricity at the standard tariff (codes 30, 32, - 34, 35, 38 per spec). Includes E_water,m only when the water - heating fuel code is 30 (standard electricity) per spec. + shower + pumps & fans. Includes E_space,m (main AND secondary space + heating) only for the electric tariffs eligible for PV self-use + (codes 30, 32, 34, 35, 38 per spec). Includes E_water,m only when + the water heating fuel code is 30 (standard electricity) per spec. + + Secondary space heating is included on the same footing as main: + Appendix M1 §3a counts E_space,m as the dwelling's total electric + space-heating demand, which for a gas-main / electric-secondary + dwelling is the (215)m secondary fuel. Omitting it understates + D_PV,m in the heating months only — depressing the monthly β → + onsite split and under-crediting PV primary energy (the calc-vs- + worksheet (233a) gap localised on the cohort-2 gas+PV certs: + cert 3136 onsite 726.9 → 790.3 vs worksheet 792.1). The off-peak immersion × (243) Ewater branch and the Appendix G4 PV diverter adjustment are deferred — current cohort fixtures don't exercise them.""" - include_space = ( + include_main_space = ( main_fuel_code_table_12 is not None and main_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES ) + include_secondary_space = ( + secondary_fuel_code_table_12 is not None + and secondary_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES + ) include_water = ( water_heating_fuel_code_table_12 is not None and water_heating_fuel_code_table_12 in _PV_ELIGIBLE_WATER_HEATING_FUEL_CODES @@ -2322,8 +2337,10 @@ def _pv_eligible_demand_monthly_kwh( + electric_shower_monthly_kwh[m] + pumps_fans_monthly_kwh[m] ) - if include_space: + if include_main_space: d += main_1_fuel_monthly_kwh[m] + if include_secondary_space: + d += secondary_fuel_monthly_kwh[m] if include_water: d += hot_water_monthly_kwh[m] monthly.append(d) @@ -6308,11 +6325,13 @@ def cert_to_inputs( pumps_fans_kwh, _DAYS_IN_MONTH, ), main_1_fuel_monthly_kwh=energy_requirements_result.main_1_fuel_monthly_kwh, + secondary_fuel_monthly_kwh=energy_requirements_result.secondary_fuel_monthly_kwh, hot_water_monthly_kwh=hot_water_monthly_kwh_for_pv, main_fuel_code_table_12=( API_FUEL_TO_TABLE_12.get(main_fuel, main_fuel) if main_fuel is not None else None ), + secondary_fuel_code_table_12=_secondary_fuel_code(epc), water_heating_fuel_code_table_12=( API_FUEL_TO_TABLE_12.get( epc.sap_heating.water_heating_fuel, diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index ed509c99..561a77f0 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -430,17 +430,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( # ------------------------------------------------------------------ _GoldenExpectation(cert_number="0036-6325-1100-0063-1226", actual_sap=63, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4019, expected_co2_resid_tonnes_per_yr=+0.0255, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0100-5141-0522-4696-3463", actual_sap=86, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5174, expected_co2_resid_tonnes_per_yr=+0.0277, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0200-3155-0122-2602-3563", actual_sap=81, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.6041, expected_co2_resid_tonnes_per_yr=-0.0096, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0300-2403-2650-2206-0235", actual_sap=77, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.1308, expected_co2_resid_tonnes_per_yr=+0.0443, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0310-2763-5450-2506-3501", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.2791, expected_co2_resid_tonnes_per_yr=+0.0150, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0200-3155-0122-2602-3563", actual_sap=81, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5078, expected_co2_resid_tonnes_per_yr=-0.0085, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0300-2403-2650-2206-0235", actual_sap=77, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0541, expected_co2_resid_tonnes_per_yr=+0.0454, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0310-2763-5450-2506-3501", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1087, expected_co2_resid_tonnes_per_yr=+0.0159, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0320-2126-2150-2326-6161", actual_sap=72, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2060, expected_co2_resid_tonnes_per_yr=+0.0128, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0320-2756-8640-2296-1101", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2370, expected_co2_resid_tonnes_per_yr=+0.0303, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0330-2257-3640-2196-3145", actual_sap=85, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2809, expected_co2_resid_tonnes_per_yr=+0.0350, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0360-2266-5650-2106-8285", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.6646, expected_co2_resid_tonnes_per_yr=-0.0170, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0360-2266-5650-2106-8285", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0150, expected_co2_resid_tonnes_per_yr=-0.0162, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0380-2530-6150-2326-4161", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0893, expected_co2_resid_tonnes_per_yr=-0.0315, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0390-2066-4250-2026-4555", actual_sap=65, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2522, expected_co2_resid_tonnes_per_yr=+0.0005, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0464-3032-0205-4276-3204", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.1607, expected_co2_resid_tonnes_per_yr=+0.0451, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0652-3022-1205-2826-1200", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.9954, expected_co2_resid_tonnes_per_yr=+0.0276, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0464-3032-0205-4276-3204", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2728, expected_co2_resid_tonnes_per_yr=+0.0459, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0652-3022-1205-2826-1200", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0524, expected_co2_resid_tonnes_per_yr=+0.0284, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="1536-9325-5100-0433-1226", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1568, expected_co2_resid_tonnes_per_yr=-0.0456, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2007-3011-9205-8136-3204", actual_sap=68, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3773, expected_co2_resid_tonnes_per_yr=-0.0325, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2031-3007-0205-1296-3204", actual_sap=64, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4198, expected_co2_resid_tonnes_per_yr=-0.0420, notes="Cohort-2 baseline pin captured by S0380.69."), @@ -462,7 +462,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation(cert_number="2590-3025-7205-9066-0200", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1309, expected_co2_resid_tonnes_per_yr=-0.0036, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2699-3025-5205-8066-0200", actual_sap=69, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4755, expected_co2_resid_tonnes_per_yr=-0.0016, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2800-7999-0322-4594-3563", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2868, expected_co2_resid_tonnes_per_yr=-0.0049, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="3136-7925-4500-0246-6202", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+1.0936, expected_co2_resid_tonnes_per_yr=-0.0485, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="3136-7925-4500-0246-6202", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3181, expected_co2_resid_tonnes_per_yr=-0.0476, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="3336-2825-9400-0512-8292", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2060, expected_co2_resid_tonnes_per_yr=-0.0420, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="4536-5424-8600-0109-1226", actual_sap=82, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0660, expected_co2_resid_tonnes_per_yr=-0.0053, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="4536-8325-3100-0409-1222", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2794, expected_co2_resid_tonnes_per_yr=+0.0093, notes="Cohort-2 baseline pin captured by S0380.69."), @@ -470,10 +470,10 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation(cert_number="6835-3920-2509-0933-5226", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5284, expected_co2_resid_tonnes_per_yr=-0.0237, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="7700-3362-0922-7022-3563", actual_sap=63, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4141, expected_co2_resid_tonnes_per_yr=+0.0216, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="7800-1501-0922-7127-3563", actual_sap=65, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0594, expected_co2_resid_tonnes_per_yr=+0.0440, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="7836-3125-0600-0526-2202", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.9583, expected_co2_resid_tonnes_per_yr=+0.0165, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="7836-3125-0600-0526-2202", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1181, expected_co2_resid_tonnes_per_yr=+0.0172, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="9036-0824-3500-0420-8222", actual_sap=84, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2791, expected_co2_resid_tonnes_per_yr=+0.0337, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="9370-3060-1205-3546-4204", actual_sap=88, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0131, expected_co2_resid_tonnes_per_yr=-0.0060, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9380-2957-7490-2595-3141", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.9173, expected_co2_resid_tonnes_per_yr=-0.0244, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9380-2957-7490-2595-3141", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2253, expected_co2_resid_tonnes_per_yr=-0.0238, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3253, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3101, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0766, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."), @@ -508,34 +508,36 @@ class _WorksheetPin: expected_co2_resid_kg: float -# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). Findings at -# capture (HEAD post-S0380.185), calc − worksheet: -# - CO2: exact on 37/47 (<0.02 kg); the 10 higher-consumption gas certs -# carry a small −0.5..−1.1 kg under-count. -# - PE : exact on 37/47 (<0.05 kWh/m²); the SAME 10 carry a +0.5..+1.5 -# kWh/m² over-count. -# PE-over + CO2-under on the same certs is the fingerprint of a small -# gas→electricity fuel-split difference (electricity PE 1.51 > gas 1.13, -# but electricity CO2 0.136 < gas 0.21), not a factor-value error — the -# next slice candidate. Values frozen from the dr87 PDFs (untracked, so -# not parsed at test time) per the worksheet_unrounded_sap convention. +# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). calc − +# worksheet now sits at PE <0.05 kWh/m² and CO2 <0.025 kg across the +# whole cohort. S0380.187 closed the 10-cert gas+PV cluster that +# previously carried PE +0.5..+1.5 / CO2 −0.5..−1.1: those certs are +# gas-main + electric-secondary + PV, and the electric secondary +# heating (215)m was being omitted from the Appendix M1 §3a PV-eligible +# demand D_PV,m — depressing the monthly β onsite/export split and +# under-crediting PV primary energy in the heating months only. The +# uniform ~0.02..0.046 PE remnant on the PV certs is the separate +# (233a)/(233b) summer-month D_PV discrepancy, unrelated to the +# secondary fix. Values frozen from the dr87 PDFs +# (untracked, so not parsed at test time) per the worksheet_unrounded_sap +# convention. _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( _WorksheetPin(cert_number="0036-6325-1100-0063-1226", ws_pe_kwh_per_m2=213.4019, ws_co2_kg_per_yr=2125.4851, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), _WorksheetPin(cert_number="0100-5141-0522-4696-3463", ws_pe_kwh_per_m2=53.4939, ws_co2_kg_per_yr=427.6895, expected_pe_resid=+0.0235, expected_co2_resid_kg=+0.0195), - _WorksheetPin(cert_number="0200-3155-0122-2602-3563", ws_pe_kwh_per_m2=192.4660, ws_co2_kg_per_yr=2191.4589, expected_pe_resid=+1.1381, expected_co2_resid_kg=-1.0649), - _WorksheetPin(cert_number="0300-2403-2650-2206-0235", ws_pe_kwh_per_m2=224.9069, ws_co2_kg_per_yr=2445.3496, expected_pe_resid=+1.2239, expected_co2_resid_kg=-1.0351), - _WorksheetPin(cert_number="0310-2763-5450-2506-3501", ws_pe_kwh_per_m2=233.8452, ws_co2_kg_per_yr=1715.8602, expected_pe_resid=+1.4339, expected_co2_resid_kg=-0.8667), + _WorksheetPin(cert_number="0200-3155-0122-2602-3563", ws_pe_kwh_per_m2=192.4660, ws_co2_kg_per_yr=2191.4589, expected_pe_resid=+0.0418, expected_co2_resid_kg=+0.0041), + _WorksheetPin(cert_number="0300-2403-2650-2206-0235", ws_pe_kwh_per_m2=224.9069, ws_co2_kg_per_yr=2445.3496, expected_pe_resid=+0.0390, expected_co2_resid_kg=+0.0065), + _WorksheetPin(cert_number="0310-2763-5450-2506-3501", ws_pe_kwh_per_m2=233.8452, ws_co2_kg_per_yr=1715.8602, expected_pe_resid=+0.0461, expected_co2_resid_kg=+0.0033), _WorksheetPin(cert_number="0320-2126-2150-2326-6161", ws_pe_kwh_per_m2=177.7940, ws_co2_kg_per_yr=2312.8161, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), _WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0263, expected_co2_resid_kg=+0.0247), _WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), _WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0189, expected_co2_resid_kg=+0.0093), _WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0164, expected_co2_resid_kg=+0.0146), - _WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.6841, expected_co2_resid_kg=-0.7413), + _WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.0346, expected_co2_resid_kg=+0.0055), _WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0387, expected_co2_resid_kg=+0.0199), _WorksheetPin(cert_number="0380-2530-6150-2326-4161", ws_pe_kwh_per_m2=174.9107, ws_co2_kg_per_yr=2368.5251, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0390-2066-4250-2026-4555", ws_pe_kwh_per_m2=176.7478, ws_co2_kg_per_yr=2500.4581, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="0464-3032-0205-4276-3204", ws_pe_kwh_per_m2=179.2365, ws_co2_kg_per_yr=1845.9475, expected_pe_resid=+0.9242, expected_co2_resid_kg=-0.8342), - _WorksheetPin(cert_number="0652-3022-1205-2826-1200", ws_pe_kwh_per_m2=251.0214, ws_co2_kg_per_yr=2828.3691, expected_pe_resid=+0.9740, expected_co2_resid_kg=-0.7228), + _WorksheetPin(cert_number="0464-3032-0205-4276-3204", ws_pe_kwh_per_m2=179.2365, ws_co2_kg_per_yr=1845.9475, expected_pe_resid=+0.0364, expected_co2_resid_kg=+0.0020), + _WorksheetPin(cert_number="0652-3022-1205-2826-1200", ws_pe_kwh_per_m2=251.0214, ws_co2_kg_per_yr=2828.3691, expected_pe_resid=+0.0310, expected_co2_resid_kg=+0.0095), _WorksheetPin(cert_number="1536-9325-5100-0433-1226", ws_pe_kwh_per_m2=180.8432, ws_co2_kg_per_yr=2054.3609, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="2007-3011-9205-8136-3204", ws_pe_kwh_per_m2=172.6227, ws_co2_kg_per_yr=2567.5298, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), _WorksheetPin(cert_number="2031-3007-0205-1296-3204", ws_pe_kwh_per_m2=191.4198, ws_co2_kg_per_yr=2257.9561, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), @@ -548,7 +550,7 @@ _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( _WorksheetPin(cert_number="2636-0525-2600-0401-2296", ws_pe_kwh_per_m2=52.5660, ws_co2_kg_per_yr=395.4880, expected_pe_resid=+0.0212, expected_co2_resid_kg=+0.0168), _WorksheetPin(cert_number="2699-3025-5205-8066-0200", ws_pe_kwh_per_m2=168.4755, ws_co2_kg_per_yr=2498.3764, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="2800-7999-0322-4594-3563", ws_pe_kwh_per_m2=89.2727, ws_co2_kg_per_yr=395.0757, expected_pe_resid=+0.0141, expected_co2_resid_kg=+0.0067), - _WorksheetPin(cert_number="3136-7925-4500-0246-6202", ws_pe_kwh_per_m2=238.6376, ws_co2_kg_per_yr=1752.3516, expected_pe_resid=+1.4560, expected_co2_resid_kg=-0.8858), + _WorksheetPin(cert_number="3136-7925-4500-0246-6202", ws_pe_kwh_per_m2=238.6376, ws_co2_kg_per_yr=1752.3516, expected_pe_resid=+0.0443, expected_co2_resid_kg=+0.0033), _WorksheetPin(cert_number="3336-2825-9400-0512-8292", ws_pe_kwh_per_m2=84.7840, ws_co2_kg_per_yr=458.0332, expected_pe_resid=+0.0099, expected_co2_resid_kg=+0.0058), _WorksheetPin(cert_number="3800-8515-0922-3398-3563", ws_pe_kwh_per_m2=58.7712, ws_co2_kg_per_yr=440.6740, expected_pe_resid=+0.0195, expected_co2_resid_kg=+0.0156), _WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0207, expected_co2_resid_kg=+0.0176), @@ -557,14 +559,14 @@ _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( _WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0360, expected_co2_resid_kg=-0.0013), _WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), _WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="7836-3125-0600-0526-2202", ws_pe_kwh_per_m2=183.0794, ws_co2_kg_per_yr=1817.2248, expected_pe_resid=+0.8789, expected_co2_resid_kg=-0.7461), + _WorksheetPin(cert_number="7836-3125-0600-0526-2202", ws_pe_kwh_per_m2=183.0794, ws_co2_kg_per_yr=1817.2248, expected_pe_resid=+0.0387, expected_co2_resid_kg=+0.0027), _WorksheetPin(cert_number="9036-0824-3500-0420-8222", ws_pe_kwh_per_m2=56.7016, ws_co2_kg_per_yr=433.6372, expected_pe_resid=+0.0192, expected_co2_resid_kg=+0.0155), _WorksheetPin(cert_number="9285-3062-0205-7766-7200", ws_pe_kwh_per_m2=56.9079, ws_co2_kg_per_yr=454.7771, expected_pe_resid=+0.0185, expected_co2_resid_kg=+0.0156), _WorksheetPin(cert_number="9370-3060-1205-3546-4204", ws_pe_kwh_per_m2=51.9889, ws_co2_kg_per_yr=494.0023, expected_pe_resid=+0.0242, expected_co2_resid_kg=+0.0229), - _WorksheetPin(cert_number="9380-2957-7490-2595-3141", ws_pe_kwh_per_m2=207.1976, ws_co2_kg_per_yr=2176.1656, expected_pe_resid=+0.7198, expected_co2_resid_kg=-0.5344), + _WorksheetPin(cert_number="9380-2957-7490-2595-3141", ws_pe_kwh_per_m2=207.1976, ws_co2_kg_per_yr=2176.1656, expected_pe_resid=+0.0278, expected_co2_resid_kg=+0.0067), _WorksheetPin(cert_number="9418-3062-8205-3566-7200", ws_pe_kwh_per_m2=58.5508, ws_co2_kg_per_yr=394.3858, expected_pe_resid=+0.0201, expected_co2_resid_kg=+0.0124), _WorksheetPin(cert_number="9421-3045-3205-1646-6200", ws_pe_kwh_per_m2=59.6459, ws_co2_kg_per_yr=295.3567, expected_pe_resid=+0.0288, expected_co2_resid_kg=+0.0145), - _WorksheetPin(cert_number="9501-3059-8202-7356-0204", ws_pe_kwh_per_m2=182.3673, ws_co2_kg_per_yr=3554.1642, expected_pe_resid=+0.4570, expected_co2_resid_kg=-0.7517), + _WorksheetPin(cert_number="9501-3059-8202-7356-0204", ws_pe_kwh_per_m2=182.3673, ws_co2_kg_per_yr=3554.1642, expected_pe_resid=+0.0180, expected_co2_resid_kg=+0.0058), _WorksheetPin(cert_number="9796-3058-6205-0346-9200", ws_pe_kwh_per_m2=53.6467, ws_co2_kg_per_yr=198.7122, expected_pe_resid=+0.0432, expected_co2_resid_kg=+0.0183), _WorksheetPin(cert_number="9836-7525-9500-0575-1202", ws_pe_kwh_per_m2=253.8868, ws_co2_kg_per_yr=3101.1029, expected_pe_resid=+0.0366, expected_co2_resid_kg=+0.0026), ) From 72743eb8a43df76807c59e13603916f4dcf19aba Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 21:05:12 +0000 Subject: [PATCH 10/16] =?UTF-8?q?S0380.188:=20D=5FPV,m=20uses=20lighting?= =?UTF-8?q?=20ELECTRICITY=20(L10)=20not=20the=20L12=20gain=20=E2=80=94=20c?= =?UTF-8?q?loses=20PV=20cohort=20to=201e-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix M1 §3a (p.93) defines PV-eligible demand as D_PV,m = E_L,m + E_A,m + E_cook,m + E_ES,m + (231)·n_m/365 + E_space,m + E_water,m where E_L,m is the lighting ELECTRICITY (Appendix L eq L10, = line (232)). The cascade fed `internal_gains_result.lighting_monthly_w` — the L12 internal heat GAIN G_L,m = E_L,m × 0.85 ("assuming 15%" of lighting energy does not become internal heat) — into D_PV, understating it by 15% of lighting on every PV cert. That depressed the monthly β onsite/export split and under-credited PV primary energy uniformly across the year. Same gain-vs-electricity class as the cooking fix S0380.73 (L18 gain vs L20 electricity). Fix: scale the (shape-identical) lighting gain profile to the annual E_L `lighting_kwh_per_yr` (= (232)), mirroring the (219)m hot-water scale-to-annual. Magnitude-only, so the shape-weighted lighting CO2/PE effective factor (Σkwh×f/Σkwh, magnitude-invariant) is unchanged; appliances need no scaling (G_A = E_A, no 0.85). Diagnosis was empirical first (calc lighting D_PV 95.1 vs worksheet (232) 111.88, ratio exactly 0.85) then confirmed against the spec text (L9d/L10/L12, M1 §3a). Impact (calc − full-precision dr87 worksheet): ALL 47 worksheet certs now match at <1e-4 on BOTH PE (max |Δ| 0.0000 kWh/m²) and CO2 (max |Δ| 0.0000 kg) — the convergence target, met cohort-wide. Combined with S0380.187 this closes the entire gas+PV + ASHP PV residual. Re-pinned 47 worksheet residuals to 0.0000 and 31 drifted lodged residuals (PV certs). SAP integers unchanged; chain SAP 1e-4 intact (164 pass). 2273 pass, 0 regressions; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 29 ++- .../rdsap/test_golden_fixtures.py | 184 +++++++++--------- 2 files changed, 118 insertions(+), 95 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 84dd2281..6b8e15dc 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -6080,15 +6080,36 @@ def cert_to_inputs( internal_gains_result.total_internal_gains_monthly_w ) lighting_kwh = internal_gains_result.lighting_kwh_per_yr - # Watts → kWh via n_days_in_month × 24 hours / 1000 W per kWh. - # Appendix M1 §3a D_PV,m needs each of these monthly so the - # PV-eligible-demand assembly downstream can sum them in kWh. - lighting_monthly_kwh = tuple( + # SAP 10.2 Appendix M1 §3a (p.93): D_PV,m sums E_L,m — the lighting + # ELECTRICITY (Appendix L eq L10, = line (232)) — NOT the L12 + # internal heat gain G_L,m = E_L,m × 0.85 (spec: "assuming 15%" of + # lighting energy does not become internal heat). `lighting_ + # monthly_w` is the L12 gain, so converting it W→kWh yields + # 0.85 × E_L,m and understates D_PV by 15% of lighting — depressing + # the monthly β onsite split and under-crediting PV primary energy + # uniformly across the year (the residual left after S0380.187 on + # the gas+PV certs: cert 3136 onsite 790.3 → 792.1 vs worksheet + # 792.1). Recover E_L,m by scaling the (shape-identical) gain + # profile to the annual E_L `lighting_kwh_per_yr` — mirroring the + # (219)m hot-water scale-to-annual below. Same mismatch the cooking + # term hit in S0380.73 (L18 gain vs L20 electricity); appliances + # need no scaling (G_A = E_A, no 0.85 factor). Magnitude-only: the + # shape-weighted lighting CO2/PE factor (Σkwh×f/Σkwh) is unchanged. + lighting_gain_monthly_kwh = tuple( w * d * 24.0 / 1000.0 for w, d in zip( internal_gains_result.lighting_monthly_w, _DAYS_IN_MONTH ) ) + _lighting_gain_total = sum(lighting_gain_monthly_kwh) + lighting_monthly_kwh = ( + tuple( + g / _lighting_gain_total * lighting_kwh + for g in lighting_gain_monthly_kwh + ) + if _lighting_gain_total > 0.0 + else lighting_gain_monthly_kwh + ) appliances_monthly_kwh = tuple( w * d * 24.0 / 1000.0 for w, d in zip( diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 561a77f0..7ca77668 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -265,7 +265,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2130-1033-4050-5007-8395", actual_sap=82, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-7.4998, + expected_pe_resid_kwh_per_m2=-7.5579, expected_co2_resid_tonnes_per_yr=-0.0454, notes=( "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " @@ -322,8 +322,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0380-2471-3250-2596-8761", actual_sap=89, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+0.5259, - expected_co2_resid_tonnes_per_yr=-0.0074, + expected_pe_resid_kwh_per_m2=+0.4872, + expected_co2_resid_tonnes_per_yr=-0.0075, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, semi-detached bungalow " "TFA 60.43 age D, PV 3 kWp + 5 kWh battery. Worksheet SAP " @@ -342,7 +342,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0350-2968-2650-2796-5255", actual_sap=84, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.2812, + expected_pe_resid_kwh_per_m2=-0.2976, expected_co2_resid_tonnes_per_yr=-0.0292, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -354,7 +354,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2225-3062-8205-2856-7204", actual_sap=89, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.2978, + expected_pe_resid_kwh_per_m2=-0.3250, expected_co2_resid_tonnes_per_yr=-0.0101, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -366,7 +366,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2636-0525-2600-0401-2296", actual_sap=86, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.4127, + expected_pe_resid_kwh_per_m2=-0.4340, expected_co2_resid_tonnes_per_yr=-0.0045, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -379,7 +379,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="3800-8515-0922-3398-3563", actual_sap=86, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.2093, + expected_pe_resid_kwh_per_m2=-0.2288, expected_co2_resid_tonnes_per_yr=+0.0407, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -391,7 +391,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="9285-3062-0205-7766-7200", actual_sap=84, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.0736, + expected_pe_resid_kwh_per_m2=-0.0921, expected_co2_resid_tonnes_per_yr=-0.0452, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " @@ -403,7 +403,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="9418-3062-8205-3566-7200", actual_sap=85, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-0.4291, + expected_pe_resid_kwh_per_m2=-0.4492, expected_co2_resid_tonnes_per_yr=-0.0056, notes=( "Daikin Altherma EDLQ05CAV3 PCDB 102421 (heating_duration " @@ -429,18 +429,18 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( # `sap worksheets/Additional data with api/`. # ------------------------------------------------------------------ _GoldenExpectation(cert_number="0036-6325-1100-0063-1226", actual_sap=63, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4019, expected_co2_resid_tonnes_per_yr=+0.0255, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0100-5141-0522-4696-3463", actual_sap=86, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5174, expected_co2_resid_tonnes_per_yr=+0.0277, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0200-3155-0122-2602-3563", actual_sap=81, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5078, expected_co2_resid_tonnes_per_yr=-0.0085, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0300-2403-2650-2206-0235", actual_sap=77, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0541, expected_co2_resid_tonnes_per_yr=+0.0454, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0310-2763-5450-2506-3501", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1087, expected_co2_resid_tonnes_per_yr=+0.0159, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0100-5141-0522-4696-3463", actual_sap=86, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4939, expected_co2_resid_tonnes_per_yr=+0.0277, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0200-3155-0122-2602-3563", actual_sap=81, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4660, expected_co2_resid_tonnes_per_yr=-0.0085, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0300-2403-2650-2206-0235", actual_sap=77, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0931, expected_co2_resid_tonnes_per_yr=+0.0453, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0310-2763-5450-2506-3501", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1548, expected_co2_resid_tonnes_per_yr=+0.0159, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0320-2126-2150-2326-6161", actual_sap=72, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2060, expected_co2_resid_tonnes_per_yr=+0.0128, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0320-2756-8640-2296-1101", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2370, expected_co2_resid_tonnes_per_yr=+0.0303, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0330-2257-3640-2196-3145", actual_sap=85, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2809, expected_co2_resid_tonnes_per_yr=+0.0350, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0360-2266-5650-2106-8285", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0150, expected_co2_resid_tonnes_per_yr=-0.0162, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0320-2756-8640-2296-1101", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2633, expected_co2_resid_tonnes_per_yr=+0.0303, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0330-2257-3640-2196-3145", actual_sap=85, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2620, expected_co2_resid_tonnes_per_yr=+0.0350, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0360-2266-5650-2106-8285", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0196, expected_co2_resid_tonnes_per_yr=-0.0162, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0380-2530-6150-2326-4161", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0893, expected_co2_resid_tonnes_per_yr=-0.0315, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="0390-2066-4250-2026-4555", actual_sap=65, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2522, expected_co2_resid_tonnes_per_yr=+0.0005, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0464-3032-0205-4276-3204", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2728, expected_co2_resid_tonnes_per_yr=+0.0459, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="0652-3022-1205-2826-1200", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0524, expected_co2_resid_tonnes_per_yr=+0.0284, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0464-3032-0205-4276-3204", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2365, expected_co2_resid_tonnes_per_yr=+0.0459, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="0652-3022-1205-2826-1200", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0214, expected_co2_resid_tonnes_per_yr=+0.0284, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="1536-9325-5100-0433-1226", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1568, expected_co2_resid_tonnes_per_yr=-0.0456, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2007-3011-9205-8136-3204", actual_sap=68, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3773, expected_co2_resid_tonnes_per_yr=-0.0325, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2031-3007-0205-1296-3204", actual_sap=64, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4198, expected_co2_resid_tonnes_per_yr=-0.0420, notes="Cohort-2 baseline pin captured by S0380.69."), @@ -457,26 +457,26 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( # / CO2 +0.005 (lodged values are integer-rounded; rounding noise). _GoldenExpectation(cert_number="2102-3018-0205-7886-5204", actual_sap=64, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1961, expected_co2_resid_tonnes_per_yr=+0.0048, notes="Cohort-2 baseline pin. House coal secondary — S0380.70 routed CO2/PE through `secondary_fuel_type` per SAP 10.2 Table 12d/12e headers, closed PE +20.36 → +0.20 and CO2 -0.79 → +0.005 (lodged values integer-rounded)."), _GoldenExpectation(cert_number="2130-3018-4205-4686-5204", actual_sap=71, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4083, expected_co2_resid_tonnes_per_yr=-0.0357, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="2336-3124-3600-0517-1292", actual_sap=83, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1247, expected_co2_resid_tonnes_per_yr=-0.0414, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="2536-2525-0600-0788-2292", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4210, expected_co2_resid_tonnes_per_yr=-0.0244, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="2336-3124-3600-0517-1292", actual_sap=83, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1077, expected_co2_resid_tonnes_per_yr=-0.0414, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="2536-2525-0600-0788-2292", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4317, expected_co2_resid_tonnes_per_yr=-0.0244, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2590-3025-7205-9066-0200", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1309, expected_co2_resid_tonnes_per_yr=-0.0036, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="2699-3025-5205-8066-0200", actual_sap=69, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4755, expected_co2_resid_tonnes_per_yr=-0.0016, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="2800-7999-0322-4594-3563", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2868, expected_co2_resid_tonnes_per_yr=-0.0049, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="3136-7925-4500-0246-6202", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3181, expected_co2_resid_tonnes_per_yr=-0.0476, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="2800-7999-0322-4594-3563", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2727, expected_co2_resid_tonnes_per_yr=-0.0049, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="3136-7925-4500-0246-6202", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3624, expected_co2_resid_tonnes_per_yr=-0.0476, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="3336-2825-9400-0512-8292", actual_sap=78, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2060, expected_co2_resid_tonnes_per_yr=-0.0420, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="4536-5424-8600-0109-1226", actual_sap=82, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0660, expected_co2_resid_tonnes_per_yr=-0.0053, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="4536-5424-8600-0109-1226", actual_sap=82, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0867, expected_co2_resid_tonnes_per_yr=-0.0054, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="4536-8325-3100-0409-1222", actual_sap=66, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2794, expected_co2_resid_tonnes_per_yr=+0.0093, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="4800-3992-0422-0599-3563", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5231, expected_co2_resid_tonnes_per_yr=-0.0406, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="6835-3920-2509-0933-5226", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.5284, expected_co2_resid_tonnes_per_yr=-0.0237, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="4800-3992-0422-0599-3563", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4814, expected_co2_resid_tonnes_per_yr=-0.0406, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="6835-3920-2509-0933-5226", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.4924, expected_co2_resid_tonnes_per_yr=-0.0237, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="7700-3362-0922-7022-3563", actual_sap=63, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.4141, expected_co2_resid_tonnes_per_yr=+0.0216, notes="Cohort-2 baseline pin captured by S0380.69."), _GoldenExpectation(cert_number="7800-1501-0922-7127-3563", actual_sap=65, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0594, expected_co2_resid_tonnes_per_yr=+0.0440, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="7836-3125-0600-0526-2202", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1181, expected_co2_resid_tonnes_per_yr=+0.0172, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9036-0824-3500-0420-8222", actual_sap=84, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2791, expected_co2_resid_tonnes_per_yr=+0.0337, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9370-3060-1205-3546-4204", actual_sap=88, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0131, expected_co2_resid_tonnes_per_yr=-0.0060, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9380-2957-7490-2595-3141", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.2253, expected_co2_resid_tonnes_per_yr=-0.0238, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3253, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3101, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."), - _GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0766, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="7836-3125-0600-0526-2202", actual_sap=80, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.0794, expected_co2_resid_tonnes_per_yr=+0.0172, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9036-0824-3500-0420-8222", actual_sap=84, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.2984, expected_co2_resid_tonnes_per_yr=+0.0336, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9370-3060-1205-3546-4204", actual_sap=88, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.0111, expected_co2_resid_tonnes_per_yr=-0.0060, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9380-2957-7490-2595-3141", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+0.1976, expected_co2_resid_tonnes_per_yr=-0.0238, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3541, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3533, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."), + _GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1132, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."), ) @@ -508,67 +508,69 @@ class _WorksheetPin: expected_co2_resid_kg: float -# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). calc − -# worksheet now sits at PE <0.05 kWh/m² and CO2 <0.025 kg across the -# whole cohort. S0380.187 closed the 10-cert gas+PV cluster that -# previously carried PE +0.5..+1.5 / CO2 −0.5..−1.1: those certs are -# gas-main + electric-secondary + PV, and the electric secondary -# heating (215)m was being omitted from the Appendix M1 §3a PV-eligible -# demand D_PV,m — depressing the monthly β onsite/export split and -# under-crediting PV primary energy in the heating months only. The -# uniform ~0.02..0.046 PE remnant on the PV certs is the separate -# (233a)/(233b) summer-month D_PV discrepancy, unrelated to the -# secondary fix. Values frozen from the dr87 PDFs -# (untracked, so not parsed at test time) per the worksheet_unrounded_sap -# convention. +# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). calc ≡ +# worksheet on BOTH PE and CO2 at <1e-4 across the ENTIRE cohort +# (every expected_*_resid below is 0.0000) — the SAP 10.2 1e-4 +# convergence target, met. Closed over two slices: +# S0380.187 — Appendix M1 §3a D_PV,m was missing electric SECONDARY +# space heating (215)m, under-crediting PV in heating months on the +# 10 gas+PV certs (PE +0.5..+1.5 / CO2 −0.5..−1.1 → ~0.03). +# S0380.188 — D_PV,m used the Appendix L L12 lighting GAIN G_L,m +# (= E_L,m × 0.85, "15% not internal heat") instead of the L10 +# lighting ELECTRICITY E_L,m that §3a requires; the 15% shortfall +# depressed β uniformly across the year on every PV cert. Fixed by +# scaling the gain's seasonal shape to the annual E_L (232). Same +# L-gain-vs-L-electricity class as the cooking fix S0380.73. +# Values frozen from the dr87 PDFs (untracked, so not parsed at test +# time) per the worksheet_unrounded_sap convention. _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( - _WorksheetPin(cert_number="0036-6325-1100-0063-1226", ws_pe_kwh_per_m2=213.4019, ws_co2_kg_per_yr=2125.4851, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="0100-5141-0522-4696-3463", ws_pe_kwh_per_m2=53.4939, ws_co2_kg_per_yr=427.6895, expected_pe_resid=+0.0235, expected_co2_resid_kg=+0.0195), - _WorksheetPin(cert_number="0200-3155-0122-2602-3563", ws_pe_kwh_per_m2=192.4660, ws_co2_kg_per_yr=2191.4589, expected_pe_resid=+0.0418, expected_co2_resid_kg=+0.0041), - _WorksheetPin(cert_number="0300-2403-2650-2206-0235", ws_pe_kwh_per_m2=224.9069, ws_co2_kg_per_yr=2445.3496, expected_pe_resid=+0.0390, expected_co2_resid_kg=+0.0065), - _WorksheetPin(cert_number="0310-2763-5450-2506-3501", ws_pe_kwh_per_m2=233.8452, ws_co2_kg_per_yr=1715.8602, expected_pe_resid=+0.0461, expected_co2_resid_kg=+0.0033), - _WorksheetPin(cert_number="0320-2126-2150-2326-6161", ws_pe_kwh_per_m2=177.7940, ws_co2_kg_per_yr=2312.8161, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0263, expected_co2_resid_kg=+0.0247), - _WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0189, expected_co2_resid_kg=+0.0093), - _WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0164, expected_co2_resid_kg=+0.0146), - _WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.0346, expected_co2_resid_kg=+0.0055), - _WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0387, expected_co2_resid_kg=+0.0199), + _WorksheetPin(cert_number="0036-6325-1100-0063-1226", ws_pe_kwh_per_m2=213.4019, ws_co2_kg_per_yr=2125.4851, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0100-5141-0522-4696-3463", ws_pe_kwh_per_m2=53.4939, ws_co2_kg_per_yr=427.6895, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0200-3155-0122-2602-3563", ws_pe_kwh_per_m2=192.4660, ws_co2_kg_per_yr=2191.4589, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0300-2403-2650-2206-0235", ws_pe_kwh_per_m2=224.9069, ws_co2_kg_per_yr=2445.3496, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0310-2763-5450-2506-3501", ws_pe_kwh_per_m2=233.8452, ws_co2_kg_per_yr=1715.8602, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0320-2126-2150-2326-6161", ws_pe_kwh_per_m2=177.7940, ws_co2_kg_per_yr=2312.8161, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), _WorksheetPin(cert_number="0380-2530-6150-2326-4161", ws_pe_kwh_per_m2=174.9107, ws_co2_kg_per_yr=2368.5251, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), - _WorksheetPin(cert_number="0390-2066-4250-2026-4555", ws_pe_kwh_per_m2=176.7478, ws_co2_kg_per_yr=2500.4581, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="0464-3032-0205-4276-3204", ws_pe_kwh_per_m2=179.2365, ws_co2_kg_per_yr=1845.9475, expected_pe_resid=+0.0364, expected_co2_resid_kg=+0.0020), - _WorksheetPin(cert_number="0652-3022-1205-2826-1200", ws_pe_kwh_per_m2=251.0214, ws_co2_kg_per_yr=2828.3691, expected_pe_resid=+0.0310, expected_co2_resid_kg=+0.0095), - _WorksheetPin(cert_number="1536-9325-5100-0433-1226", ws_pe_kwh_per_m2=180.8432, ws_co2_kg_per_yr=2054.3609, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), - _WorksheetPin(cert_number="2007-3011-9205-8136-3204", ws_pe_kwh_per_m2=172.6227, ws_co2_kg_per_yr=2567.5298, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="2031-3007-0205-1296-3204", ws_pe_kwh_per_m2=191.4198, ws_co2_kg_per_yr=2257.9561, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), - _WorksheetPin(cert_number="2102-3018-0205-7886-5204", ws_pe_kwh_per_m2=228.1961, ws_co2_kg_per_yr=4104.7798, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="2130-3018-4205-4686-5204", ws_pe_kwh_per_m2=181.4083, ws_co2_kg_per_yr=2364.3480, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="2225-3062-8205-2856-7204", ws_pe_kwh_per_m2=52.6750, ws_co2_kg_per_yr=389.8819, expected_pe_resid=+0.0272, expected_co2_resid_kg=+0.0209), - _WorksheetPin(cert_number="2336-3124-3600-0517-1292", ws_pe_kwh_per_m2=68.1077, ws_co2_kg_per_yr=458.6131, expected_pe_resid=+0.0171, expected_co2_resid_kg=+0.0085), - _WorksheetPin(cert_number="2536-2525-0600-0788-2292", ws_pe_kwh_per_m2=87.5683, ws_co2_kg_per_yr=375.6003, expected_pe_resid=+0.0107, expected_co2_resid_kg=+0.0045), - _WorksheetPin(cert_number="2590-3025-7205-9066-0200", ws_pe_kwh_per_m2=171.8691, ws_co2_kg_per_yr=2396.4327, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="2636-0525-2600-0401-2296", ws_pe_kwh_per_m2=52.5660, ws_co2_kg_per_yr=395.4880, expected_pe_resid=+0.0212, expected_co2_resid_kg=+0.0168), - _WorksheetPin(cert_number="2699-3025-5205-8066-0200", ws_pe_kwh_per_m2=168.4755, ws_co2_kg_per_yr=2498.3764, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), - _WorksheetPin(cert_number="2800-7999-0322-4594-3563", ws_pe_kwh_per_m2=89.2727, ws_co2_kg_per_yr=395.0757, expected_pe_resid=+0.0141, expected_co2_resid_kg=+0.0067), - _WorksheetPin(cert_number="3136-7925-4500-0246-6202", ws_pe_kwh_per_m2=238.6376, ws_co2_kg_per_yr=1752.3516, expected_pe_resid=+0.0443, expected_co2_resid_kg=+0.0033), - _WorksheetPin(cert_number="3336-2825-9400-0512-8292", ws_pe_kwh_per_m2=84.7840, ws_co2_kg_per_yr=458.0332, expected_pe_resid=+0.0099, expected_co2_resid_kg=+0.0058), - _WorksheetPin(cert_number="3800-8515-0922-3398-3563", ws_pe_kwh_per_m2=58.7712, ws_co2_kg_per_yr=440.6740, expected_pe_resid=+0.0195, expected_co2_resid_kg=+0.0156), - _WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0207, expected_co2_resid_kg=+0.0176), - _WorksheetPin(cert_number="4536-8325-3100-0409-1222", ws_pe_kwh_per_m2=181.7206, ws_co2_kg_per_yr=2109.2633, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), - _WorksheetPin(cert_number="4800-3992-0422-0599-3563", ws_pe_kwh_per_m2=66.4814, ws_co2_kg_per_yr=259.3652, expected_pe_resid=+0.0417, expected_co2_resid_kg=+0.0188), - _WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0360, expected_co2_resid_kg=-0.0013), - _WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), - _WorksheetPin(cert_number="7836-3125-0600-0526-2202", ws_pe_kwh_per_m2=183.0794, ws_co2_kg_per_yr=1817.2248, expected_pe_resid=+0.0387, expected_co2_resid_kg=+0.0027), - _WorksheetPin(cert_number="9036-0824-3500-0420-8222", ws_pe_kwh_per_m2=56.7016, ws_co2_kg_per_yr=433.6372, expected_pe_resid=+0.0192, expected_co2_resid_kg=+0.0155), - _WorksheetPin(cert_number="9285-3062-0205-7766-7200", ws_pe_kwh_per_m2=56.9079, ws_co2_kg_per_yr=454.7771, expected_pe_resid=+0.0185, expected_co2_resid_kg=+0.0156), - _WorksheetPin(cert_number="9370-3060-1205-3546-4204", ws_pe_kwh_per_m2=51.9889, ws_co2_kg_per_yr=494.0023, expected_pe_resid=+0.0242, expected_co2_resid_kg=+0.0229), - _WorksheetPin(cert_number="9380-2957-7490-2595-3141", ws_pe_kwh_per_m2=207.1976, ws_co2_kg_per_yr=2176.1656, expected_pe_resid=+0.0278, expected_co2_resid_kg=+0.0067), - _WorksheetPin(cert_number="9418-3062-8205-3566-7200", ws_pe_kwh_per_m2=58.5508, ws_co2_kg_per_yr=394.3858, expected_pe_resid=+0.0201, expected_co2_resid_kg=+0.0124), - _WorksheetPin(cert_number="9421-3045-3205-1646-6200", ws_pe_kwh_per_m2=59.6459, ws_co2_kg_per_yr=295.3567, expected_pe_resid=+0.0288, expected_co2_resid_kg=+0.0145), - _WorksheetPin(cert_number="9501-3059-8202-7356-0204", ws_pe_kwh_per_m2=182.3673, ws_co2_kg_per_yr=3554.1642, expected_pe_resid=+0.0180, expected_co2_resid_kg=+0.0058), - _WorksheetPin(cert_number="9796-3058-6205-0346-9200", ws_pe_kwh_per_m2=53.6467, ws_co2_kg_per_yr=198.7122, expected_pe_resid=+0.0432, expected_co2_resid_kg=+0.0183), - _WorksheetPin(cert_number="9836-7525-9500-0575-1202", ws_pe_kwh_per_m2=253.8868, ws_co2_kg_per_yr=3101.1029, expected_pe_resid=+0.0366, expected_co2_resid_kg=+0.0026), + _WorksheetPin(cert_number="0390-2066-4250-2026-4555", ws_pe_kwh_per_m2=176.7478, ws_co2_kg_per_yr=2500.4581, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0464-3032-0205-4276-3204", ws_pe_kwh_per_m2=179.2365, ws_co2_kg_per_yr=1845.9475, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0652-3022-1205-2826-1200", ws_pe_kwh_per_m2=251.0214, ws_co2_kg_per_yr=2828.3691, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="1536-9325-5100-0433-1226", ws_pe_kwh_per_m2=180.8432, ws_co2_kg_per_yr=2054.3609, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2007-3011-9205-8136-3204", ws_pe_kwh_per_m2=172.6227, ws_co2_kg_per_yr=2567.5298, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2031-3007-0205-1296-3204", ws_pe_kwh_per_m2=191.4198, ws_co2_kg_per_yr=2257.9561, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2102-3018-0205-7886-5204", ws_pe_kwh_per_m2=228.1961, ws_co2_kg_per_yr=4104.7798, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2130-3018-4205-4686-5204", ws_pe_kwh_per_m2=181.4083, ws_co2_kg_per_yr=2364.3480, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2225-3062-8205-2856-7204", ws_pe_kwh_per_m2=52.6750, ws_co2_kg_per_yr=389.8819, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2336-3124-3600-0517-1292", ws_pe_kwh_per_m2=68.1077, ws_co2_kg_per_yr=458.6131, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2536-2525-0600-0788-2292", ws_pe_kwh_per_m2=87.5683, ws_co2_kg_per_yr=375.6003, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2590-3025-7205-9066-0200", ws_pe_kwh_per_m2=171.8691, ws_co2_kg_per_yr=2396.4327, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2636-0525-2600-0401-2296", ws_pe_kwh_per_m2=52.5660, ws_co2_kg_per_yr=395.4880, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2699-3025-5205-8066-0200", ws_pe_kwh_per_m2=168.4755, ws_co2_kg_per_yr=2498.3764, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2800-7999-0322-4594-3563", ws_pe_kwh_per_m2=89.2727, ws_co2_kg_per_yr=395.0757, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="3136-7925-4500-0246-6202", ws_pe_kwh_per_m2=238.6376, ws_co2_kg_per_yr=1752.3516, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="3336-2825-9400-0512-8292", ws_pe_kwh_per_m2=84.7840, ws_co2_kg_per_yr=458.0332, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="3800-8515-0922-3398-3563", ws_pe_kwh_per_m2=58.7712, ws_co2_kg_per_yr=440.6740, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="4536-8325-3100-0409-1222", ws_pe_kwh_per_m2=181.7206, ws_co2_kg_per_yr=2109.2633, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="4800-3992-0422-0599-3563", ws_pe_kwh_per_m2=66.4814, ws_co2_kg_per_yr=259.3652, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="7836-3125-0600-0526-2202", ws_pe_kwh_per_m2=183.0794, ws_co2_kg_per_yr=1817.2248, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9036-0824-3500-0420-8222", ws_pe_kwh_per_m2=56.7016, ws_co2_kg_per_yr=433.6372, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9285-3062-0205-7766-7200", ws_pe_kwh_per_m2=56.9079, ws_co2_kg_per_yr=454.7771, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9370-3060-1205-3546-4204", ws_pe_kwh_per_m2=51.9889, ws_co2_kg_per_yr=494.0023, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9380-2957-7490-2595-3141", ws_pe_kwh_per_m2=207.1976, ws_co2_kg_per_yr=2176.1656, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9418-3062-8205-3566-7200", ws_pe_kwh_per_m2=58.5508, ws_co2_kg_per_yr=394.3858, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9421-3045-3205-1646-6200", ws_pe_kwh_per_m2=59.6459, ws_co2_kg_per_yr=295.3567, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9501-3059-8202-7356-0204", ws_pe_kwh_per_m2=182.3673, ws_co2_kg_per_yr=3554.1642, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9796-3058-6205-0346-9200", ws_pe_kwh_per_m2=53.6467, ws_co2_kg_per_yr=198.7122, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="9836-7525-9500-0575-1202", ws_pe_kwh_per_m2=253.8868, ws_co2_kg_per_yr=3101.1029, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), ) From 1382c8c886c96899e3120e2e9b9e82e14e713ae8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 21:32:29 +0000 Subject: [PATCH 11/16] =?UTF-8?q?docs:=20add=20AGENT=5FGUIDE.md=20?= =?UTF-8?q?=E2=80=94=20fresh-start=20onboarding=20for=20the=20SAP=20calcul?= =?UTF-8?q?ator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single durable doc so agents can pick up the calculator without reading historical handovers: (1) the accuracy bar for the two input paths (site-notes 1e-4 vs worksheet; API 1e-4 when a worksheet exists, ±0.5 register fallback otherwise; cross-mapper parity); (2) the per-line-walk debugging loop incl. comparing site-notes vs API; (3) the tools & pipeline (Summary PDF → extractor → from_elmhurst_site_notes → cert_to_inputs → calculate_sap_from_inputs → SapResult, plus the API from_api_response front-end, section helpers, and where the test vectors live). Pointer added from SAP_CALCULATOR.md; HANDOVER_* flagged as point-in-time notes. Co-Authored-By: Claude Opus 4.8 --- domain/sap10_calculator/docs/AGENT_GUIDE.md | 265 ++++++++++++++++++ .../sap10_calculator/docs/SAP_CALCULATOR.md | 5 + 2 files changed, 270 insertions(+) create mode 100644 domain/sap10_calculator/docs/AGENT_GUIDE.md diff --git a/domain/sap10_calculator/docs/AGENT_GUIDE.md b/domain/sap10_calculator/docs/AGENT_GUIDE.md new file mode 100644 index 00000000..8edc10c0 --- /dev/null +++ b/domain/sap10_calculator/docs/AGENT_GUIDE.md @@ -0,0 +1,265 @@ +# SAP calculator — agent guide (start here) + +This is the **canonical onboarding doc** for working on the SAP 10.2 / +RdSAP 10 calculator. It is meant to get you productive **without reading +any historical handover**. The `HANDOVER_*.md` files in this directory +are point-in-time session notes (useful for the specific residual they +chase, ignore otherwise). For deep architecture/API see +[`SAP_CALCULATOR.md`](SAP_CALCULATOR.md). + +Three things this doc gives you: (1) the **accuracy bar** for the two +input paths, (2) the **debugging loop**, (3) the **tools & pipeline**. + +--- + +## 0. The one-paragraph mental model + +A cert's data comes in via one of two front-ends — an **Elmhurst Summary +PDF** (site-notes path) or an **EPC-register API JSON** (API path). Both +map to the same typed `EpcPropertyData`, which feeds a deterministic +cascade that reproduces the RdSAP10 engine. Our **ground truth is the +Elmhurst worksheet PDF** (U985 / P960 / dr87) — the per-line `(1)..(286)` +calculation, not the rounded values the EPC register lodges. We pin the +cascade against the worksheet to **abs = 1e-4 on every line ref**. + +--- + +## 1. Accuracy expectations — site-notes vs API + +The worksheet PDF is **always** the target. The EPC register's lodged +SAP/CO2/PE are rounded *and* carry Elmhurst's own residual, so matching +the lodged values is not the goal — matching the worksheet is. + +| Path | Input | When a worksheet PDF exists for the cert | API/site-notes-only (no worksheet) | +|---|---|---|---| +| **Site-notes** | Elmhurst Summary PDF → extractor → `from_elmhurst_site_notes` | **abs = 1e-4** on continuous SAP **and every populated line ref** and cost / CO2 / PE | n/a (we always have the worksheet for site-notes fixtures) | +| **API** | register JSON → `from_api_response` | **abs = 1e-4** on continuous SAP vs the worksheet (same bar as site-notes — the two paths must converge) | **±0.5** SAP vs the lodged register value (fallback only) | + +Three rules that fall out of this: + +- **Cross-mapper parity.** For a cert that has both an API JSON and an + Elmhurst Summary, the two paths must produce SAP within **1e-4 of each + other** *and* of the worksheet. The cascade output (not a structural + EPC diff) is the equivalence check. A divergence localises to one + mapper. +- **No tolerance widening.** A failing 1e-4 pin is a real cascade bug or + a fixture defect — diagnose it, don't relax it. No `rel=`, no `xfail`, + no adaptive ceilings. ΔSAP = 0.07 is **not** "closed". +- **±0.5 is a fallback, not a destination.** It's only for API-only + certs with no worksheet to check against. If you can get a worksheet, + the bar is 1e-4. + +Two documented, deliberate exceptions to "match the spec literal" live +in [`SAP_CALCULATOR.md` §8](SAP_CALCULATOR.md) ("Elmhurst-mirrored spec +divergences") — cases where the BRE-approved Elmhurst engine diverges +from the SAP 10.2 text and we mirror the engine. Add a §8 row only with +≥2-cert evidence. + +--- + +## 2. The tools & pipeline + +### 2.1 The two PDFs per cert + +- **`Summary_NNNNNN.pdf`** — the Elmhurst **site notes / input**. This is + what the assessor lodged: dimensions, fabric, heating system, controls, + cylinder, etc. It is the INPUT, equivalent to the API JSON. +- **The worksheet** — the **ground truth output**, every line ref + `(1)..(286)` to 4 d.p. Three families, all the same format: + - `U985-0001-NNNNNN.pdf` — the 6 gas-combi conformance fixtures. + - `P960-0001-NNNNNN.pdf` — the heating-systems corpus + community heating. + - `dr87-0001-NNNNNN.pdf` — the API-paired cohort ("Additional data with api"). + +### 2.2 The cascade pipeline (site-notes path) + +```python +import subprocess, re +from pathlib import Path +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + cert_to_inputs, cert_to_demand_inputs, local_climate_for_cert, +) +from domain.sap10_calculator.calculator import calculate_sap_from_inputs + +# 1. Summary PDF -> per-page text (pdftotext -layout, one string per page) +def summary_pdf_to_pages(pdf: Path) -> list[str]: + n = int(re.search(r"Pages:\s+(\d+)", + subprocess.run(["pdfinfo", str(pdf)], capture_output=True, text=True).stdout).group(1)) + pages = [] + for i in range(1, n + 1): + layout = subprocess.run( + ["pdftotext", "-layout", "-f", str(i), "-l", str(i), str(pdf), "-"], + capture_output=True, text=True).stdout + pages.append("\n".join( + tok for line in layout.splitlines() for tok in re.split(r"\s{2,}", line.strip()) if tok)) + return pages + +pages = summary_pdf_to_pages(Path("sap worksheets/.../Summary_NNNNNN.pdf")) +site_notes = ElmhurstSiteNotesExtractor(pages).extract() # -> ElmhurstSiteNotes +epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) # -> EpcPropertyData + +# 2. Two cascades. RATING = SAP/EI rating (UK-avg climate, region 0). +# DEMAND = Current Carbon / Current PE / Fuel Bill (postcode climate, PCDB Table 172). +rating = calculate_sap_from_inputs(cert_to_inputs(epc)) +demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) # climate = local_climate_for_cert(epc) + +rating.sap_score_continuous # un-rounded SAP — pin THIS, not the integer +rating.total_fuel_cost_gbp +rating.co2_kg_per_yr +demand.primary_energy_kwh_per_yr +``` + +Shortcut: `Sap10Calculator().calculate(epc)` runs the rating cascade +(`cert_to_inputs` → `calculate_sap_from_inputs`) in one call. + +### 2.3 The API path + +Identical from `EpcPropertyData` onward — only the front-end changes: + +```python +import json +data = json.loads(Path("tests/domain/sap10_calculator/rdsap/fixtures/golden/.json").read_text()) +epc = EpcPropertyDataMapper.from_api_response(data) # -> EpcPropertyData +# ... same cert_to_inputs / calculate_sap_from_inputs as above +``` + +### 2.4 Section helpers — intermediate line refs + +Every worksheet section has a `
_section_from_cert(epc)` helper +returning a typed result with the line-ref values. Use these to inspect +where a residual originates **without** running the whole cascade +(`postcode_climate=` selects rating vs demand): + +```python +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + water_heating_section_from_cert, # §4 (42)..(65)m + heat_transmission_section_from_cert, # §3 (26)..(37) + internal_gains_section_from_cert, # §5 (66)..(73) + mean_internal_temperature_section_from_cert, # §7 (85)..(94) + space_heating_section_from_cert, # §8 (95)..(99) + fuel_cost_section_from_cert, # §10a (240)..(255) + environmental_section_from_cert, # §12 (261)..(274) + primary_energy_section_from_cert, # §13a (275)..(286) +) +wh = water_heating_section_from_cert(epc) +wh.energy_content_monthly_kwh # (45)m ; wh.output_kwh_per_yr # (62)/(64) +``` + +(Full table of helpers + line refs is in [`SAP_CALCULATOR.md` §1.3](SAP_CALCULATOR.md).) + +### 2.5 Reading the worksheet from the shell + +```bash +# Dump a worksheet line ref (e.g. (217)m water-heater monthly efficiency): +pdftotext -layout "sap worksheets/.../P960-0001-NNNNNN.pdf" - | grep -nE "\(217\)|\(62\)|\(210\)" +# Read a Summary input field (controls, cylinder, fuel): +pdftotext -layout "sap worksheets/.../Summary_NNNNNN.pdf" - | grep -niE "cylinder|control|interlock|fuel" +``` + +### 2.6 Where the test vectors live + +| Set | Location | What | +|---|---|---| +| 6 U985 conformance fixtures | `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_NNNNNN.py` (+ Summary PDFs in `backend/documents_parser/tests/fixtures/`) | Gas-combi certs, every line ref transcribed as `LINE_*` / `DEMAND_LINE_*` constants. Pinned in `worksheet/test_section_cascade_pins.py` + `worksheet/test_e2e_elmhurst_sap_score.py`. | +| Heating-systems corpus | `sap worksheets/heating systems examples//` (Summary + P960) | 41 variants of **one property** with only the heating system changed → any residual is attributable to the heating subsystem. Pinned in `backend/documents_parser/tests/test_heating_systems_corpus.py`. | +| API golden fixtures | `tests/domain/sap10_calculator/rdsap/fixtures/golden/.json` | Register JSON for the API path. | +| API + worksheet pairs | `sap worksheets/Additional data with api//` (Summary + dr87) | Certs that have BOTH an API JSON and a worksheet → cross-mapper parity checks. | + +--- + +## 3. The debugging loop + +When a cert's SAP/cost/CO2/PE is off, **never guess a fix** — walk it. + +1. **Reproduce & decompose.** Build the epc (extractor+mapper, or a + fixture's `build_epc()`), run both cascades, and see **which of the + four outputs** drifts. Cost/CO2/PE drift with the same sign as energy; + isolate the carrier. +2. **Find the section.** Walk the four metrics back to a worksheet + section: SAP off but cost EXACT often means a demand/gains issue; + cost off but energy EXACT means a price/factor issue; CO2/PE off but + cost EXACT means a factor issue. Use the §2.4 section helpers to get + the cascade's intermediate line refs. +3. **Per-line compare vs the worksheet.** `pdftotext -layout` the + worksheet and compare the cascade's `(45)/(56)/(62)/(210)/(217)m/...` + line-by-line against the PDF. The first diverging line ref is the bug. +4. **Localise to a layer.** + - cascade value present in worksheet but cascade has 0 / wrong → **calculator** gap (a spec rule not wired, or a dispatch gate). + - the input field the worksheet used isn't in `epc` → **mapper** (mis-mapped) or **extractor** (didn't capture the Summary field). Audit the Summary PDF for the field first — many lodgements are incomplete and the fixture, not the calculator, is wrong. +5. **Cite the spec.** Find the SAP 10.2 / RdSAP 10 rule (page + line) that + produces the worksheet's number. Confirm the worksheet matches the + spec literal; if it diverges, it's a candidate §8 Elmhurst-mirror + (needs ≥2-cert evidence). **SAP 10.2 only — never 10.3.** +6. **Cross-check vs API (when available).** If the cert has an API JSON + too, run `from_api_response` through the same cascade. If the API path + matches the worksheet but the site-notes path doesn't (or vice-versa), + the bug is in **that mapper**, not the calculator. If both diverge + identically, it's the **calculator/cascade**. +7. **Fix one cause, re-pin smaller.** TDD: one failing AAA test → one + impl → re-pin the (now smaller) residual. A spec-correct fix often + **exposes** the next residual that an offsetting bug was masking — + that's the next slice, not a regression. Don't conflate + `main_heating_category` (often `None` on Elmhurst Table 4b boilers) + with `sap_main_heating_code`. + +### Worked shape (real example: oil 6) + +Residual +3.05 SAP. (1) HW + space both off. (2) §4 HW efficiency. (3) +worksheet (210) space eff = 75 but Table 4b code 126 = 80; (217)m summer += 63 = 68−5 → a −5pp penalty. (4) the Summary lodges control `2101` ("no +thermostatic control of room temperature") → no room thermostat → P960 +header "Boiler Interlock: No". (5) RdSAP 10 §3 + SAP 10.2 Table 4c(2): +no room thermostat ⇒ not interlocked ⇒ −5pp Space+DHW. Fix the +`no_interlock` gate → space+HW fuel EXACT, residual collapses to a single +exposed pump cause (Table 4f footnote a) ×1.3) → next slice. Two slices, +fully closed. + +--- + +## 4. Run the suite + +```bash +PYTHONPATH=/workspaces/model python -m pytest \ + tests/domain/sap10_calculator/ \ + backend/documents_parser/tests/ \ + --no-cov -q -p no:cacheprovider +``` + +Conformance pins only: + +```bash +PYTHONPATH=/workspaces/model python -m pytest \ + tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py \ + tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py \ + backend/documents_parser/tests/test_heating_systems_corpus.py \ + --no-cov -q +``` + +Notes: +- `load_cells` tests pin against the gitignored `*.xlsx` reference + worksheet at repo root; they **skip** when it's absent (CI), run + locally when present. +- All new code passes `pyright` strict, zero errors. Tests use literal + `# Arrange / # Act / # Assert` headers and `abs(x - y) <= tol` (not + `pytest.approx`, which strict-pyright flags). +- Commit one slice per change, with the spec citation in the message. + +--- + +## 5. Spec PDFs on disk + +``` +domain/sap10_calculator/docs/specs/ + sap-10-2-full-specification-2025-03-14.pdf # SAP 10.2 (the methodology) + RdSAP 10 Specification 10-06-2025.pdf # RdSAP 10 (the reduced-data rules) + pcdb10.dat / pcdb_table_*.jsonl # PCDB (boilers, HPs, postcode weather) +``` + +Pages worth bookmarking: SAP 10.2 §7 MIT (p.28-32), Table 4b boiler eff +(p.168), Table 4c efficiency adjustments (p.169), Table 4e controls +(p.171-174), Table 4f auxiliary energy (p.175), Table 12 factors (p.191), +Appendix U region tables (p.124-127). RdSAP 10 §10 water heating (p.54-56, +incl. §10.7 no-water-heating default), Table 28/29 cylinder defaults, +Table 32 prices (p.95). +``` diff --git a/domain/sap10_calculator/docs/SAP_CALCULATOR.md b/domain/sap10_calculator/docs/SAP_CALCULATOR.md index b8db1853..7e78d99c 100644 --- a/domain/sap10_calculator/docs/SAP_CALCULATOR.md +++ b/domain/sap10_calculator/docs/SAP_CALCULATOR.md @@ -1,5 +1,10 @@ # SAP 10.2 / RdSAP 10 calculator — module overview +> **New here? Start with [`AGENT_GUIDE.md`](AGENT_GUIDE.md)** — the +> accuracy bar (site-notes vs API), the debugging loop, and the +> tools/pipeline. This file is the deeper architecture + API reference; +> the `HANDOVER_*` files are point-in-time session notes, not onboarding. + Deterministic, bit-faithful replication of the RdSAP10 calculation engine. Validated against the 6 Elmhurst U985 worksheet PDFs at **abs=1e-4 on every line ref** for both the Rating cascade (UK-average climate, used From e03f08cdc8340373c241f74388195b892a9c71f4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:01:35 +0000 Subject: [PATCH 12/16] =?UTF-8?q?S0380.189:=20thermal=20mass=20parameter?= =?UTF-8?q?=20per=20RdSAP=2010=20=C2=A75.16=20Table=2022,=20not=20hardcode?= =?UTF-8?q?d=20250?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §7 mean-internal-temperature cascade hardcoded the thermal mass parameter (TMP) to 250 kJ/m²K at all 5 call sites, ignoring construction. RdSAP 10 §5.16 Table 22 (PDF p.48) makes TMP construction-dependent: 100 kJ/m²K — timber frame, cob, park home (regardless of internal insulation); OR masonry (stone/solid brick/cavity/system built) WITH internal insulation. 250 kJ/m²K — masonry WITHOUT internal insulation. A too-high TMP inflates the §7 time constant τ = Cm/(3.6·H) (e.g. 40 h vs 16 h), under-cuts the temperature reduction between heating periods, and over-states mean internal temperature → over-states space heating. `_thermal_mass_parameter_kj_per_m2_k(epc)` classifies the MAIN building's wall via the RdSAP `wall_construction` codes (5/7/8 = timber/cob/park) and `wall_insulation_type` codes (3/7 = internal); unknown/curtain fall back to the masonry 250 (no regression on unlisted classes). 17-case parametrised test covers every Table 22 branch. Diagnosis (per-line walk vs the user-simulated 001431 worksheet, same archetype as golden cert 6035): fabric (26-37), internal gains (73), climate (96)m and HTC (39) all EXACT; the entire +8.78 PE / -1.76 SAP gap was §7 MIT (92) +0.71 °C, traced to TMP 250 vs Table 22's 100 (solid brick WITH internal insulation). Fix closes the simulated case to 1e-4 on PE and CO2. Blast radius: only golden cert 6035 re-pins (solid brick + internal insulation) — SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. The 47 dr87 cohort, 6 U985 fixtures and 41-variant heating corpus are all masonry-no-internal → TMP unchanged at 250, all still pass. 2290 pass (+17 new), 0 fail; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 53 +++++++++++++++-- .../rdsap/test_cert_to_inputs.py | 59 +++++++++++++++++++ .../rdsap/test_golden_fixtures.py | 15 ++++- 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 6b8e15dc..0d99af01 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -210,7 +210,26 @@ _LIVING_AREA_FRACTION_MIN: Final[float] = 0.13 _PENCE_TO_GBP: Final[float] = 0.01 +# RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter (TMP), +# keyed on the construction type of the MAIN building (not extensions): +# 100 kJ/m²K — timber frame, cob, park home (the three types regardless +# of internal insulation); OR masonry (stone, solid brick, +# cavity, system built) WITH internal insulation. +# 250 kJ/m²K — masonry WITHOUT internal insulation. +# This default is the masonry-no-internal value; `_thermal_mass_parameter_ +# kj_per_m2_k` lowers it to 100 for the Table 22 low-mass cases. Unknown / +# unmapped / curtain-wall constructions keep the 250 default (the +# pre-Table-22 behaviour, so no fixture regresses on a missing class). _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 +_TMP_LOW_KJ_PER_M2_K: Final[float] = 100.0 +# `wall_construction` int codes (domain/sap10_ml/rdsap_uvalues.py): +# 5 = timber frame, 7 = cob, 8 = park home — Table 22's "three types". +_TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: Final[frozenset[int]] = frozenset({5, 7, 8}) +# `wall_insulation_type` int codes that are INTERNAL insulation +# (Table 22 "masonry … with internal insulation"): 3 = internal wall +# insulation, 7 = filled cavity + internal. External (1), filled cavity +# (2), cavity+external (6), as-built (4), none (5) keep the masonry 250. +_TMP_INTERNAL_WALL_INSULATION_CODES: Final[frozenset[int]] = frozenset({3, 7}) # SAP 10.2 Table 4f (PDF p.174) — Heating system circulation pump # rows. Keyed on RdSAP API `central_heating_pump_age` enum: @@ -3269,6 +3288,30 @@ def _int_or_none(value: object) -> Optional[int]: return value if isinstance(value, int) else None +def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float: + """RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from + the MAIN building's wall construction. + + Timber frame / cob / park home → 100 kJ/m²K regardless of insulation. + Masonry (stone, solid brick, cavity, system built) → 100 with internal + insulation, else 250. Unknown / unmapped / curtain-wall constructions + fall back to the masonry default (250). See the Table 22 constant + comments above for the `wall_construction` / `wall_insulation_type` + code sets. TMP feeds the §7 time constant τ = Cm/(3.6·H); a wrong + (too-high) TMP slows the cooling rate, under-cuts the §7 temperature + reduction, and over-states mean internal temperature → space heating. + """ + parts: list[SapBuildingPart] = epc.sap_building_parts or [] + if not parts: + return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K + main: SapBuildingPart = parts[0] + if _int_or_none(main.wall_construction) in _TMP_ALWAYS_LOW_WALL_CONSTRUCTION_CODES: + return _TMP_LOW_KJ_PER_M2_K + if _int_or_none(main.wall_insulation_type) in _TMP_INTERNAL_WALL_INSULATION_CODES: + return _TMP_LOW_KJ_PER_M2_K + return _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K + + @dataclass(frozen=True) class _VentilationCounts: open_flues: int = 0 @@ -3513,7 +3556,7 @@ def mean_internal_temperature_section_from_cert( ), monthly_total_gains_w=monthly_total_gains_w, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), total_floor_area_m2=dim.total_floor_area_m2, control_type=_control_type(main), responsiveness=_responsiveness(main, tariff=tariff), @@ -3612,7 +3655,7 @@ def space_cooling_section_from_cert( monthly_external_temperature_c=monthly_external_temp_c, monthly_total_gains_w=(0.0,) * 12, total_floor_area_m2=dim.total_floor_area_m2, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), cooled_area_fraction=0.0, intermittency_factor=0.25, ) @@ -6174,7 +6217,7 @@ def cert_to_inputs( ), monthly_total_gains_w=monthly_total_gains_w, monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), total_floor_area_m2=dim.total_floor_area_m2, control_type=control_type_value, responsiveness=responsiveness_value, @@ -6270,7 +6313,7 @@ def cert_to_inputs( monthly_external_temperature_c=monthly_external_temp_c, monthly_total_gains_w=(0.0,) * 12, total_floor_area_m2=dim.total_floor_area_m2, - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), cooled_area_fraction=0.0, intermittency_factor=0.25, ) @@ -6454,7 +6497,7 @@ def cert_to_inputs( responsiveness=responsiveness_value, living_area_fraction=living_area_fraction_value, control_temperature_adjustment_c=_control_temperature_adjustment_c(main), - thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), main_heating_efficiency=eff, hot_water_kwh_per_yr=hw_kwh, pumps_fans_kwh_per_yr=pumps_fans_kwh, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index ccfdee3e..c8b92602 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -59,6 +59,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _separately_timed_dhw, # pyright: ignore[reportPrivateUsage] _space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage] + _thermal_mass_parameter_kj_per_m2_k, # pyright: ignore[reportPrivateUsage] _water_efficiency_with_category_inherit, # pyright: ignore[reportPrivateUsage] _water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage] cert_to_demand_inputs, @@ -122,6 +123,64 @@ def _typical_semi_detached_epc(): ) +@pytest.mark.parametrize( + "wall_construction, wall_insulation_type, expected_tmp", + [ + # RdSAP 10 §5.16 Table 22 (PDF p.48) — timber frame (5), cob (7), + # park home (8) are always low-mass, regardless of insulation. + (5, 4, 100.0), # timber frame, as-built + (5, 2, 100.0), # timber frame, filled cavity — still 100 + (7, 4, 100.0), # cob + (8, 1, 100.0), # park home, external insulation — still 100 + # Masonry WITH internal insulation (ins 3 = internal, 7 = + # filled cavity + internal) → low-mass 100. + (3, 3, 100.0), # solid brick + internal + (3, 7, 100.0), # solid brick + filled cavity + internal + (4, 3, 100.0), # cavity + internal + (6, 3, 100.0), # system built + internal + (1, 3, 100.0), # stone granite + internal + # Masonry WITHOUT internal insulation → high-mass 250. External + # (1), filled cavity (2), cavity+external (6), as-built (4) all + # leave the structural mass coupled. + (3, 4, 250.0), # solid brick, as-built + (4, 2, 250.0), # cavity, filled + (4, 1, 250.0), # cavity, external insulation (NOT internal) + (4, 6, 250.0), # cavity + external + (1, 4, 250.0), # stone, as-built + (6, 4, 250.0), # system built, as-built (Table 22 lists it as masonry) + # Unmapped / curtain (9) / unknown (10) → masonry default 250 + # (pre-Table-22 behaviour; no fixture regresses on a missing class). + (9, 4, 250.0), # curtain wall + (10, 4, 250.0), # unknown + ], +) +def test_thermal_mass_parameter_follows_rdsap_table_22( + wall_construction: int, wall_insulation_type: int, expected_tmp: float +) -> None: + # Arrange — a single-part dwelling carrying the wall construction + + # insulation under test (RdSAP 10 §5.16 Table 22, PDF p.48). + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[ + make_building_part( + wall_construction=wall_construction, + wall_insulation_type=wall_insulation_type, + ), + ], + sap_heating=make_sap_heating( + main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)], + ), + ) + + # Act + tmp: float = _thermal_mass_parameter_kj_per_m2_k(epc) + + # Assert + assert abs(tmp - expected_tmp) <= 1e-9 + + def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> None: # Arrange — heat-network main heating (Table 4a code 301 = community # heating with CHP/boilers; main_heating_category=6). Cert age band diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 7ca77668..dc000956 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -193,11 +193,20 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="6035-7729-2309-0879-2296", actual_sap=70, - expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=+46.4156, - expected_co2_resid_tonnes_per_yr=+1.0677, + expected_sap_resid=-2, + expected_pe_resid_kwh_per_m2=+19.1566, + expected_co2_resid_tonnes_per_yr=+0.4211, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " + "S0380.189 fixed the dominant driver: walls are solid brick " + "WITH internal insulation (wall_insulation_type=3), so " + "RdSAP 10 §5.16 Table 22 sets TMP=100 (not the old hardcoded " + "250). Correct TMP → §7 time constant ~16h not ~40h → larger " + "temperature reduction → MIT down ~0.7C → space heating drops. " + "SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. " + "Validated 1e-4 against the user-simulated 001431 worksheet " + "(same archetype). Remaining +19 PE is other gaps + lodged " + "divergence (no worksheet for 6035 itself to pin further). " "Slice 59 per-bp window apportionment tightens all 3 " "residuals: SAP -5 → -4, PE +36.15 → +34.02, CO2 +0.81 → " "+0.76 (2 of 8 windows route to Ext1 with ins_type 4 vs " From e63d046b9d55b55c9775004cbd7ff6451b162843 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:05:41 +0000 Subject: [PATCH 13/16] =?UTF-8?q?docs:=20handover=20post=20S0380.189=20?= =?UTF-8?q?=E2=80=94=20TMP/Table=2022=20+=20the=20two=20open=20follow-ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point-in-time note for the next agent: what S0380.185-189 shipped (worksheet PE/CO2 pins, the two D_PV electricity-vs-gain fixes, and the thermal-mass- parameter Table 22 fix), the per-line diagnosis template, the two worksheet- block / gains-vs-solar traps, and the ranked open slices (Summary-path fuel derivation first, then pin the simulated 001431 case, then cert 6035). Co-Authored-By: Claude Opus 4.8 --- .../docs/HANDOVER_POST_S0380_189.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_POST_S0380_189.md diff --git a/domain/sap10_calculator/docs/HANDOVER_POST_S0380_189.md b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_189.md new file mode 100644 index 00000000..0ec6fa30 --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_POST_S0380_189.md @@ -0,0 +1,114 @@ +# Handover — post S0380.189 (thermal mass parameter / Table 22) + +Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for the +methodology, accuracy bar, and pipeline — this doc only records *what this +session did* and *what is open*. + +- **Branch:** `feature/per-cert-mapper-validation` +- **HEAD:** `e03f08cd` (S0380.189) +- **Baseline:** `2290 passed, 1 skipped, 0 failed` (the skip is the + gitignored xlsx `load_cells` test). Verify with the §4 suite command. + +--- + +## What this session shipped (S0380.185–189) + +| Slice | What | Spec | +|---|---|---| +| **.185** | Recorded the CH6 "pin-forever" proof — distribution-loss is an Elmhurst Summary-export gap, not a mapper miss (controlled adjoining-dwellings pair byte-identical in inputs). | — | +| **.186** | Added `test_golden_cert_pe_co2_matches_worksheet` — pins calc PE/CO2 for the 47 worksheet-backed certs against the dr87 `(286)`/`(272)` at full precision (not lodged register values). | Appendix U | +| **.187** | Appendix M1 §3a `D_PV,m` was missing **electric secondary** space heating `(215)m` — under-credited PV on gas-main+electric-secondary+PV certs. | SAP 10.2 App M1 §3a | +| **.188** | `D_PV,m` used the Appendix L **L12 lighting GAIN** (`= E_L×0.85`) instead of the **L10 lighting ELECTRICITY** `(232)`. Closed the whole PV cohort to 1e-4. Same gain-vs-electricity class as the S0380.73 cooking fix. | SAP 10.2 App L L10/L12, M1 §3a | +| **.189** | **Thermal mass parameter** was hardcoded 250 at all 5 §7/§8 call sites. Now `_thermal_mass_parameter_kj_per_m2_k(epc)` per Table 22. | RdSAP 10 §5.16 Table 22 (p.48) | + +After .186–.188 **all 47 worksheet-backed certs match calc≡worksheet at +<1e-4 on PE and CO2** (the convergence target). See +[`project-golden-coverage-state` memory] for the per-slice detail. + +--- + +## The S0380.189 diagnosis (template for the open work) + +Driven entirely by the **per-line walk** (AGENT_GUIDE §3) against a +**user-simulated worksheet** for a 6035-archetype property: + +- **Fixture:** `sap worksheets/golden fixture debugging/simulated case 1/` + — `Summary_001431 (1).pdf` (input) + `P960-0001-001431 - ….pdf` (worksheet + ground truth). A gas-combi mid-terrace, TFA 128, solid brick **with + internal insulation**, no PV/secondary/cylinder. (The `P960-…` prefix is + just the Elmhurst account id; cert is 001431.) **These PDFs are untracked** + — the user holds them locally. +- **Decompose:** PE +8.78 / SAP −1.76 / CO2 +0.21, *entirely* space-heating + demand (+838 kWh). Fabric `(26–37)`, internal gains `(73)`, climate + `(96)m`, HTC `(39)` all EXACT → localised to **§7 MIT** `(92)` +0.71 °C. +- **Root cause:** TMP hardcoded 250; cert is masonry **with internal + insulation** → RdSAP 10 §5.16 Table 22 = **100**. Wrong TMP → time + constant τ=Cm/(3.6·H) ≈40 h not 16 h → §7 temperature reduction too small + → MIT too high → space heating over-stated. +- **Fix + blast radius:** only golden cert **6035** re-pinned (SAP −6→−2, PE + +46.42→+19.16, CO2 +1.07→+0.42). All other fixtures are masonry-no-internal + → TMP unchanged. + +### Two diagnostic traps that cost false starts (read before debugging §7/§8) + +1. **The worksheet has TWO blocks per dwelling.** The first is the SAP-rating + block (UK-average climate, region 0); the second, under *"CALCULATION OF + EPC COSTS, EMISSIONS AND PRIMARY ENERGY"*, is the postcode block. The + **demand cascade (`cert_to_demand_inputs`) matches the POSTCODE block.** + Comparing calc HTC/MIT to the UK-avg block shows phantom gaps (e.g. HTC + "−10.8"); against the postcode block HTC is exact. +2. **`(84)` Total gains = internal `(73)` + solar `(83)`.** The calc's + `internal_gains_annual_avg_w` is `(73)` only — don't diff it against `(84)` + (a phantom "−248 W" gap that is just solar). + +Use the §2.4 section helpers (`mean_internal_temperature_section_from_cert`, +`space_heating_section_from_cert`, `internal_gains_section_from_cert`, +`local_climate_for_cert`) for the per-line walk. + +--- + +## OPEN — next slices (ranked) + +### 1. Summary-path `main_fuel_type` derivation from the SAP code ← do first +The Elmhurst **Summary PDF has no main-heating fuel field** — only the SAP +code (`14.0 Main Heating1 → Main Heating SAP Code 104`). So +`from_elmhurst_site_notes` leaves `main_fuel_type=''`, and `cert_to_inputs` +raises `MissingMainFuelType` (cert_to_inputs.py `_main_fuel_code`). This +**blocks the Summary path for every gas-combi cert**, including the simulated +case (I injected `main_fuel_type=26` to run the diagnosis). + +Fix: derive the fuel in the mapper (or `_main_fuel_code`) from +`sap_main_heating_code` via SAP 10.2 Table 4b (104 = condensing combi **mains +gas**), mirroring the existing strict-raise → derive pattern. Cite the table. +This is the higher-leverage win — it unblocks the whole site-notes gas-combi +population, not one cert. + +### 2. Pin the simulated 001431 case end-to-end (after #1) +Once #1 lands, the Summary path runs natively. Add it as a site-notes fixture +and pin every line ref at 1e-4. **NB:** with .189 + injected fuel, PE and CO2 +close to 1e-4 but **SAP showed +0.0007** vs a hand-computed target (`100 − +13.95×ECF` from the 4-dp `(257)`=1.6047). That is almost certainly ECF +rounding in the target, not a real gap — but **verify against the worksheet's +own continuous SAP** before declaring it closed (don't trust my hand value). + +### 3. Cert 6035 remaining +19 PE +6035 is API/lodged-only (no worksheet) so it can't be pinned past the lodged +register. The user can reproduce 6035 *exactly* in Elmhurst to get a +worksheet — offer to format the golden JSON +(`tests/domain/sap10_calculator/rdsap/fixtures/golden/6035-7729-2309-0879-2296.json`) +as Elmhurst inputs. With a worksheet, walk the remaining +19 the same way. + +### Carry-over (lower priority) +- `transform.py:973` treats `wall_construction in (5,6)` as timber-frame for + the ventilation structural-ACH split, but Table 22 / `rdsap_uvalues` + classify 6 = **system built (masonry)**, only 5/7/8 are timber/cob/park. + Possible latent ventilation-ACH bug — verify before touching. + +--- + +## Process notes +- One slice = one commit, spec citation in the message, `Co-Authored-By: + Claude Opus 4.8` trailer. AAA tests, `abs(x-y) <= tol` (not `pytest.approx`). +- The golden worksheet PE/CO2 pins (.186) re-pin smaller as gaps close — never + widen. The lodged-residual pins (`test_golden_cert_residual_matches_pin`) + carry the API-vs-register residual and move when the calc improves. From e43ff79c77bab347378432b0526afbfd81a7603e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:23:02 +0000 Subject: [PATCH 14/16] =?UTF-8?q?S0380.190:=20derive=20gas-combi=20main=20?= =?UTF-8?q?fuel=20from=20=C2=A715.0=20when=20=C2=A714.0=20Fuel=20Type=20is?= =?UTF-8?q?=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The newer Elmhurst Summary export lodges a gas combi as §14.0 "Fuel Type" empty + "Main Heating SAP Code" 104 (EES "BGW"), with no fuel string. The site-notes mapper left `main_fuel_type=''`, so `cert_to_inputs` raised `MissingMainFuelType` — blocking the whole gas-combi Summary path (reproduced on the simulated 001431 case). SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas boilers (including mains gas, LPG and biogas)": the code fixes the boiler type/efficiency but NOT the carrier, so 104 alone can't distinguish mains gas from LPG. The disambiguator is §15.0 "Water Heating Fuel Type" — a combi/boiler heats space + water from one appliance — exactly mirroring the existing liquid-fuel (codes 120-141) fallback. `_elmhurst_gas_boiler_main_fuel` adopts the §15.0 carrier only when the SAP code is in 101-119 AND §15.0 resolves to a gas/LPG fuel, so a regular boiler + electric immersion (§15.0 = "Electricity") still strict-raises rather than mis-billing gas as electric. 2291 passed (+1), 0 failed; pyright net-zero on both files. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 66 +++++++++++++++++++ .../rdsap/test_cert_to_inputs.py | 31 +++++++++ 2 files changed, 97 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 7c6532a9..0ddb4baa 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4143,6 +4143,59 @@ _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = ( frozenset(range(120, 142)) ) +# SAP 10.2 Table 4b gas-boiler code range (PDF p.168). Rows 101-119 are +# "Gas boilers (including mains gas, LPG and biogas)" — 101-109 are +# 1998-or-later, 110-114 pre-1998 fan-assisted flue, 115-119 pre-1998 +# balanced/open flue. The code identifies the boiler TYPE/efficiency, not +# the specific carrier: the same row applies to mains gas, bulk/bottled +# LPG and biogas alike. The older Elmhurst export lodged §14.0 "Fuel +# Type: Mains gas" explicitly, but the newer form leaves §14.0 "Fuel +# Type" empty and lodges only the SAP code (e.g. 104 condensing combi, +# EES "BGW"). For these, §15.0 "Water Heating Fuel Type" names the +# carrier — a combi/boiler heats space + water from the one appliance — +# so it disambiguates mains-gas-vs-LPG. Codes 120-141 (CPSU + range +# cookers) are already covered by +# `_LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES`. +_GAS_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = ( + frozenset(range(101, 120)) +) + +# SAP10 main-fuel codes in the gas / LPG family — the only carriers a +# Table 4b gas-boiler row (101-119) can have (mains gas, mains gas +# community, bottled/bulk/special-condition LPG). Per +# `_ELMHURST_MAIN_FUEL_TO_SAP10`: mains gas = 26, mains gas community = +# 1, LPG bottled/bulk/special = 5/6/7, "Bulk LPG" = 27. The §15.0 +# water-heating-fuel derivation is gated on the resolved fuel being one +# of these so it can't mis-assign electricity from a separate immersion +# (where §15.0 lodges the immersion's fuel, not the boiler's) — that +# case still strict-raises `MissingMainFuelType` to force a mapper fix. +_GAS_LPG_MAIN_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 5, 6, 7, 26, 27}) + + +def _elmhurst_gas_boiler_main_fuel( + sap_main_heating_code: Optional[int], + water_heating_fuel_code: Optional[int], +) -> Optional[int]: + """Derive a gas/LPG main-fuel code for a Table 4b gas boiler whose + §14.0 "Fuel Type" string is absent (newer Elmhurst export form). + + Returns the §15.0 water-heating fuel code when, and only when, the + SAP main-heating code is a Table 4b gas-boiler row (101-119) AND the + §15.0 fuel resolves to a gas/LPG carrier — the same combi/boiler + heats space + water, so §15.0 names the boiler's carrier. Returns + None otherwise (non-gas-boiler code, or §15.0 lodges a non-gas fuel + such as an electric immersion), leaving the caller to strict-raise. + + Spec: SAP 10.2 Table 4b "Seasonal efficiency for gas and liquid fuel + boilers" (PDF p.168) — rows 101-119 are gas-family boilers. + """ + if ( + sap_main_heating_code in _GAS_BOILER_SAP_MAIN_HEATING_CODES + and water_heating_fuel_code in _GAS_LPG_MAIN_FUEL_CODES + ): + return water_heating_fuel_code + return None + # Elmhurst §14.0 "Main Heating EES Code" → Table 32 main fuel code. # Empirically derived from the heating-systems corpus at @@ -4770,6 +4823,19 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: and mh.main_heating_sap_code in _LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES ): main_fuel_int = water_heating_fuel + # Gas / LPG boilers: SAP 10.2 Table 4b codes 101-119 (PDF p.168) + # identify a gas-family boiler but not the specific carrier (mains + # gas vs LPG vs biogas). The newer Elmhurst export leaves §14.0 + # "Fuel Type" empty and lodges only the SAP code (e.g. 104 condensing + # combi, EES "BGW"); the §15.0 "Water Heating Fuel Type" names the + # carrier because the same combi/boiler heats space + water. Adopt it + # only when it resolves to a gas/LPG fuel, so a regular boiler paired + # with an electric immersion (where §15.0 lodges "Electricity") still + # strict-raises rather than mis-billing the gas boiler as electric. + if main_fuel_int is None: + main_fuel_int = _elmhurst_gas_boiler_main_fuel( + mh.main_heating_sap_code, water_heating_fuel, + ) # Solid-fuel main heating: SAP code rows 150-160 (open / closed # room heaters with boiler) and 600-636 (independent solid-fuel # boilers) cover multiple distinct fuels under a single Table 4a diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index c8b92602..40ba7aff 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -2120,6 +2120,37 @@ def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> N assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71 +def test_elmhurst_gas_boiler_main_fuel_derives_carrier_from_water_heating() -> None: + # Arrange — SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas + # boilers (including mains gas, LPG and biogas)". The code identifies + # the boiler type/efficiency, NOT the carrier. The newer Elmhurst + # export leaves §14.0 "Fuel Type" empty and lodges only the SAP code + # (e.g. 104 condensing combi); the §15.0 "Water Heating Fuel Type" + # names the carrier because the same combi/boiler heats space + water. + from datatypes.epc.domain.mapper import ( + _elmhurst_gas_boiler_main_fuel, # pyright: ignore[reportPrivateUsage] + ) + + # Act / Assert — combi (104) + §15.0 mains gas (26) → mains gas. + assert _elmhurst_gas_boiler_main_fuel(104, 26) == 26 + # Regular condensing (102) + §15.0 bulk LPG (27) → bulk LPG. + assert _elmhurst_gas_boiler_main_fuel(102, 27) == 27 + # Boundary codes of the 101-119 gas-boiler range resolve too. + assert _elmhurst_gas_boiler_main_fuel(101, 26) == 26 + assert _elmhurst_gas_boiler_main_fuel(119, 5) == 5 # bottled LPG + # §15.0 lodges a separate electric immersion's fuel (30), NOT the + # gas boiler's carrier → no derivation; caller strict-raises. + assert _elmhurst_gas_boiler_main_fuel(104, 30) is None + # Non-gas-boiler SAP code (224 = air-source heat pump) → None even + # when §15.0 names a gas fuel (the HP doesn't burn it). + assert _elmhurst_gas_boiler_main_fuel(224, 26) is None + # Liquid-fuel boiler range (120-141) is owned by the separate + # `_LIQUID_FUEL_BOILER_SAP_MAIN_HEATING_CODES` branch → None here. + assert _elmhurst_gas_boiler_main_fuel(120, 26) is None + # No SAP code lodged → None. + assert _elmhurst_gas_boiler_main_fuel(None, 26) is None + + def test_elmhurst_main_heating_ees_maps_no_system_code_to_electricity() -> None: # Arrange — SAP 10.2 §A.2.2 (PDF p.189 area) "When no main heating # system is identified, the calculation is for the assumed system From 896b5740c347d8c07a7358a440edbecabbb2f0be Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:44:32 +0000 Subject: [PATCH 15/16] S0380.191: pin simulated 001431 gas-combi end-to-end at 1e-4 (e2e harness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user-simulated 001431 case (the cert that drove S0380.189/.190) as an Elmhurst-only e2e fixture: Summary PDF → extractor → mapper → calculator, every Block-1 SapResult field pinned against the P960-0001-001431 worksheet at abs=1e-4. All 11 pins pass with zero residual — the case is clean, confirming the S0380.190 gas-combi fuel derivation closes the Summary path natively. Verified the handover's flagged "+0.0007 SAP" was a target artifact, not a cascade gap: the worksheet displays ECF (257) rounded to 1.6047 and integer SAP (258)=78; the cascade's continuous SAP is computed from the UNROUNDED ECF = (255)*(256)/((4)+45) = 660.9750*0.4200/173.0, giving 77.6147 — which matches the worksheet's own unrounded value. Pinning the continuous SAP from the display-rounded ECF (→ 77.6144) was the wrong target. Block-1 line refs all match exactly: (211) 10699.7225, (219) 3327.1592, (231) 86.0, (232) 283.2229, (255) 660.9750, (272) 3000.1664, Σ(98) 8987.7669. Summary mirrored into the tracked fixtures dir as Summary_001431_gas_combi.pdf (distinct name — the corpus reuses cert 001431 across every heating variant); source Summary + worksheet tracked under sap worksheets/golden fixture debugging/ as the pin ground truth. 2302 passed (+11), 0 failed; pyright net-zero on new/changed files. Co-Authored-By: Claude Opus 4.8 --- .../fixtures/Summary_001431_gas_combi.pdf | Bin 0 -> 78690 bytes ...60-0001-001431 - 2026-06-02T221203.958.pdf | Bin 0 -> 44688 bytes .../simulated case 1/Summary_001431 (1).pdf | Bin 0 -> 78690 bytes .../worksheet/_elmhurst_worksheet_001431.py | 126 ++++++++++++++++++ .../worksheet/test_e2e_elmhurst_sap_score.py | 21 +++ 5 files changed, 147 insertions(+) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 1/P960-0001-001431 - 2026-06-02T221203.958.pdf create mode 100644 sap worksheets/golden fixture debugging/simulated case 1/Summary_001431 (1).pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1a15e3dac8a69db41c17f44d0d34a34dbfa2cfeb GIT binary patch literal 78690 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 literal 0 HcmV?d00001 diff --git a/sap worksheets/golden fixture debugging/simulated case 1/P960-0001-001431 - 2026-06-02T221203.958.pdf b/sap worksheets/golden fixture debugging/simulated case 1/P960-0001-001431 - 2026-06-02T221203.958.pdf new file mode 100644 index 0000000000000000000000000000000000000000..04de61516bb0f39a96188fcf564bd2cc97228312 GIT binary patch literal 44688 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 Jrj}BX{(rXSA&me4 literal 0 HcmV?d00001 diff --git a/sap worksheets/golden fixture debugging/simulated case 1/Summary_001431 (1).pdf b/sap worksheets/golden fixture debugging/simulated case 1/Summary_001431 (1).pdf new file mode 100644 index 0000000000000000000000000000000000000000..1a15e3dac8a69db41c17f44d0d34a34dbfa2cfeb GIT binary patch literal 78690 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 literal 0 HcmV?d00001 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431.py new file mode 100644 index 00000000..96609e90 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431.py @@ -0,0 +1,126 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 1" worksheet (gas-combi archetype). + +Like 000565, this fixture does NOT hand-build the EpcPropertyData. It +routes the Summary PDF through ElmhurstSiteNotesExtractor + +EpcPropertyDataMapper.from_elmhurst_site_notes so the SAP-result pin +grid exercises the WHOLE extractor + mapper + calculator pipeline. + +This is the cert that motivated S0380.190 — the newer Elmhurst export +lodges the gas combi as §14.0 "Fuel Type" EMPTY + "Main Heating SAP +Code" 104 (condensing combi, EES "BGW"), with the carrier ("Mains +gas") only in §15.0 "Water Heating Fuel Type". Before S0380.190 the +mapper left `main_fuel_type=''` → `cert_to_inputs` raised +`MissingMainFuelType`; `_elmhurst_gas_boiler_main_fuel` now derives +mains gas (code 26) from §15.0 per SAP 10.2 Table 4b (rows 101-119 are +gas-family boilers; the §15.0 fuel disambiguates the carrier because +the combi heats space + water from one appliance). + +It is also the cert that motivated S0380.189 (thermal mass parameter +per RdSAP 10 §5.16 Table 22): solid brick WITH internal insulation → +TMP 100, not the previously-hardcoded 250. + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 1/` (Summary_001431 (1).pdf input + +P960-0001-001431 - 2026-06-02T221203.958.pdf worksheet). The Summary +is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_gas_combi.pdf` +(distinct name — the corpus reuses cert 001431 across every heating +variant) so the test runs without depending on the unstaged workspace. + +Cert shape (Summary §1-19): gas-combi mid-terrace, TFA 128 m², solid +brick WITH internal insulation (→ Table 22 TMP 100), no PV, no +secondary heating, no cylinder (combi instantaneous HW, WHC HWP / SAP +code 901). Condensing combi SAP code 104, EES "BGW". + +Worksheet pin targets (P960-0001-001431 …958.pdf, Block 1 — energy +rating, lines 115-410; the second "FOR IMPROVED DWELLING" block is the +potential rating and is NOT pinned): +- SAP rating 78 (line 258) +- Energy cost factor 1.6047 (line 257; cascade carries it unrounded as + (255)*(256)/((4)+45) = 660.9750*0.4200/173.0 — the continuous SAP + 100 - 13.95*ECF is reconstructed from the unrounded ECF, NOT the + display-rounded 1.6047, so sap_score_continuous = 77.6147) +- Total fuel cost £660.9750 (line 255) +- CO2 3000.1664 kg/year (line 272) +- Space heating 8987.7669 kWh/year (Σ monthly (98)) +- Main 1 fuel 10699.7225 kWh/year (line 211) — mains gas +- Secondary fuel 0.0 (line 215) +- Hot water fuel 3327.1592 kWh/year (line 219) — combi +- Lighting 283.2229 kWh/year (line 232) +- Pumps/fans 86.0 kWh/year (line 231) + +Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation- +philosophy]]: pins are abs=1e-4 against the worksheet PDF. Failing +pins are named extractor / mapper / calculator gaps to fix. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_gas_combi.pdf" +) + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label\\nvalue sequences). + + Mirror of the helper in `backend/documents_parser/tests/ + test_summary_pdf_mapper_chain.py::_summary_pdf_to_textract_style_ + pages` (and `_elmhurst_worksheet_000565.py`). `pdftotext -layout` + preserves the spatial label/value pairing on each line; we split on + 2+ spaces to surface the tokens, then rejoin newline-delimited. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated 001431 Summary through extractor + mapper. + + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. Exercises the S0380.190 gas-combi fuel derivation + (§14.0 Fuel Type empty + SAP code 104 → mains gas via §15.0). + """ + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 69ad44ba..4f4653fd 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -37,6 +37,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_000490 as _w000490, _elmhurst_worksheet_000516 as _w000516, _elmhurst_worksheet_000565 as _w000565, + _elmhurst_worksheet_001431 as _w001431, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -147,6 +148,25 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=1384.8353, pumps_fans_kwh_per_yr=252.5159, ), + # Mapper-driven cohort entry — Summary_001431_gas_combi.pdf → + # extractor → mapper → calculator. Gas-combi mid-terrace, TFA 128, + # solid brick WITH internal insulation (Table 22 TMP 100), no PV / + # secondary / cylinder. The cert that motivated S0380.190 (gas-combi + # fuel from §15.0 when §14.0 Fuel Type is empty + SAP code 104) and + # S0380.189 (thermal mass parameter). Pins are worksheet Block 1 + # (energy rating) line refs. sap_score_continuous is reconstructed + # from the UNROUNDED ECF ((255)*(256)/((4)+45)), not the display- + # rounded (257)=1.6047 — see the fixture module docstring. + "001431": FixtureCascadePins( + sap_score=78, sap_score_continuous=77.6147, ecf=1.6047, + total_fuel_cost_gbp=660.9750, co2_kg_per_yr=3000.1664, + space_heating_kwh_per_yr=8987.7669, + main_heating_fuel_kwh_per_yr=10699.7225, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3327.1592, + lighting_kwh_per_yr=283.2229, + pumps_fans_kwh_per_yr=86.0, + ), } @@ -158,6 +178,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "000490": _w000490, "000516": _w000516, "000565": _w000565, + "001431": _w001431, } From ec9ef0e8bb2f1028cdcdd2d974b2afb31743003a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 22:54:49 +0000 Subject: [PATCH 16/16] fix(extractor): drop windows-table header remnant from first window glazing type Summary PDFs preprocessed from `pdftotext -layout` wrap the windows-table header across several lines. The third header line's tail ("U value / g value / Draught Proofed / Permanent Shutters") tokenises to "value value Proofed Shutters" and lands directly above the FIRST window's data row. Because the first window in a building part has `before_start = 0`, its prefix block reaches back into that header remnant. The remnant is neither an orientation nor a building-part fragment, so it survived the pops in `_compose_window_descriptors` and leaked into glazing_type as "value value Proofed Shutters Double between 2002 and 2021" (windows 2-3, whose prefix starts after the previous window's manufacturer line, were clean). Fix: the glazing-type phrase always starts with a glazing-start word (Single/Double/Triple/Secondary), so trim any prefix fragments preceding that word before joining the glazing type. Orientation/bp pops still run on the full prefix, so they are unaffected. Reproduced from `sap worksheets/Recommendations Elmhurst Files/ cavity_wall_insulation - main wall/before/Summary_001431.pdf`. Added a regression test driving the real `_extract_windows_from_layout` path with the verbatim tokenised header+rows. 2306 passed (+4), pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 23 +++- .../tests/test_elmhurst_extractor.py | 109 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 12c33830..b3fde06b 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1090,7 +1090,28 @@ class ElmhurstSiteNotesExtractor: if inline_glazing_type is not None: glazing_type = inline_glazing_type else: - glazing_type = " ".join([*prefix, *suffix]).strip() + # The glazing-type phrase always starts with a glazing-start + # word (Single/Double/Triple/Secondary). The FIRST window in + # a building part has `before_start = 0`, so its prefix block + # reaches back into the wrapped windows-table header; the + # third header line's tail tokenises to "value value Proofed + # Shutters" (the "U value / g value / Draught Proofed / + # Permanent Shutters" column titles) and is neither an + # orientation nor a bp fragment, so it survives the pops. + # Drop any prefix fragments preceding the glazing-start word + # so they don't leak into the glazing type. + glazing_start = next( + ( + idx + for idx, frag in enumerate(prefix) + if frag.split(" ", 1)[0] in self._GLAZING_TYPE_PREFIX_WORDS + ), + None, + ) + glazing_prefix = ( + prefix[glazing_start:] if glazing_start is not None else prefix + ) + glazing_type = " ".join([*glazing_prefix, *suffix]).strip() # Building part: inline token wins; otherwise join prefix + suffix. if bp_inline is not None: diff --git a/backend/documents_parser/tests/test_elmhurst_extractor.py b/backend/documents_parser/tests/test_elmhurst_extractor.py index e0dca443..62c0e743 100644 --- a/backend/documents_parser/tests/test_elmhurst_extractor.py +++ b/backend/documents_parser/tests/test_elmhurst_extractor.py @@ -513,3 +513,112 @@ class TestLightingLedCflUnknown: def test_cfl_count_zero_when_unknown(self, result2: ElmhurstSiteNotes) -> None: assert result2.lighting.cfl_count == 0 + + +class TestWindowsLayoutHeaderRemnant: + """Regression for the first-window glazing-type header leak. + + Summary PDFs preprocessed from `pdftotext -layout` wrap the windows + table header across several lines. The third header line's tail + ("U value / g value / Draught Proofed / Permanent Shutters") tokenises + to "value value Proofed Shutters" and sits directly above the FIRST + window's data row. Because the first window in a building part has + `before_start = 0`, its prefix block reaches back into that header + remnant, which is neither an orientation nor a building-part fragment + and so survived into `glazing_type` as + "value value Proofed Shutters Double between 2002 and 2021". + + Reproduced from `sap worksheets/Recommendations Elmhurst Files/ + cavity_wall_insulation - main wall/before/Summary_001431.pdf` (3 + Manufacturer-data-source windows; only window 0 was corrupted). + """ + + # Faithful reproduction of the tokenised windows section (one page), + # captured verbatim from the Summary PDF above. The header remnant + # "value value Proofed Shutters" precedes window 0's wrapped glazing + # cell ("Double between 2002" / "and 2021"). + _WINDOWS_PAGE = "\n".join([ + "11.0 Windows:", + "Frame Frame Glazing", + "Building", + "U", + "g Draught Permanent", + "W", + "H", + "Area Glazing Type", + "Location", + "Orient. Data-Source", + "Type Factor Gap", + "Part", + "value value Proofed Shutters", + "Double between 2002", + "North", + "0.97 1.00 0.97", + "PVC", + "0.70", + "Main", + "External wall", + "Manufacturer 2.00", + "0.72", + "Yes", + "None", + "and 2021", + "West", + "Double between 2002", + "South", + "2.66 1.00 2.66", + "PVC", + "0.70", + "Main", + "External wall", + "Manufacturer 2.00", + "0.72", + "Yes", + "None", + "and 2021", + "East", + "Double between 2002", + "South", + "2.66 1.00 2.66", + "PVC", + "0.70", + "Main", + "External wall", + "Manufacturer 2.00", + "0.72", + "Yes", + "None", + "and 2021", + "East", + "12.0 Ventilation", + ]) + + @pytest.fixture(scope="class") + def windows(self) -> list[Window]: + return ElmhurstSiteNotesExtractor([self._WINDOWS_PAGE])._extract_windows() + + def test_window_count(self, windows: list[Window]) -> None: + # Arrange / Act / Assert + assert len(windows) == 3 + + def test_first_window_glazing_type_excludes_header_remnant( + self, windows: list[Window] + ) -> None: + # Arrange / Act / Assert — no "value value Proofed Shutters" leak. + assert windows[0].glazing_type == "Double between 2002 and 2021" + + def test_all_windows_share_clean_glazing_type( + self, windows: list[Window] + ) -> None: + # Arrange / Act / Assert — windows 1 and 2 were already clean; + # all three must agree after the fix. + assert [w.glazing_type for w in windows] == [ + "Double between 2002 and 2021" + ] * 3 + + def test_first_window_orientation_unaffected( + self, windows: list[Window] + ) -> None: + # Arrange / Act / Assert — trimming the glazing prefix must not + # disturb orientation extraction (North + West fragments). + assert windows[0].orientation == "North-West"