Merge pull request #1396 from Hestia-Homes/community-heating-hhrsh

Don't recommend HHRSH or ASHP to properties with community heating
This commit is contained in:
Daniel Roth 2026-07-01 15:33:57 +01:00 committed by GitHub
commit ae4a8569e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 85 additions and 41 deletions

View file

@ -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 (301304) 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

View file

@ -15,7 +15,7 @@ produced by scoring (ADR-0016).
from typing import Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP, is_heat_network_main
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.products import (
AshpCostInputs,
@ -670,10 +670,14 @@ def _ashp_eligible(epc: EpcPropertyData, restrictions: PlanningRestrictions) ->
installability the Optimiser owns the economics (ADR-0024), so floor area,
built form, fuel, and fabric are deliberately not gates. A conservation area
does not exclude ASHP (offered with a planning caveat); a listed/heritage
protection (`blocks_internal`) does."""
protection (`blocks_internal`) does. Community heating (SAP Table 4a codes
301304 or category 6) is excluded by topology: replacing a shared heat
network with an individual ASHP is never appropriate."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
return False
if is_heat_network_main(main):
return False
if restrictions.blocks_internal:
return False
return _is_house_or_bungalow(epc)
@ -718,11 +722,14 @@ def _hhr_storage_eligible(epc: EpcPropertyData) -> bool:
heat pump. Eligibility keys on the heating *fuel* (main_fuel_type not in
_GAS_FUEL_CODES), not the gas-connection flag a dwelling on a gas street
with electric or oil heating has gas_connection_available=True but still
qualifies."""
qualifies. Community heating (SAP Table 4a codes 301304 or category 6) is
excluded by topology regardless of fuel: ripping out a shared heat network
to install storage heaters is never appropriate."""
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
if main.sap_main_heating_code == _HHR_STORAGE_SAP_CODE:
return False
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
return False
non_gas_fuel: bool = main.main_fuel_type not in _GAS_FUEL_CODES
return non_gas_fuel
if is_heat_network_main(main):
return False
return main.main_fuel_type not in _GAS_FUEL_CODES

View file

@ -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

View file

@ -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()
@ -314,6 +336,22 @@ def test_existing_heat_pump_yields_no_ashp_bundle() -> None:
}
def test_community_heated_house_yields_no_ashp_bundle() -> None:
# Arrange — community boiler network (SAP code 301, category 6) on a house:
# topology alone must block ASHP regardless of fuel.
epc = build_epc()
main = epc.sap_heating.main_heating_details[0]
main.sap_main_heating_code = 301
main.main_heating_category = 6
# Act / Assert
recommendation: Recommendation | None = recommend_heating(epc, _StubProducts())
if recommendation is not None:
assert MeasureType.AIR_SOURCE_HEAT_PUMP not in {
o.measure_type for o in recommendation.options
}
# --- Gas boiler upgrade (Heating/HW expansion) ----------------------------