From c17330b319f363a1184a4e3f8a9eadbb31d26e3b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 12:01:09 +0000 Subject: [PATCH] Slice S0380.173: Community heating HW path routes through main fuel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes CH1 (boilers) + CH3 (HP) HW CO2 / PE residuals by routing the HW cost / CO2 / PE factor lookups through the heat-network main when WHC ∈ {901, 902, 914} ("HW from main heating system"). Pre- slice the cascade honoured Elmhurst Summary §15.0's `water_heating_fuel_type = "Mains gas"` placeholder on community- heated certs, mis-routing HW through Table 12 code 1 (mains gas, 3.48 p/kWh / 0.21 CO2 / 1.13 PE) instead of the heat-network code (4.24 p/kWh + Table 12 code 41 / 51 / 53 / 54 with Table 4a heat- source-eff scaling per S0380.172). Per SAP 10.2 §C1 + RdSAP 10 §C (PDF p.49 + p.58) the HW heat delivered by a heat-network main is supplied through the same network as SH: spec block 10b (342a)/(342b) computes HW cost as `(310a) × CHP_price + (310b) × boiler_price`, mirroring SH's (340a)/(340b) split. Block 12b (365)/(366) and 13a (465)/(466) likewise apply the heat-source-eff division on HW. Three layers wired: 1. New `_is_community_heating_hw_from_main(epc)` predicate. Gates on WHC ∈ {901, 902, 914} + heat-network main + SAP code in `_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY` table (S0380.172 — only 301 boilers + 304 HP). SAP 302 (CHP+boilers) is excluded because the 35%/65% split needs the displaced-electricity credit cascade per spec block 13b (464)/(466) on BOTH SH and HW paths — both converge in a single follow-up slice. 2. `_hot_water_fuel_cost_gbp_per_kwh` gains a keyword-only `inherit_main_for_community_heating: bool = False` parameter. When True, returns `_fuel_cost_gbp_per_kwh(main, prices)` — same helper that already applies the S0380.171 CHP blend + heat-network rate. The orchestrator passes `inherit_main_for_community_heating=_is_community_heating_hw_ from_main(epc)` at the cost-rate construction site. 3. `_hot_water_co2_factor_kg_per_kwh` and `_hot_water_primary_ factor` get top-level branches: when the predicate fires, return `Table_12_factor × _heat_network_heat_source_efficiency_scaling (main)` — same scaled-factor return as the SH path in S0380.172. Closures (heating-systems corpus block 11b): CH1 (Boilers/Gas) ΔPE −967 → −9 (essentially closed) CH1 ΔCO2 −126 → +52 (shifted across worksheet) CH3 (HP/Elec) ΔPE +1749 → −387 (~78% closure) CH3 ΔCO2 +473 → −86 (~82% closure) Cost / SAP signs flip on CH1 / CH3 (was −£14 / +0.59 SAP, now +£12 / −0.53 SAP) — HW cost now matches the worksheet's (342) line exactly, exposing a +£12 lighting / standing overage that was previously masked by the HW under-charge. Per [[feedback-software- no-special-handling]] the pre-slice near-zero on CH1 / CH3 cost was an offsetting-bugs artifact; the spec-correct fix surfaces the real lighting / standing gap as the next forcing function. CH2 / CH4 / CH6 (SAP 302) unchanged from S0380.171 / S0380.172 pins — gated out per the heat-source-eff-table membership check. Test baseline at HEAD: 926 pass + 1 skipped (was 926 + 1 at predecessor 36d4bf87). Pyright net-zero on affected files (cert_to_inputs.py, test_heating_systems_corpus.py): 32 → 32. Per [[feedback-spec-citation-in-commits]] the rule cites SAP 10.2 §C1 verbatim ("heat from CHP + back-up boilers, via a heat main") and RdSAP 10 §C defaults (PDF p.58). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 33 ++++++++- .../sap10_calculator/rdsap/cert_to_inputs.py | 67 ++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) 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),