From 41e50be216f55572f071b533399b3fac18b081cd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 08:34:20 +0000 Subject: [PATCH] Slice S0380.163: Elmhurst-mirror HW PE/CO2 factor on dual-rate tariffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 12 footnote (t) (PDF p.189): "PE factors for grid electricity vary by month. The average figure given in this table is therefore not used directly. Instead the monthly factors given in Table 12e should be used in the SAP worksheet." Footnote (s) says the same for CO2 / Table 12d. Read literally, monthly factors apply to every electric end-use including dual-rate HW. The BRE-approved Elmhurst rdSAP engine doesn't follow that reading for HW. The 41-variant heating-systems corpus controlled-variable fixture lodges worksheet (278) "Water heating (low-rate cost)" with factor **1.5010 PE / 0.136 CO2** (Table 12 annual flat) across every dual-rate tariff cert, while applying the monthly Table 12e/12d cascade to lighting (1.5338 winter-weighted) and secondary heating (1.5715) on the same certs. It's an engine implementation choice, not a documented spec exception. Per [[feedback-software-no-special-handling]] the calculator contract is bit-faithful replication of the engine, not literal compliance with the spec text. This slice flips cascade `_hot_water_primary_factor` + `_hot_water_co2_factor_kg_per_kwh` to accept a `tariff: Tariff` parameter: - STANDARD tariff → Table 12e/12d monthly cascade weighted by HW demand seasonality (unchanged from S0380.71 / .72, matches cohort-1 ASHP standard-tariff worksheet) - 7-hour / 10-hour / 18-hour / 24-hour → Table 12 annual flat (1.501 / 0.136) matching the Elmhurst worksheet (278) "Water heating (low-rate cost)" row Per-line walk on electric 3 (18-hour tariff, electric immersion HW, 2384.116 kWh annual): worksheet (278) factor = 1.5010 cascade pre-slice = 1.5214 delta = +0.0204 (1.5214 - 1.5010) × 2384.116 = +48.66 kWh/yr PE — EXACT match the corpus residual pin. Same shape for CO2: worksheet 0.1360, cascade pre-slice 0.1410, delta +0.0050 × 2384.116 = +11.95 kg/yr. Closures across the 18-variant deferred lighting-PE cohort (electric 1/2/3/5/6/7/8/9 + solid fuel 4/5/6/7/8/9/10/11 + ashp + gshp): ΔCO2 +6.31 / +11.95 → ±0.0000 EXACT ΔPE +25.51 / +48.66 → ±0.0000 EXACT ΔSAP_c / Δcost unchanged at ±0.0000 EXACT (already closed pre-slice by S0380.156..162). All 25 cascade-OK variants in the heating-systems corpus now SAP / cost / CO2 / PE EXACT vs worksheet on all 4 metrics, with solid fuel 2 as the only remaining open residual (separate S0380.154 summer-immersion-blend CO2/PE artifact — deferred). Documented in `domain/sap10_calculator/docs/SAP_CALCULATOR.md §8.1 "HW PE/CO2 factors on dual-rate tariffs use Table 12 annual"` — the master doc now carries a new §8 "Elmhurst-mirrored spec divergences" section for cases like this. Validation tally refreshed from stale "930/930" to current "941/941". No regressions on the 6 Elmhurst U985 fixtures (gas combi STANDARD tariff — unaffected) or the cohort-1 ASHP certs (STANDARD tariff — unaffected). The dual-rate gate fires only on the 4 off-peak tariffs. Verbatim spec quote retained for reference (SAP 10.2 Table 12 footnote (t), PDF p.189): "PE factors for grid electricity vary by month. The average figure given in this table is therefore not used directly. Instead the monthly factors given in Table 12e should be used in the SAP worksheet." Tests: 907 pass (+1), 0 fail. Pyright net-zero (43 → 43). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 57 ++++++++++----- .../sap10_calculator/docs/SAP_CALCULATOR.md | 73 ++++++++++++++++++- .../sap10_calculator/rdsap/cert_to_inputs.py | 50 +++++++++---- .../rdsap/tests/test_cert_to_inputs.py | 72 ++++++++++++++++++ 4 files changed, 217 insertions(+), 35 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 36d8ff3c..777f409f 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -350,17 +350,38 @@ class _CorpusExpectation: # narrowed to the Elmhurst-vs-spec HW PE annual-vs-monthly quirk # only). Cohort Σ|ΔSAP_c| 0.07 → 0.03 in one slice. All 25 cascade-OK # variants now SAP+cost EXACT. +# +# Slice S0380.163 closed the 18-variant lighting-PE deferred cohort +# (electric 1/2/3/5/6/7/8/9 + solid fuel 4/5/6/7/8/9/10/11 + ashp + +# gshp). Cascade `_hot_water_primary_factor` + `_hot_water_co2_factor_ +# kg_per_kwh` now take a `tariff` parameter and apply Table 12 annual +# factors (1.501 PE / 0.136 CO2) on dual-rate tariffs (7-hour / 10- +# hour / 18-hour / 24-hour). STANDARD tariff still uses Table 12d/12e +# monthly. Worksheet evidence: the 41-variant corpus consistently +# shows worksheet (278) "Water heating (low-rate cost)" using factor +# 1.5010 for electricity HW on 18-hour. SAP 10.2 Table 12 footnote +# (t) read literally would mandate monthly factors for all electric +# end-uses, but the BRE-approved Elmhurst engine applies the annual +# Table 12 figure for the dual-rate low-rate-cost lines. We mirror +# the engine per [[feedback-software-no-special-handling]]; the +# divergence is documented at +# `domain/sap10_calculator/docs/SAP_CALCULATOR.md §8`. CO2 +11.95 / +# PE +48.66 (immersion HW: 2384 kWh × 0.020 PE delta) and CO2 +6.31 +# / PE +25.51 (HP HW: 1138 kWh × 0.022 PE delta) → all close to 0 in +# one slice. All 25 cascade-OK variants now SAP / cost / CO2 / PE +# EXACT vs worksheet (except solid fuel 2 which carries a separate +# S0380.154 summer-immersion-blend CO2/PE artifact). _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( - _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+6.3106, expected_pe_resid_kwh=+25.5090), - _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6605), - _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6605), - _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6605), - _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+6.3106, expected_pe_resid_kwh=+25.5090), + _CorpusExpectation(variant='ashp', block='11a', 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='electric 1', block='11a', 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='electric 2', block='11a', 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='electric 3', block='11a', 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='electric 5', block='11a', 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='electric 6', block='11a', 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='electric 7', block='11a', 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='electric 8', block='11a', 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='electric 9', block='11a', 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='gshp', block='11a', 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='oil 1', block='11a', 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='oil pcdb 1', block='11a', 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='oil pcdb 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), @@ -375,14 +396,14 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # control-type gaps — separate slices. _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-93.0988, expected_pe_resid_kwh=-1027.5099), _CorpusExpectation(variant='solid fuel 3', block='11a', 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='solid fuel 4', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9452, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), - _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), + _CorpusExpectation(variant='solid fuel 4', block='11a', 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='solid fuel 5', block='11a', 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='solid fuel 6', block='11a', 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='solid fuel 7', block='11a', 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='solid fuel 8', block='11a', 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='solid fuel 9', block='11a', 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='solid fuel 10', block='11a', 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='solid fuel 11', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=-0.0000), ) diff --git a/domain/sap10_calculator/docs/SAP_CALCULATOR.md b/domain/sap10_calculator/docs/SAP_CALCULATOR.md index 5f51dd84..61c6e6da 100644 --- a/domain/sap10_calculator/docs/SAP_CALCULATOR.md +++ b/domain/sap10_calculator/docs/SAP_CALCULATOR.md @@ -7,7 +7,9 @@ for the published SAP rating + EI rating) and the Demand cascade (postcode climate via PCDB Table 172, used for the EPC's published Current Carbon, Current Primary Energy, and Fuel Bill). -**Current state: 930/930 pins green** (768 rating + 90 demand + 72 e2e). +**Current state: 941/941 pins green** (rating + demand section cascade +pins via `test_section_cascade_pins.py`, plus e2e SapResult + monthly +infiltration ACH pins via `test_e2e_elmhurst_sap_score.py`). This document is the public API + architecture reference. For fixture authoring see [`domain/sap10_calculator/README.md`](../../domain/sap10_calculator/README.md). @@ -373,3 +375,72 @@ PCDB10: Table 105 (gas/oil boilers) domain/sap10_calculator/docs/specs/pcdb_table_105_... Table 172 (postcode-district weather) domain/sap10_calculator/tables/pcdb/data/pcdb10.dat ``` + +--- + +## 8. Elmhurst-mirrored spec divergences + +The calculator's contract is **bit-faithful replication of the BRE-approved +Elmhurst rdSAP engine**, not literal compliance with the SAP 10.2 spec +text. The two coincide >99% of the time, but in a few places the +worksheet PDFs from Elmhurst lodge a value that the spec text — read in +isolation — would call wrong. We mirror the engine in those cases and +document the divergence here. + +Trigger to ADD a row: cascade matches spec literal interpretation, but +worksheet PDF disagrees, AND the worksheet PDF value is reproducible +across multiple Elmhurst-lodged certs (i.e. it's the engine's behaviour, +not a one-off lodging defect). Per +[[feedback-software-no-special-handling]] / [[feedback-spec-floor-skepticism]] +verify both the worksheet PDF and the cascade output before adding. + +### 8.1 HW PE/CO2 factors on dual-rate tariffs use Table 12 annual, not Table 12e/12d monthly + +**Slice:** S0380.163. +**Code:** +[`_hot_water_primary_factor`](../rdsap/cert_to_inputs.py), +[`_hot_water_co2_factor_kg_per_kwh`](../rdsap/cert_to_inputs.py). +**Test:** `test_electric_water_heating_factors_use_annual_table_12_on_dual_rate_tariff`. + +SAP 10.2 Table 12 footnote (t) (PDF p.189) reads: + +> *PE factors for grid electricity vary by month. The average figure +> given in this table is therefore not used directly. Instead the +> monthly factors given in Table 12e should be used in the SAP +> worksheet.* + +(Footnote (s) says the same for CO2 / Table 12d.) Read literally this +applies to every electric end-use including dual-rate HW. The cascade +originally followed the literal reading: Σ(HW_m × F_m_12e) / ΣHW_m = +~1.521 PE for 18-hour HW on a winter-skewed demand profile. + +The Elmhurst worksheet ((278) "Water heating (low-rate cost)") uses +1.5010 PE / 0.136 CO2 — the Table 12 ANNUAL row — on every dual-rate +tariff cert in the 41-variant controlled-variable corpus. The engine +applies monthly Table 12e for lighting (1.5338 winter-weighted) and +secondary heating (1.5715) on the same certs, but flat Table 12 for the +"low-rate cost" line items (SH main 1 + HW). It's an Elmhurst +implementation choice, not a documented spec exception. + +**Cascade rule (post-S0380.163):** + +| Tariff | HW PE / CO2 factor source | +|---|---| +| STANDARD | Table 12e / 12d monthly, weighted by HW demand seasonality (per spec literal) | +| 7-hour / 10-hour / 18-hour / 24-hour | Table 12 annual flat (1.501 PE / 0.136 CO2) | + +The SH main factor (`_main_heating_primary_factor`) already +matches Elmhurst by accident: for dual-rate tariffs the +`_table_12a_system_for_main` lookup returns None for storage heaters / +electric direct-acting / electric boilers without PCDB → falls through +to `primary_energy_factor(fuel)` annual. STANDARD tariff goes through +the monthly cascade. + +### Cohort impact + +The 41-variant heating-systems corpus closed its HW PE/CO2 residual on +18 variants (all dual-rate electric HW: electric 1/2/3/5/6/7/8/9, solid +fuel 4/5/6/7/8/9/10/11, ashp, gshp). Each variant moved from PE +25.51 +or +48.66 → ±0.0000, CO2 +6.31 or +11.95 → ±0.0000. Cohort-1 ASHP certs +(STANDARD tariff) and the 6 Elmhurst U985 fixtures (gas combi, STANDARD +tariff) are unaffected — they continue to use the monthly cascade. diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0a83526e..5816b3de 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2669,20 +2669,28 @@ def _other_use_primary_factor( def _hot_water_co2_factor_kg_per_kwh( epc: EpcPropertyData, hw_monthly_kwh: tuple[float, ...], + tariff: Tariff, ) -> float: """SAP 10.2 Table 12 / 12d (p.195) per-end-use CO2 factor for the - cert's lodged water-heating fuel. HW-side analog of - `_main_heating_co2_factor_kg_per_kwh` (Slice S0380.71) + - `_secondary_heating_co2_factor_kg_per_kwh` (Slice S0380.70). + cert's lodged water-heating fuel. Per Table 12d header (p.195): "Where electricity is the fuel used, the relevant set of factors in the table below should be used to calculate the monthly CO2 emissions instead the annual - average factor given in Table 12." → electric HW fuels apply the - monthly Table 12d cascade weighted by the cert's HW demand profile - (mirroring the worksheet's monthly weighting); non-electric HW - fuels (mains gas, oil, etc.) pass through the annual Table 12 - factor. + average factor given in Table 12." Read literally this would apply + monthly Table 12d to every electric end-use including dual-rate HW. + + **Elmhurst-mirror divergence (S0380.163).** The BRE-approved + Elmhurst rdSAP engine applies Table 12 ANNUAL factors (0.136 CO2 / + 1.501 PE) for the (278) "Water heating (low-rate cost)" worksheet + line on dual-rate tariffs (7-hour / 10-hour / 18-hour / 24-hour), + NOT the Table 12d/12e monthly cascade. STANDARD tariff (where HW + bills via Table 12d row "standard tariff" code 30 monthly) still + uses the monthly cascade. We mirror the engine per + [[feedback-software-no-special-handling]] — see + `domain/sap10_calculator/docs/SAP_CALCULATOR.md §8` for the full + documentation of this divergence. Non-electric HW fuels (mains + gas, oil, etc.) always pass through the annual Table 12 factor. `hw_monthly_kwh` is the monthly HW demand profile (proxy for monthly HW fuel kWh — the calculator uses an annual-flat HW @@ -2695,6 +2703,8 @@ def _hot_water_co2_factor_kg_per_kwh( fuel if fuel in CO2_KG_PER_KWH else API_FUEL_TO_TABLE_12.get(fuel, fuel) ) + if tariff is not Tariff.STANDARD: + return co2_factor_kg_per_kwh(table_12_code) monthly = _effective_monthly_co2_factor(hw_monthly_kwh, table_12_code) if monthly is not None: return monthly @@ -2704,19 +2714,25 @@ def _hot_water_co2_factor_kg_per_kwh( def _hot_water_primary_factor( epc: EpcPropertyData, hw_monthly_kwh: tuple[float, ...], + tariff: Tariff, ) -> float: """SAP 10.2 Table 12 / 12e (p.196) per-end-use PE factor for the cert's lodged water-heating fuel. PE-side mirror of - `_hot_water_co2_factor_kg_per_kwh`. Per Table 12e header (p.196): - electric HW fuels apply the monthly Table 12e cascade; non- - electric HW fuels pass through the annual Table 12 factor. + `_hot_water_co2_factor_kg_per_kwh` — same Elmhurst-mirror + divergence: dual-rate tariffs use Table 12 annual (1.501), + STANDARD tariff uses Table 12e monthly cascade. - Cohort closure context: cert 9796 (ASHP, water_heating_fuel=29 API - standard electricity → Table 12 code 30) lands at 1.5177 monthly- + Cohort closure context: cert 9796 (ASHP, STANDARD tariff via + water_heating_fuel=29 → Table 12 code 30) lands at 1.5177 monthly- weighted PE vs 1.501 annual flat (≈ +0.30 kWh/m² for the cert). Same routing across the 20-cert STANDARD-tariff ASHP cohort averages ~+0.3 kWh/m² closure on top of the S0380.71 main heating - fix.""" + fix. + + On dual-rate tariffs (S0380.163) the cascade now returns 1.501 + 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.""" fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_PEF @@ -2724,6 +2740,8 @@ def _hot_water_primary_factor( fuel if fuel in PRIMARY_ENERGY_FACTOR else API_FUEL_TO_TABLE_12.get(fuel, fuel) ) + if tariff is not Tariff.STANDARD: + return primary_energy_factor(table_12_code) monthly = _effective_monthly_pe_factor(hw_monthly_kwh, table_12_code) if monthly is not None: return monthly @@ -5673,10 +5691,10 @@ def cert_to_inputs( prices, ) hw_co2_factor = _hot_water_co2_factor_kg_per_kwh( - epc, hw_monthly_kwh_for_factors, + epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc), ) hw_pe_factor = _hot_water_primary_factor( - epc, hw_monthly_kwh_for_factors, + epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc), ) _hw_extra_standing = 0.0 standing_charges_total = additional_standing_charges_gbp( diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 7c353d98..7d29d20f 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -2423,6 +2423,78 @@ def test_electric_water_heating_co2_and_pe_factors_apply_monthly_table_12d_12e() ) +def test_electric_water_heating_factors_use_annual_table_12_on_dual_rate_tariff() -> None: + # Arrange — Elmhurst-mirror divergence from SAP 10.2 Table 12 footnote + # (t) literal reading. For electric HW on a dual-rate tariff (7-hour, + # 10-hour, 18-hour, 24-hour) Elmhurst applies the Table 12 ANNUAL + # PE/CO2 factor (1.501 PE / 0.136 CO2) to the low-rate-cost billing + # line, not the monthly Table 12e/12d cascade. STANDARD tariff still + # uses monthly Table 12d/12e (see preceding test). + # + # Spec text vs Elmhurst behaviour: SAP 10.2 Table 12 footnote (t) + # reads "PE factors for grid electricity vary by month ... the + # monthly factors given in Table 12e should be used in the SAP + # worksheet." If that footnote were taken literally for all electric + # end-uses, dual-rate HW would use Table 12e monthly weighted + # (~1.521 for 18-hour winter-skewed HW seasonality). The 41-variant + # heating-systems corpus at `sap worksheets/heating systems examples/` + # shows the canonical BRE-approved Elmhurst engine producing 1.5010 + # exactly for the (278) "Water heating (low-rate cost)" row across + # every dual-rate cert. We mirror Elmhurst per + # [[feedback-software-no-special-handling]]; the divergence is + # documented in `domain/sap10_calculator/docs/SAP_CALCULATOR.md`. + # + # Build a minimal cert with electric immersion HW + 18-hour meter + # (Elmhurst "18 Hour" → Tariff.EIGHTEEN_HOUR). Storage-heater main + # so the cert's tariff resolves through `_rdsap_tariff` to dual-rate. + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=3, + region_code="1", + dwelling_type="Semi-detached house", + sap_building_parts=[ + make_building_part( + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=_TYPICAL_TFA_M2, floor=0, + ), + ], + ), + ], + sap_heating=make_sap_heating( + water_heating_fuel=29, # API standard electricity → Table 12 code 30 + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, + heat_emitter_type="", + emitter_temperature="", + main_heating_control=2401, + sap_main_heating_code=401, # Cat 7 storage heater + central_heating_pump_age_str="Unknown", + ), + ], + ), + ) + epc.sap_energy_source.meter_type = "18 Hour" + + # Act + inputs = cert_to_inputs(epc) + + # Assert — Table 12 annual factors for electric HW on 18-hour tariff. + # 1.501 PE / 0.136 CO2 (Table 12 row "18-hour tariff (low rate)" / + # "standard tariff" — all electricity rows in Table 12 share these + # annual figures). + co2 = inputs.hot_water_co2_factor_kg_per_kwh + pe = inputs.hot_water_primary_factor + assert co2 is not None and abs(co2 - 0.136) <= 1e-9, ( + f"expected annual Table 12 CO2 = 0.136 for dual-rate HW; got {co2}" + ) + assert pe is not None and abs(pe - 1.501) <= 1e-9, ( + f"expected annual Table 12 PE = 1.501 for dual-rate HW; got {pe}" + ) + + def test_gas_water_heating_co2_and_pe_factors_pass_through_annual_table_12() -> None: # Arrange — RdSAP cert with mains-gas water heating # (`water_heating_fuel=26` API mains gas → Table 12 code 1). Per