diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index ae9e7115..82a28ed6 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -591,11 +591,28 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # 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.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), + # + # Slice S0380.174 closed the (62)m HW useful-kWh path on all 5 CH + # variants by adding the spec-required storage (57)m + primary (59)m + # loss components that the §4 cascade omitted for heat-network mains. + # Per SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482): "Primary + # circuit loss for insulated pipework and cylinderstat should be + # included (see Table 3)." And per SAP 10.2 Table 2b note b (PDF + # p.159) verbatim — the ×0.9 Temperature Factor reduction applies + # only to "boiler systems, warm air systems and heat pump systems", + # excluding community heating. CH1's HW path closes EXACTLY (cascade + # 3854.12 = worksheet 3854.12 at 4.24 p/kWh = £163.41), but the spec- + # correct fix exposes a separate +0.46 K MIT (92) over-count in §7 + # that drives a residual SH demand over-count of ~396 kWh/yr per CH + # variant. Pre-S0380.174 the §4 (65)m heat-gains under-count + # offset the §7 MIT over-count, masking the bug. Per + # [[feedback-software-no-special-handling]] apply spec-correct fix + # uniformly; the exposed §7 MIT residual is the next closure front. + _CorpusExpectation(variant='community heating 1', block='11b', expected_sap_resid=-1.0572, expected_cost_resid_gbp=+24.3605, expected_co2_resid_kg=+127.2164, expected_pe_resid_kwh=+408.6704), + _CorpusExpectation(variant='community heating 2', block='11b', expected_sap_resid=-0.4187, expected_cost_resid_gbp=+9.6470, expected_co2_resid_kg=-1356.9498, expected_pe_resid_kwh=+1778.5550), + _CorpusExpectation(variant='community heating 3', block='11b', expected_sap_resid=-1.0572, expected_cost_resid_gbp=+24.3605, expected_co2_resid_kg=-72.8776, expected_pe_resid_kwh=-239.0266), + _CorpusExpectation(variant='community heating 4', block='11b', expected_sap_resid=-0.4187, expected_cost_resid_gbp=+9.6470, expected_co2_resid_kg=-4323.7080, expected_pe_resid_kwh=+767.1285), + _CorpusExpectation(variant='community heating 6', block='11b', expected_sap_resid=-8.4406, expected_cost_resid_gbp=+194.4846, expected_co2_resid_kg=-2861.5307, expected_pe_resid_kwh=+8137.1145), ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index f9a75afb..0b0dfdae 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4191,6 +4191,36 @@ def _separately_timed_dhw( return bool(epc.has_hot_water_cylinder) +def _table_2b_note_b_multiplier_applies( + epc: EpcPropertyData, main: Optional[MainHeatingDetail], +) -> bool: + """SAP 10.2 Table 2b note b) (PDF p.159) verbatim: + "Multiply Temperature Factor by 0.9 if there is separate time + control of domestic hot water (boiler systems, warm air systems + and heat pump systems)." + + The system-type list "boiler / warm air / heat pump systems" omits + community heating. The ×0.9 reduction therefore does NOT fire for + heat-network mains even when DHW IS separately timed — for Table 3 + primary-loss hours the cascade still treats community-heating DHW + as separately timed (h=3) because Table 3 is system-type-agnostic. + + Worksheet evidence for heating-systems corpus 001431 community + heating 1 (Table 4a code 301, cylinder + thermostat + WHC=901): + (53) Temperature factor lodged as 0.6000 (Table 2b base) — NOT + 0.54 (= 0.6 × 0.9). Pre-slice the cascade routed community heating + through `_separately_timed_dhw=True` and applied the ×0.9 multiplier, + under-counting (57)m storage loss by ~10% × 12 months ≈ 45 kWh/yr. + """ + if not _separately_timed_dhw(epc, main): + return False + if main is None: + return False + if _is_heat_network_main(main): + return False + return True + + # RdSAP §3 default table (PDF p.56) — "Insulation of primary pipework": # age bands A-J → none (p=0.0); age bands K, L, M → full (p=1.0). The # default applies when the cert does not lodge an explicit insulation @@ -4212,6 +4242,20 @@ def _pipework_insulation_fraction_table_3(primary_age: Optional[str]) -> float: return PIPEWORK_INSULATED_UNINSULATED +# SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482): "Primary circuit +# loss for INSULATED pipework and cylinderstat should be included (see +# Table 3)." The spec literal "insulated pipework" pins the Table 3 +# pipework_insulation_fraction at p=1.0 for community-heating mains, +# overriding the age-band default in `_pipework_insulation_fraction_ +# table_3`. Worksheet evidence for heating-systems corpus 001431 CH1 +# (age G, age-band default p=0): the P960 (59)m monthly back-solves to +# h=3 + p=1 (n × 14 × (0.0091×3 + 0.0263) = 23.26 Jan), not h=3 + p=0 +# (which would give n × 14 × (0.0245×3 + 0.0263) = 43.4 Jan). +_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION: Final[float] = ( + PIPEWORK_INSULATED_FULLY +) + + # SAP 10.2 PDF p.100 line 5950: design heat loss = (39) × ΔT, where ΔT # = 24.2 K. The HLC × ΔT product feeds the PSR denominator per line 5946. _SAP_DESIGN_HEAT_LOSS_DELTA_T_K: Final[float] = 24.2 @@ -4498,6 +4542,17 @@ def _primary_loss_applies( and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES ): return True + # SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482): "Primary + # circuit loss for insulated pipework and cylinderstat should be + # included (see Table 3)." Heat-network mains with WHC=901/902/914 + # feed the dwelling-side cylinder via primary pipework from the + # HIU/connection — Table 3 row 1 (heat generator connected to a + # cylinder via primary pipework) applies. + if ( + _is_heat_network_main(main) + and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES + ): + return True return False @@ -4888,10 +4943,17 @@ def _primary_loss_override( water_heating_code=epc.sap_heating.water_heating_code, ): return None + # SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482) pins community- + # heating primary pipework to "insulated" (p=1.0), overriding the + # RdSAP §3 age-band default which would otherwise return 0 for + # pre-2007 stock. See `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION`. + pipework_p = ( + _HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION + if _is_heat_network_main(main) + else _pipework_insulation_fraction_table_3(primary_age) + ) base = primary_loss_monthly_kwh( - pipework_insulation_fraction=_pipework_insulation_fraction_table_3( - primary_age - ), + pipework_insulation_fraction=pipework_p, has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y", separately_timed_dhw=_separately_timed_dhw(epc, main), ) @@ -4949,7 +5011,13 @@ def _cylinder_storage_loss_override( insulation_type="factory_insulated", thickness_mm=float(thickness_mm), has_cylinder_thermostat=sh.cylinder_thermostat == "Y", - separately_timed_dhw=_separately_timed_dhw(epc, main), + # SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the + # ×0.9 multiplier to boiler / warm-air / heat-pump systems — + # community heating excluded. Gate via the dedicated helper so + # the storage-loss call site stays decoupled from Table 3's + # primary-loss `_separately_timed_dhw` (which still fires for + # community heating + cylinder → h=3 all year). + separately_timed_dhw=_table_2b_note_b_multiplier_applies(epc, main), ) # (57)m solar adjustment when solar HW + dedicated solar storage # share the cylinder. Vs follows the combined-cylinder convention. diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 436cc3e5..9aa4b190 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -2355,6 +2355,93 @@ def test_separately_timed_dhw_solid_fuel_boiler_codes_per_sap_10_2_table_3() -> assert _separately_timed_dhw(gas_epc, gas_main) is True +def test_community_heating_hw_from_main_applies_storage_and_primary_loss_per_sap_10_2_heat_networks() -> None: + # Arrange — SAP 10.2 §4 "Heat networks" (PDF p.17 line 1482): + # + # "Primary circuit loss for insulated pipework and cylinderstat + # should be included (see Table 3)." + # + # SAP 10.2 Table 2b note b) (PDF p.159) ×0.9 storage-loss multiplier: + # + # "Multiply Temperature Factor by 0.9 if there is separate time + # control of domestic hot water (boiler systems, warm air systems + # and heat pump systems)." + # + # Verbatim system-type list omits community heating — the ×0.9 + # cylinder Temperature Factor reduction therefore does NOT apply to + # heat-network mains. Worksheet evidence for heating-systems corpus + # property 001431 community heating 1 (Table 4a code 301 boiler-driven + # heat network, age band G, 110 L cylinder + cylinder thermostat, WHC + # = 901 "HW from main heating"): + # + # (53) Temperature factor = 0.6000 (Table 2b base, no ×0.9) + # (56)/(57) storage loss sum ≈ 449 kWh/yr (110 × 0.0181 × 1.0294 + # × 0.6 × 365) + # (59) primary loss sum ≈ 274 kWh/yr (365 × 14 × (0.0091×3 + # + 0.0263), p=1, h=3) + # + # Pre-slice the cascade applied the ×0.9 separately-timed-DHW + # multiplier to community heating (TF=0.54 → (57) sum ≈ 404) AND + # missed the primary-loss branch entirely ((59) sum = 0). The two + # gaps combined dropped HW useful (62) sum to 2339.24 vs worksheet + # 2658.01 — a -319 kWh/yr undercount that propagated through to (65) + # heat gains (cascade 793.51 vs ws 1221.62) and (310) HW fuel kWh + # (cascade 3391.90 vs ws 3854.12). + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=20, # mains gas (community) + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=6, # heat network + sap_main_heating_code=301, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_building_parts=[make_building_part(construction_age_band="G")], + sap_heating=make_sap_heating( + main_heating_details=[main], + water_heating_code=901, # HW from main heating + cylinder_size=2, # 110 L + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=38, + cylinder_thermostat="Y", + ), + ) + + # Act + wh_result, _ = _water_heating_worksheet_and_gains( + epc=epc, + water_efficiency_pct=1.0 / 1.45, # community heating: 1/DLF + is_instantaneous=False, + primary_age="G", + pcdb_record=None, + ) + + # Assert — both loss components match the spec formula at the + # worksheet's Table 2b base TF=0.6 and Table 3 (p=1, h=3) row. + assert wh_result is not None + expected_57_sum = 448.7429 # 110 × 0.0181 × 1.0294 × 0.6 × 365 + expected_59_sum = 273.8960 # 365 × 14 × (0.0091×3 + 0.0263) + got_57_sum = sum(wh_result.solar_storage_monthly_kwh) + got_59_sum = sum(wh_result.primary_loss_monthly_kwh) + assert abs(got_57_sum - expected_57_sum) < 1e-3, ( + f"(57) storage loss sum: got {got_57_sum!r}, want " + f"{expected_57_sum!r} — Table 2b note b ×0.9 must NOT apply to " + f"community heating mains (verbatim system-type list excludes " + f"heat networks)" + ) + assert abs(got_59_sum - expected_59_sum) < 1e-3, ( + f"(59) primary loss sum: got {got_59_sum!r}, want " + f"{expected_59_sum!r} — SAP 10.2 §4 'Heat networks' line 1482 " + f"requires primary circuit loss for insulated pipework + " + f"cylinderstat to be included for heat-network mains" + ) + + def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> None: # Arrange — an electric storage heater (SAP code 401) on an 18-hour # tariff. `_table_12a_system_for_main` returns None for storage