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