diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 34f2b549..ae9e7115 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -562,9 +562,38 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # the WHC=901 HW path (cascade reads cert-lodged "Mains gas" as # HW fuel; should fall through to main fuel for community heating) # + the Elmhurst 0.8523 multiplier on heat-network energy column. - _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=-126.4571, expected_pe_resid_kwh=-967.3648), + # + # Slice S0380.173 routed CH1 + CH3 HW cost / CO2 / PE through the + # main heat-network fuel + Table 4a heat-source-eff scaling via a + # new `_is_community_heating_hw_from_main(epc)` predicate (WHC ∈ + # {901, 902, 914} + heat-network main + SAP code in + # `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` table from S0380.172). + # Pre-slice the cascade honoured Elmhurst's §15.0 placeholder + # `water_heating_fuel_type = "Mains gas"` for community-heated + # certs, mis-routing HW through the Mains-gas Table 12 code + # (3.48 p/kWh / 0.21 CO2 / 1.13 PE) instead of the heat-network + # code (4.24 p/kWh + scaled factors). Closures: + # + # CH1 (Boilers/Gas) ΔPE −967 → −9 (essentially closed) + # CH1 ΔCO2 −126 → +52 (shift) + # CH3 (HP/Elec) ΔPE +1749 → −387 (~78% closed) + # CH3 ΔCO2 +473 → −86 (~82% closed) + # + # Cost / SAP signs flip on CH1 / CH3 (was −£14 / +0.59 SAP, now + # +£12 / −0.53 SAP) — HW cost now matches the worksheet exactly, + # exposing a +£12 lighting / standing overage that was previously + # masked by the HW under-charge. The exposed lighting / standing + # gap is the next closure front (likely the £120 heat-network + # standing charge being applied to lighting kWh instead). + # + # SAP 302 (CHP+boilers) gated out per `_is_community_heating_hw_ + # from_main`'s `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` check — the + # 35%/65% split + displaced-electricity credit must converge on + # both SH and HW in a single follow-up slice. CH2 / CH4 / CH6 + # residuals unchanged from S0380.172 / S0380.171 pins. + _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=-0.5273, expected_cost_resid_gbp=+12.1495, expected_co2_resid_kg=+51.6176, expected_pe_resid_kwh=-9.1529), _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.0076, expected_cost_resid_gbp=+0.1744, expected_co2_resid_kg=-1430.3212, expected_pe_resid_kwh=+1506.0355), - _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=+472.5996, expected_pe_resid_kwh=+1748.7395), + _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=-0.5273, expected_cost_resid_gbp=+12.1495, expected_co2_resid_kg=-85.9334, expected_pe_resid_kwh=-387.0272), _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.0076, expected_cost_resid_gbp=+0.1744, expected_co2_resid_kg=-4397.0794, expected_pe_resid_kwh=+494.6090), _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.0295, expected_cost_resid_gbp=+185.0120, expected_co2_resid_kg=-2934.9021, expected_pe_resid_kwh=+7864.5950), ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9c959f7b..f9a75afb 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1298,6 +1298,36 @@ def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]: return _main_fuel_code(_water_heating_main(epc)) +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). + + 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). + + 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. + """ + if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: + return False + main = _water_heating_main(epc) + 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 + + def _main_heating_efficiency(epc: EpcPropertyData) -> float: """SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction. @@ -1793,6 +1823,8 @@ def _hot_water_fuel_cost_gbp_per_kwh( main: Optional[MainHeatingDetail], tariff: Tariff, prices: PriceTable, + *, + inherit_main_for_community_heating: bool = False, ) -> float: """Hot water bills at the *water-heating* fuel's rate. When the water-heating fuel is electric AND tariff is off-peak, bill at the @@ -1801,7 +1833,17 @@ def _hot_water_fuel_cost_gbp_per_kwh( not consulted — those fuels are single-rate per Table 32. For cert 000565 HW routes to gas combi via WHC 914 → tariff branch not taken. TODO: Table 12a Grid 1 WH high-rate-fraction split for - electric WH on off-peak (currently uses 100% low rate).""" + electric WH on off-peak (currently uses 100% low rate). + + `inherit_main_for_community_heating`: per S0380.173, when WHC + ∈ {901, 902, 914} AND main is a heat network, ignore the cert- + lodged HW fuel (which Elmhurst defaults to "Mains gas") and route + HW cost through `_fuel_cost_gbp_per_kwh(main, prices)` — same + helper that applies the .171 CHP heat-fraction blend for SAP 302 + + heat-network rate for code 41 / 51 / 53 / 54. + """ + if inherit_main_for_community_heating: + return _fuel_cost_gbp_per_kwh(main, prices) water_electric = _is_electric_water(water_heating_fuel) if water_electric and tariff is not Tariff.STANDARD: return _off_peak_low_rate_gbp_per_kwh(tariff) @@ -2782,6 +2824,17 @@ 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).""" + # 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_ + # eff per spec block 12b (363)/(367). Cert-lodged HW fuel "Mains + # 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) + ) fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_CO2_KG_PER_KWH @@ -2819,6 +2872,16 @@ 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.""" + # 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, + # 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) + ) fuel = _water_heating_fuel_code(epc) if fuel is None: return _DEFAULT_PEF @@ -5818,11 +5881,13 @@ def cert_to_inputs( hw_co2_factor = _hw_co2_factor hw_pe_factor = _hw_pe_factor else: + _community_hw_inherit = _is_community_heating_hw_from_main(epc) hw_cost_rate = _hot_water_fuel_cost_gbp_per_kwh( _water_heating_fuel_code(epc), _water_heating_main(epc), _rdsap_tariff(epc), prices, + inherit_main_for_community_heating=_community_hw_inherit, ) hw_co2_factor = _hot_water_co2_factor_kg_per_kwh( epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),