mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
commit
ae4a8569e9
4 changed files with 85 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
301–304 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 301–304 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ----------------------------
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue