From 8452cf9e2df9bedb9fee59146d968292a975349e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:04:16 +0000 Subject: [PATCH] =?UTF-8?q?S0380.180:=20heat-network=20distribution=20pump?= =?UTF-8?q?ing=20electricity=20(=C2=A7C3.2)=20=E2=80=94=20closes=20CH1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix C §C3.2 (PDF p.51), verbatim: "CO2 emissions and Primary Energy associated with the electricity used for pumping water through the distribution system are allowed for by adding electrical energy equal to 1% of the energy required for space and water heating." Worksheet line (313) = 0.01 × [(307)+(310)]; its CO2 (372) and PE (472) bill on the Table 12d/12e monthly factors for fuel code 50 ("electricity for pumping in distribution network"), weighted by the monthly heat profile per worksheet footnote (a). (307)m/(310)m = (space_demand + hw_output) / efficiency (the cascade models a heat network's generator efficiency as 1/DLF). This un-defers the (372)/(472) front the post-S0380.179 handover flagged "don't guess until the factor source is identified": the source is §C3.2 + Table 12d/12e code 50, NOT an empirical constant. The apparent 0.1994/0.2114 "factor" is an Elmhurst DISPLAY artifact — the worksheet shows the (372) energy column as 0.01×(307) (space only) while computing emissions on 0.01×(307+310) per the §C3.2 text. Verified EXACT line-by- line against the CH2 corpus worksheet: (372)=23.6007 CO2 (rating), (472)=208.2267 PE (demand). New `_heat_network_distribution_electricity` helper (gated on `_is_heat_network_main`) precomputes the energy + effective CO2/PE factors; three new CalculatorInputs fields + calculator.py CO2/PE summation terms (0.0/None → no-op for individually-heated certs). Closures: CH1 (Boilers/Gas) CO2 −23.60→−0.00, PE −208.23→+0.00 — FULLY EXACT CH3 (HP/Elec) CO2 −98.92→−75.32, PE −457.54→−249.32 (distribution component closed; code-304 community-HP COP remains) CH2/CH4/CH6 gain their (372)/(472) component (CO2 +23.6, PE +208.2); dominant CHP displaced-electricity credit residual (Table 12f + block 12b/13b) is next slice. No regression on the other 36 corpus variants (helper returns None off heat-network mains) + golden + U985 fixtures. 2223 pass + 1 skip + 0 fail; pyright net-zero 43→43. Co-Authored-By: Claude Opus 4.8 --- .../tests/test_heating_systems_corpus.py | 26 ++++- domain/sap10_calculator/calculator.py | 28 ++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 96 +++++++++++++++++++ .../rdsap/test_cert_to_inputs.py | 78 +++++++++++++++ 4 files changed, 223 insertions(+), 5 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 69c0b1ae..14ccc2d9 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -674,11 +674,27 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # distribution" line — 118.38 kWh billed at electricity factors # (CO2 0.1993, PE 1.760), not heat-network factors — the cascade # doesn't currently meter this. Next follow-up slice. - _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-23.6007, expected_pe_resid_kwh=-208.2267), - _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1435.0874, expected_pe_resid_kwh=+1123.0063), - _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-98.9235, expected_pe_resid_kwh=-457.5428), - _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4401.8456, expected_pe_resid_kwh=+111.5798), - _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2939.6683, expected_pe_resid_kwh=+7481.5658), + # Slice S0380.180 wired the SAP 10.2 Appendix C §C3.2 (PDF p.51) + # heat-network distribution pumping electricity (worksheet (313) = + # 0.01 × [(307)+(310)]; CO2 (372) / PE (472) on Table 12d/12e fuel- + # code-50 monthly factors weighted by the monthly heat profile). + # CH1 (Boilers/Gas) closes FULLY — the (372)/(472) line was its + # entire remaining residual (un-defers the front the predecessor + # handover flagged "don't guess"; the factor source is §C3.2 + + # Table 12f, not an empirical constant). CH3 (HP/Elec) closes its + # distribution component (CO2 −98.92→−75.32, PE −457.54→−249.32); + # the remainder is the code-304 community-HP COP cascade (separate + # follow-up). CH2/CH4/CH6 gain their (372)/(472) component (CO2 + # +23.6, PE +208.2/+208.2/+208.2); their dominant CHP displaced- + # electricity credit residual (Table 12f + block 12b/13b) remains + # for the next slice. Elmhurst DISPLAYS the (372) energy column as + # 0.01 × (307) (space only) but computes emissions on 0.01 × + # (307+310) per the §C3.2 text — verified EXACT line-by-line. + _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.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-1411.4867, expected_pe_resid_kwh=+1331.2330), + _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 4', block='11b', expected_sap_resid=+0.5277, expected_cost_resid_gbp=-12.1598, expected_co2_resid_kg=-4378.2449, expected_pe_resid_kwh=+319.8065), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-7.4942, expected_cost_resid_gbp=+172.6778, expected_co2_resid_kg=-2916.0676, expected_pe_resid_kwh=+7689.7925), ) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 364ad23d..6b33c3f7 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -236,6 +236,17 @@ class CalculatorInputs: pumps_fans_primary_factor: Optional[float] = None lighting_primary_factor: Optional[float] = None electric_shower_primary_factor: Optional[float] = None + # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution + # pumping electricity. For community-heating mains the network pump + # energy = 1% of (space + water) heat generated (worksheet (313)); + # its CO2 / PE (worksheet (372)/(472)) bill on Table 12d/12e monthly + # electricity factors (fuel code 50) weighted by the monthly heat + # profile. The energy + effective factors are precomputed in + # cert_to_inputs. 0.0 / None for individually-heated certs (no + # distribution loop) leaves the cascade unchanged. + heat_network_distribution_kwh_per_yr: float = 0.0 + heat_network_distribution_co2_factor_kg_per_kwh: Optional[float] = None + heat_network_distribution_primary_factor: Optional[float] = None # Generation offsets — applied as a cost credit against the ECF # numerator. SAP 10.2 Appendix M: PV self-consumption + export # collapse to a single credit at the export rate (Table 12 code 60). @@ -596,6 +607,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: electric_shower_co2 = ( inputs.electric_shower_kwh_per_yr * electric_shower_co2_factor ) + # SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (372) — electricity + # for pumping water through a heat network's distribution system. + # Zero for individually-heated certs (factor None → 0.0). + heat_network_distribution_co2 = ( + inputs.heat_network_distribution_kwh_per_yr + * (inputs.heat_network_distribution_co2_factor_kg_per_kwh or 0.0) + ) co2 = ( main_heating_co2 + secondary_heating_co2 @@ -603,6 +621,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + pumps_fans_co2 + lighting_co2 + electric_shower_co2 + + heat_network_distribution_co2 ) # SAP 10.2 Appendix M1 §7 — subtract PV CO2 credit. Onsite consumption # offsets grid imports at the IMPORT CO2 factor (Table 12d weighted @@ -662,6 +681,12 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + inputs.lighting_kwh_per_yr * lighting_primary_factor + inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor ) + # SAP 10.2 Appendix C §C3.2 (PDF p.51) worksheet (472) — heat-network + # distribution pumping electricity primary energy (CO2 sister above). + heat_network_distribution_primary_kwh = ( + inputs.heat_network_distribution_kwh_per_yr + * (inputs.heat_network_distribution_primary_factor or 0.0) + ) # SAP 10.2 Appendix M1 §8: PV onsite consumption credits at IMPORT # PEF (offsets grid imports); PV exports credit at the EXPORT PEF # ("electricity sold to grid, PV" — Table 12 code 60 = 0.501). When @@ -696,6 +721,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: space_heating_primary_kwh + hot_water_primary_kwh + other_primary_kwh + + heat_network_distribution_primary_kwh - pv_primary_offset_kwh, ) primary_energy_per_m2 = primary_energy_kwh / tfa if tfa > 0 else 0.0 @@ -738,6 +764,8 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: "hot_water_co2_kg_per_yr": hot_water_co2, "pumps_fans_co2_kg_per_yr": pumps_fans_co2, "lighting_co2_kg_per_yr": lighting_co2, + "heat_network_distribution_co2_kg_per_yr": heat_network_distribution_co2, + "heat_network_distribution_pe_kwh_per_yr": heat_network_distribution_primary_kwh, "space_heating_pe_kwh_per_m2": space_heating_primary_kwh / tfa if tfa > 0 else 0.0, "hot_water_pe_kwh_per_m2": hot_water_primary_kwh / tfa if tfa > 0 else 0.0, "other_pe_kwh_per_m2": other_primary_kwh / tfa if tfa > 0 else 0.0, diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5e3f5a77..11877d8b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -913,6 +913,77 @@ def _heat_network_dlf(age_band: Optional[str]) -> float: raise UnmappedSapCode("heat_network_age_band", age_band) +# SAP 10.2 Table 12 fuel code 50 — "electricity for pumping in +# distribution network". Its CO2 / PE factors vary by month per Table +# 12d / 12e (= standard-electricity profile); worksheet (372)/(472) +# footnote (a) applies the monthly factors weighted by the heat profile. +_ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE: Final[int] = 50 +# SAP 10.2 Appendix C §C3.2 (PDF p.51) — pumping energy = 1% of the +# energy required for space and water heating. +_HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT: Final[float] = 0.01 + + +def _heat_network_distribution_electricity( + main: Optional[MainHeatingDetail], + space_heating_monthly_kwh: tuple[float, ...], + hot_water_output_monthly_kwh: tuple[float, ...], + efficiency: float, +) -> Optional[tuple[float, float, float]]: + """SAP 10.2 Appendix C §C3.2 (PDF p.51) — electricity for pumping + water through a heat network's distribution system. + + Spec verbatim: "CO2 emissions and Primary Energy associated with the + electricity used for pumping water through the distribution system + are allowed for by adding electrical energy equal to 1% of the + energy required for space and water heating." Worksheet line (313) = + 0.01 × [(307) + (310)]; its CO2 (372) and PE (472) bill on the + Table 12d / 12e monthly factors for fuel code 50 ("electricity for + pumping in distribution network"), weighted by the monthly heat + profile per worksheet footnote (a). + + (307)m = space-heating fuel and (310)m = water-heating fuel: for a + heat network the cascade models the heat-generator efficiency as + 1/DLF, so fuel = q_useful / efficiency = q_useful × DLF. The + monthly weighting of the Table 12d/12e factor is shape-only (the DLF + scalar cancels), and the energy carries the DLF. + + Returns (energy_kwh, co2_factor, pe_factor) for heat-network mains + (Table 4a 301-304 / category 6); None otherwise so the default + 0.0 / None fields leave individually-heated certs unchanged. + + NB Elmhurst's worksheet DISPLAYS the (372) energy column as 0.01 × + (307) (space only) but computes the EMISSIONS on 0.01 × (307+310) + per the §C3.2 text — verified line-by-line against the community- + heating corpus worksheets. We mirror the spec text (space + water). + """ + if not _is_heat_network_main(main) or efficiency <= 0.0: + return None + distribution_monthly_kwh = tuple( + _HEAT_NETWORK_PUMPING_FRACTION_OF_HEAT * (sh + hw) / efficiency + for sh, hw in zip( + space_heating_monthly_kwh, hot_water_output_monthly_kwh + ) + ) + energy_kwh = sum(distribution_monthly_kwh) + if energy_kwh <= 0.0: + return None + co2_monthly = co2_monthly_factors_kg_per_kwh( + _ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE + ) + pe_monthly = pe_monthly_factors_kwh_per_kwh( + _ELECTRICITY_FOR_DISTRIBUTION_PUMPING_FUEL_CODE + ) + if co2_monthly is None or pe_monthly is None: + return None + co2_factor = sum( + kwh * f for kwh, f in zip(distribution_monthly_kwh, co2_monthly) + ) / energy_kwh + pe_factor = sum( + kwh * f for kwh, f in zip(distribution_monthly_kwh, pe_monthly) + ) / energy_kwh + return (energy_kwh, co2_factor, pe_factor) + + @dataclass(frozen=True) class PriceTable: """Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and @@ -6125,6 +6196,16 @@ def cert_to_inputs( tariff=_rdsap_tariff(epc), ) + _hw_extra_standing + # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution + # pumping electricity (worksheet (313)/(372)/(472)). None for + # individually-heated certs. + heat_network_distribution = _heat_network_distribution_electricity( + main, + space_heating_result.total_space_heating_monthly_kwh, + hw_monthly_kwh_for_factors, + eff, + ) + return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -6214,6 +6295,21 @@ def cert_to_inputs( epc, energy_requirements_result.secondary_fuel_monthly_kwh, ), hot_water_co2_factor_kg_per_kwh=hw_co2_factor, + # SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution + # pumping electricity (worksheet (313)/(372)/(472)). 0.0 / None + # on individually-heated certs. + heat_network_distribution_kwh_per_yr=( + heat_network_distribution[0] + if heat_network_distribution is not None else 0.0 + ), + heat_network_distribution_co2_factor_kg_per_kwh=( + heat_network_distribution[1] + if heat_network_distribution is not None else None + ), + heat_network_distribution_primary_factor=( + heat_network_distribution[2] + if heat_network_distribution is not None else None + ), # SAP 10.2 Table 12a Grid 2 (p.191) + Table 12d (p.194): pumps, # lighting, and the electric-shower end-use all bill via the # "All other uses" row → on off-peak tariffs blend the high / 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 f086cd36..6c6602c8 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -41,6 +41,7 @@ from domain.sap10_calculator.exceptions import ( from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] + _heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_water, # pyright: ignore[reportPrivateUsage] @@ -153,6 +154,83 @@ def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> assert inputs.main_heating_efficiency == pytest.approx(1.0 / 1.41, abs=0.005) +def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None: + # Arrange — heat-network main (Table 4a code 301 = community heating, + # category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution + # pumping electricity = 1% of the (space + water) heat generated, + # i.e. 0.01 × [(307) + (310)] where (307)m/(310)m = (space_demand + + # hw_output) / efficiency. Its CO2 (372) / PE (472) bill on the + # Table 12d / 12e monthly factors for fuel code 50 ("electricity for + # pumping in distribution network"), weighted by the monthly heat + # profile per worksheet footnote (a). + from domain.sap10_calculator.tables.table_12 import ( + co2_monthly_factors_kg_per_kwh, + pe_monthly_factors_kwh_per_kwh, + ) + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, + sap_main_heating_code=301, + ) + space = (1000.0, 800.0, 600.0, 400.0, 200.0, 0.0, + 0.0, 0.0, 0.0, 300.0, 700.0, 1000.0) + hw = (200.0,) * 12 + efficiency = 1.0 / 1.45 # heat network models efficiency as 1/DLF + + # Act + result = _heat_network_distribution_electricity(main, space, hw, efficiency) + + # Assert — energy = 0.01 × Σ((space + hw) / eff) = 0.01 × 7400 × 1.45; + # factors = code-50 monthly weighted by the (space + hw) heat profile. + assert result is not None + energy_kwh, co2_factor, pe_factor = result + distribution_monthly = tuple( + 0.01 * (s + w) / efficiency for s, w in zip(space, hw) + ) + co2_monthly = co2_monthly_factors_kg_per_kwh(50) + pe_monthly = pe_monthly_factors_kwh_per_kwh(50) + assert co2_monthly is not None + assert pe_monthly is not None + expected_energy = 0.01 * (5000.0 + 2400.0) / efficiency + expected_co2_factor = sum( + d * f for d, f in zip(distribution_monthly, co2_monthly) + ) / expected_energy + expected_pe_factor = sum( + d * f for d, f in zip(distribution_monthly, pe_monthly) + ) / expected_energy + assert abs(energy_kwh - expected_energy) <= 1e-9 + assert abs(energy_kwh - 0.01 * 7400.0 * 1.45) <= 1e-9 + assert abs(co2_factor - expected_co2_factor) <= 1e-9 + assert abs(pe_factor - expected_pe_factor) <= 1e-9 + + +def test_heat_network_distribution_electricity_none_for_individual_main() -> None: + # Arrange — an individually-heated gas-boiler main (category 2, no + # heat-network SAP code). §C3.2 pumping electricity applies only to + # heat networks, so no distribution line should be emitted. + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + ) + space = (1000.0,) * 12 + hw = (200.0,) * 12 + + # Act + result = _heat_network_distribution_electricity(main, space, hw, 0.85) + + # Assert + assert result is None + + def test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh() -> None: # Arrange — when main heating is a heat network AND water heating # inherits from main (water_heating_code=901), the HW also incurs