From 82f7315f8db4048540b99edf44746554de58bbfd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:43:16 +0000 Subject: [PATCH] =?UTF-8?q?S0380.184:=20community=20electric-HP=20network?= =?UTF-8?q?=20CO2/PE=20uses=20monthly=20Table=2012d/12e=20=E2=80=94=20clos?= =?UTF-8?q?es=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