diff --git a/datatypes/epc/domain/field_mappings.py b/datatypes/epc/domain/field_mappings.py index cc0f90678..b5d0e6b3a 100644 --- a/datatypes/epc/domain/field_mappings.py +++ b/datatypes/epc/domain/field_mappings.py @@ -1,3 +1,21 @@ +from typing import Optional + +from datatypes.epc.domain.epc_property_data import MainHeatingDetail + PROPERTY_TYPE_LOOKUP = {0: "House", 1: "Bungalow", 2: "Flat", 3: "Maisonette"} ROOF_CONSTRUCTION_LOOKUP = {} ROOF_INSULATION_LOCATION_LOOKUP = {} + +HEAT_NETWORK_MAIN_CODES: frozenset[int] = frozenset({301, 302, 303, 304}) +HEAT_NETWORK_CATEGORY: int = 6 + + +def is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool: + """True when the dwelling's main heating is connected to a shared heat + network — by SAP Table 4a code (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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 13bf41eee..275c841f1 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -62,6 +62,7 @@ from datatypes.epc.domain.epc_property_data import ( SapVentilation, SapWindow, ) +from datatypes.epc.domain.field_mappings import is_heat_network_main from domain.sap10_ml.demand import predicted_hot_water_kwh from domain.sap10_ml.rdsap_uvalues import Country, u_floor @@ -1076,15 +1077,6 @@ _HEAT_NETWORK_DLF_BY_AGE: Final[dict[str, float]] = { _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 - - # SAP 10.2 Table 4a (PDF p.164) heat-network heat-source efficiency by # SAP code. Verbatim: # 301 "Boilers (RdSAP)" → 80% @@ -1116,17 +1108,6 @@ _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]] = { _HEAT_NETWORK_STANDING_CHARGE_GBP: Final[float] = 120.0 -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_heat_source_efficiency_scaling( main: Optional[MainHeatingDetail], ) -> float: @@ -1138,7 +1119,7 @@ def _heat_network_heat_source_efficiency_scaling( 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): + 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): @@ -1159,7 +1140,7 @@ def _is_heat_network_electric_main(main: Optional[MainHeatingDetail]) -> bool: 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): + if not is_heat_network_main(main): return False return co2_monthly_factors_kg_per_kwh(_main_fuel_code(main)) is not None @@ -1293,7 +1274,7 @@ def _heat_network_distribution_electricity( 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: + 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 @@ -1995,7 +1976,7 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool: if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES: return False main = _water_heating_main(epc) - if not _is_heat_network_main(main): + if not is_heat_network_main(main): return False code = main.sap_main_heating_code if main is not None else None return isinstance(code, int) and ( @@ -2026,7 +2007,7 @@ def _heat_network_standing_charge_gbp( gas community) is not a Table-32 gas code, so `_is_gas_code` returned False and the standing came out £0 — cert 9390 lost the whole £120. """ - if _is_heat_network_main(main): + if is_heat_network_main(main): return _HEAT_NETWORK_STANDING_CHARGE_GBP if _is_community_heating_hw_from_main(epc): return _HEAT_NETWORK_STANDING_CHARGE_GBP / 2.0 @@ -2062,7 +2043,7 @@ def _main_heating_detail_efficiency( eff = pcdb_main.winter_efficiency_pct / 100.0 else: eff = seasonal_efficiency(main_code, main_category, main_fuel) - if _is_heat_network_main(main): + if is_heat_network_main(main): primary_age = _dwelling_age_band(epc) # Worksheet (307): heat required = demand × (305) × (306 DLF), so the # delivered-per-fuel efficiency carries 1 / ((305) charging factor × @@ -2307,7 +2288,7 @@ def _heat_network_community_fuel_code( 42/43/44 (the same rows the backwards-compat enum codes 11/12/13 map to). Cert 8536 (biomass community, SAP code 301) closed -17.2 → -6.5. - Gating on `_is_heat_network_main` keeps the bare Table-32 code 30 the + Gating on `is_heat_network_main` keeps the bare Table-32 code 30 the cascade uses internally as grid electricity untouched on non-community certs (e.g. cert 2211 whose whc=999 default writes `water_heating_fuel=30`). @@ -2317,7 +2298,7 @@ def _heat_network_community_fuel_code( gap loudly instead of silently mis-pricing it as grid electricity, per the strict-raise principle ([[reference-unmapped-sap-code]]). """ - if fuel not in _API_COMMUNITY_COLLISION_FUELS or not _is_heat_network_main(main): + if fuel not in _API_COMMUNITY_COLLISION_FUELS or not is_heat_network_main(main): return None translated = API_FUEL_TO_TABLE_12.get(fuel) if translated is None: @@ -2420,7 +2401,7 @@ def _heat_network_factor_fuel_code( (367) CO2 factor 0.2100, (467) PE factor 1.1300. """ fuel = _main_fuel_code(main) - if fuel is None or not _is_heat_network_main(main): + if fuel is None or not is_heat_network_main(main): return fuel return _table_12_factor_fuel_code(fuel) @@ -6009,7 +5990,7 @@ def _apply_heat_network_hiu_default_store( if epc.has_hot_water_cylinder: return epc dhw_main = _water_heating_main(epc) - if not _is_heat_network_main(dhw_main): + if not is_heat_network_main(dhw_main): return epc if dhw_main is not None and dhw_main.main_heating_index_number is not None: return epc @@ -6183,7 +6164,7 @@ def _table_2b_note_b_multiplier_applies( return False if main is None: return False - if _is_heat_network_main(main): + if is_heat_network_main(main): return False return True @@ -6544,7 +6525,7 @@ def _primary_loss_applies( # HIU/connection — Table 3 row 1 (heat generator connected to a # cylinder via primary pipework) applies. if ( - _is_heat_network_main(main) + is_heat_network_main(main) and water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES ): return True @@ -6986,7 +6967,7 @@ def _primary_loss_override( # pre-2007 stock. See `_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION`. pipework_p = ( _HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION - if _is_heat_network_main(main) + if is_heat_network_main(main) else _pipework_insulation_fraction_table_3(primary_age) ) base = primary_loss_monthly_kwh( @@ -6998,7 +6979,7 @@ def _primary_loss_override( # row applies regardless of the thermostat / separate-timing # lodgement (and so is robust to the community-fuel-as-electric # collision that would otherwise route DHW to the h=5 row). - heat_network=_is_heat_network_main(main), + heat_network=is_heat_network_main(main), ) # SAP 10.2 §12.4.4 (PDF p.36-37): for back-boiler combos summer DHW # comes from an electric immersion, not from the boiler — the boiler @@ -7037,7 +7018,7 @@ def _cylinder_thermostat_present( if epc.sap_heating.cylinder_thermostat == "Y": return True dhw_main = _water_heating_main(epc) - if _is_heat_network_main(dhw_main): + if is_heat_network_main(dhw_main): return True if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION: return True @@ -7828,7 +7809,7 @@ def cert_to_inputs( ): water_eff = apm_water_eff if ( - _is_heat_network_main(main) + 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 diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index d1f53d638..bb54792ca 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -31,6 +31,14 @@ _API_ELECTRICITY = 29 _OLD_STORAGE_SAP_CODE = 402 +def _hhr_measure_type_in(rec: Recommendation | None) -> bool: + if rec is None: + return False + return MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS in { + o.measure_type for o in rec.options + } + + def _electric_storage_baseline() -> EpcPropertyData: """A 000490 dwelling re-cast as an existing (non-HHR) electric storage system: electric main fuel, Table 4a code 402.""" @@ -160,6 +168,20 @@ def test_on_gas_street_electric_dwelling_yields_hhr_storage_bundle() -> None: assert "high_heat_retention_storage_heaters" in options +def test_community_heated_dwelling_yields_no_hhr_storage_bundle() -> None: + # Arrange — community boiler network (SAP code 301, category 6) with + # electricity as the upstream heat source fuel (code 41): not gas, so the + # fuel gate alone would pass — the topology gate must block it. + epc = build_epc() + main = epc.sap_heating.main_heating_details[0] + main.sap_main_heating_code = 301 + main.main_heating_category = 6 + main.main_fuel_type = 41 + + # Act / Assert + assert _hhr_measure_type_in(recommend_heating(epc, _StubProducts())) is False + + def _gas_boiler_house() -> EpcPropertyData: """A 000490 mains-gas combi dwelling, explicitly a House — ASHP-eligible.""" epc: EpcPropertyData = build_epc()