diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 5b0b63d7..8683e75c 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -185,6 +185,51 @@ _FORCE_SECONDARY_FOR_MAIN_CODES: Final[frozenset[int]] = frozenset( _PV_EXPORT_TARIFF_CODE: Final[int] = 60 +# SAP 10.2 Table 12c (page 193) — Distribution Loss Factor for heat +# networks by dwelling age band, used when no PCDB record is available +# (the modal RdSAP case). Per §C3.1: "Where a heat network is listed +# in the PCDB, the DLF is already factored into the cost, CO2 and PE +# factors recorded therein, so a DLF of 1 should be entered in +# worksheet (306) to avoid double counting." For non-PCDB networks +# (our case), DLF must be applied. K-or-newer (post-2007) = 1.50. +_HEAT_NETWORK_DLF_BY_AGE: Final[dict[str, float]] = { + "A": 1.20, "B": 1.26, "C": 1.33, "D": 1.37, "E": 1.41, "F": 1.43, + "G": 1.45, "H": 1.46, "I": 1.48, "J": 1.49, "K": 1.50, "L": 1.50, + "M": 1.50, +} +_HEAT_NETWORK_DLF_DEFAULT: Final[float] = 1.50 + + +# SAP 10.2 Table 4a codes for heat-network main heating systems: +# 301 = boiler-driven community heating +# 302 = boiler-driven community heating with CHP +# 303 = community CHP only +# 304 = electric heat-pump community heating +_HEAT_NETWORK_MAIN_CODES: Final[frozenset[int]] = frozenset({301, 302, 303, 304}) +_HEAT_NETWORK_CATEGORY: Final[int] = 6 + + +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).""" + if main is None: + return False + code = main.sap_main_heating_code + if isinstance(code, int) and code in _HEAT_NETWORK_MAIN_CODES: + return True + return main.main_heating_category == _HEAT_NETWORK_CATEGORY + + +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.""" + if age_band is None: + return _HEAT_NETWORK_DLF_DEFAULT + return _HEAT_NETWORK_DLF_BY_AGE.get( + age_band.upper(), _HEAT_NETWORK_DLF_DEFAULT + ) + + @dataclass(frozen=True) class PriceTable: """Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and @@ -749,12 +794,29 @@ def cert_to_inputs( ) eff = seasonal_efficiency(main_code, main_category, main_fuel) + if _is_heat_network_main(main): + # SAP 10.2 Table 12 note (k): heat-network unit prices are per + # kWh of heat GENERATED (before distribution losses), not per + # kWh of fuel consumed. Setting efficiency = 1/DLF makes the + # calculator's `main_fuel_kwh = q_useful / (1/DLF) = q_useful + # × DLF = q_generated`, so cost = q_generated × unit_price as + # the spec requires. + eff = 1.0 / _heat_network_dlf(primary_age) water_eff = _water_efficiency_with_category_inherit( water_heating_code=epc.sap_heating.water_heating_code, main_code=main_code, main_category=main_category, main_fuel=main_fuel, ) + if ( + _is_heat_network_main(main) + and epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES + ): + # HW from main on a heat-network cert: the DHW also incurs the + # network's distribution losses. Same 1/DLF override as for + # space heating so the delivered HW kWh reflects q_useful × DLF + # = q_generated, matching the per-kWh-generated unit price. + water_eff = 1.0 / _heat_network_dlf(primary_age) is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES hw_kwh = predicted_hot_water_kwh( total_floor_area_m2=epc.total_floor_area_m2, diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index 2d13e2f5..5798be33 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -80,6 +80,133 @@ def _typical_semi_detached_epc(): ) +def test_heat_network_main_applies_table12c_dlf_to_main_heating_efficiency() -> None: + # Arrange — heat-network main heating (Table 4a code 301 = community + # heating with CHP/boilers; main_heating_category=6). Cert age band + # E (1967-1975) lodges Table 12c DLF = 1.41. + # Per SAP 10.2 §C3.1 + Table 12 note (k): unit price is per kWh of + # heat GENERATED (i.e. before distribution losses), so the fuel-kwh + # multiplied by the unit price must be q_generated = q_useful × DLF. + # Setting main_heating_efficiency = 1/DLF makes our calculator's + # main_fuel_kwh = q_useful × DLF, which gives the correct cost. + 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, + sap_main_heating_code=301, + ) + part = make_building_part(construction_age_band="E") + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — DLF = 1.41 for age E → effective efficiency = 1/1.41 = 0.709. + assert inputs.main_heating_efficiency == pytest.approx(1.0 / 1.41, abs=0.005) + + +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 + # the network's distribution losses. The water efficiency must be + # overridden to 1/DLF so that the delivered HW kWh (and therefore + # cost/CO2/PE applied to it) reflects q_useful × DLF. + # Compare against a gas-boiler baseline at the same age band: the + # heat-network HW kWh should be greater by the ratio 0.80/(1/DLF) = + # DLF × 0.80 = 0.80 × 1.41 = 1.128 (i.e. ~13% higher) since the + # non-heat-network baseline inherits water efficiency 0.80 from + # the heat-network main's pre-DLF efficiency. + part = make_building_part(construction_age_band="E") + + hn_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, + ) + hn_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[hn_main], + water_heating_code=901, # from main + ), + ) + + # Comparable gas-boiler baseline that ALSO inherits a 0.80 water + # efficiency through `water_heating_code=901` for direct comparison. + # Use sap_main_heating_code = None so cascade returns 0.80 default. + gas_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + ) + gas_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating( + main_heating_details=[gas_main], + water_heating_code=901, + ), + ) + + # Act + hn_hw = cert_to_inputs(hn_epc).hot_water_kwh_per_yr + gas_hw = cert_to_inputs(gas_epc).hot_water_kwh_per_yr + + # Assert — DLF (1.41) for age E × 0.80 baseline / (1/1.41) HN = 1.128. + assert hn_hw / gas_hw == pytest.approx(1.41 * 0.80 / 1.0, abs=0.02) + + +def test_gas_boiler_main_efficiency_unchanged_by_dlf_override() -> None: + # Arrange — regression check: the DLF override only fires for heat- + # network main heating. A standard gas boiler (cat=2, code=102) must + # still get its Table 4b winter efficiency (0.84). + + main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=26, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=2, + sap_main_heating_code=102, # gas combi pre-2005, 0.84 eff + ) + part = make_building_part(construction_age_band="E") + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[part], + sap_heating=make_sap_heating(main_heating_details=[main]), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert — Table 4b code 102 winter efficiency = 0.84, no DLF override. + assert inputs.main_heating_efficiency == pytest.approx(0.84, abs=0.001) + + def test_main_heating_fraction_does_not_override_table11_secondary_default() -> None: # Arrange — the S-B30 attempt assumed `main_heating_fraction=1` meant # "no secondary heating" and dropped the Table 11 default in that