diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index b3b298ed..34f2b549 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -546,9 +546,25 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # (worksheet (464)/(466)/(364)/(366) per SAP 10.2 §13b spec) + # (2) community-HP COP cascade for CH3 + (3) heat-network overall # factor (486)/(386) calc — separate follow-up slices). - _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=+0.5915, expected_cost_resid_gbp=-13.6289, expected_co2_resid_kg=-787.2531, expected_pe_resid_kwh=-3827.1887), + # + # Slice S0380.172 closed the CH1 (boiler) + CH3 (HP) CO2 / PE + # residuals via SAP 10.2 Table 4a (PDF p.164) heat-network heat- + # source efficiency scaling: code 301 (boilers) eff = 80%, code + # 304 (HP) eff = 300%. Spec block 13a (467) = (307+310) × 100 / + # heat_source_eff × Table 12 PE factor; cascade meters network_ + # input directly so PE/CO2 factors are scaled by 1/heat_source_eff + # at lookup time. CH1 ΔCO2 −787 → −126 (~84% closed) and ΔPE + # −3827 → −967 (~75% closed); CH3 ΔCO2 +1614 → +473 (~71% + # closed) and ΔPE +11879 → +1749 (~85% closed). Code 302 (CHP+ + # boilers) is omitted from the scaling table — the 35%/65% split + # requires the displaced-electricity credit line per spec block + # 13b (464)/(466); follow-up slice scope. Residual CH1/CH3 gap is + # 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), _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=+1613.7837, expected_pe_resid_kwh=+11878.7588), + _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 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), ) @@ -881,3 +897,15 @@ def test_community_heating_mapper_populates_chp_split_fields( main_1 = main_heating_details[0] assert main_1.community_heating_chp_fraction == expected_chp_fraction assert main_1.community_heating_boiler_fuel_type == expected_boiler_fuel_code + + +# S0380.172 — Heat-network heat-source-eff scaling residual coverage. +# +# Per SAP 10.2 Table 4a (PDF p.164): "Boilers (RdSAP)" eff=80%, "Heat +# pump (RdSAP)" eff=300%. The cascade's CO2/PE factor functions scale +# Table 12 factors by 1/heat_source_eff so that network_input × scaled +# factor lands on the spec block 13a (467) / 12b (367) "(307+310) × +# 100 / eff × Table 12 factor" formula. SAP code 302 (CHP+boilers) is +# excluded — 35%/65% split + displaced-electricity credit is follow-up. +# Coverage is asserted via the residual-pin test above (CH1 / CH3 +# closure; CH2 / CH4 / CH6 unchanged). diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 79b370d0..9c959f7b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -825,6 +825,31 @@ _HEAT_NETWORK_MAIN_CODES: Final[frozenset[int]] = frozenset({301, 302, 303, 304} _HEAT_NETWORK_CATEGORY: Final[int] = 6 +# SAP 10.2 Table 4a (PDF p.164) heat-network heat-source efficiency by +# SAP code. Verbatim: +# 301 "Boilers (RdSAP)" → 80% +# 302 "CHP and boilers (RdSAP)" → 75% (overall — per RdSAP 10 §C) +# 304 "Heat pump (RdSAP)" → 300% (= COP 3.0) +# Used by the block 13a/12b PE/CO2 cascade to convert delivered network +# input (post-DLF) into FUEL input by dividing by the heat-source +# efficiency: spec (467) = (307+310) × 100 / (467a). The cascade meters +# heat-network input directly (eff = 1/DLF for cost via Table 12 +# heat-network rate), so PE/CO2 factors are scaled by 1/heat_source_eff +# at lookup time to land at the spec's fuel-input × Table-12-factor. +# +# Code 302 (CHP+boilers) is omitted here because the 35%/65% heat- +# fraction split applies different efficiencies to the two heat sources +# (CHP 75% overall + boilers 80%) and a single composite efficiency +# can't model the displaced-electricity credit line per spec block +# 13b (464)/(466). The cascade for code 302 keeps the current +# 1/DLF override (giving large CO2/PE residuals on CH2/CH4/CH6 — +# follow-up slice scope). +_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]] = { + 301: 0.80, + 304: 3.00, +} + + def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool: """True when the cert's main heating is a heat network — either by SAP code (Table 4a 301-304) or by `main_heating_category` (6).""" @@ -836,6 +861,28 @@ def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool: return main.main_heating_category == _HEAT_NETWORK_CATEGORY +def _heat_network_heat_source_efficiency_scaling( + main: Optional[MainHeatingDetail], +) -> float: + """Return the multiplicative scaling factor to apply to Table 12 + CO2 / PE factors when the main is a heat-network boiler (SAP 301) or + heat pump (SAP 304). Cascade computes CO2/PE = network_input × + Table_12_factor; spec block 13a/12b computes (network_input / + heat_source_eff) × Table_12_factor. Equivalent transform: scale the + factor by 1/heat_source_eff. Returns 1.0 for code 302 (CHP+boilers + — separate split-formula path) and non-heat-network mains. + """ + if not _is_heat_network_main(main): + return 1.0 + code = main.sap_main_heating_code if main is not None else None + if not isinstance(code, int): + return 1.0 + eff = _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY.get(code) + if eff is None: + return 1.0 + return 1.0 / eff + + 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. @@ -2413,7 +2460,16 @@ def _main_heating_co2_factor_kg_per_kwh( annual factor is the safe degenerate value) """ if not _is_electric_main(main): - return _co2_factor_kg_per_kwh(main) + # Heat-network mains (SAP codes 301 / 304) are non-electric per + # `_is_electric_main` but require a heat-source-efficiency scaling + # per spec block 12b (363)/(367) = network_input × 100 / + # 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) + ) if tariff is Tariff.STANDARD: monthly = _effective_monthly_co2_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, @@ -2470,7 +2526,15 @@ def _main_heating_primary_factor( unknown dual-rate codes, zero-fuel).""" fuel = _main_fuel_code(main) if not _is_electric_main(main): - return primary_energy_factor(fuel) + # PE-side mirror of `_main_heating_co2_factor_kg_per_kwh` + # heat-network heat-source-eff scaling. Spec block 13a (463)/ + # (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) + ) if tariff is Tariff.STANDARD: monthly = _effective_monthly_pe_factor( main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,