mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge pull request #1399 from Hestia-Homes/fix/heating-rec-pcdb-boiler-fallback
Classify PCDB-index gas boilers with no SAP code for heating recs (#1388 A#2)
This commit is contained in:
commit
de9159f9b8
2 changed files with 169 additions and 20 deletions
|
|
@ -28,6 +28,7 @@ from domain.modelling.measure_type import MeasureType
|
|||
from domain.modelling.product import Product
|
||||
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
|
||||
from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record
|
||||
from domain.sap10_calculator.tables.table_4b import (
|
||||
table_4b_seasonal_efficiencies_pct,
|
||||
)
|
||||
|
|
@ -192,6 +193,19 @@ _WET_BOILER_SAP_CODE_RANGES: tuple[range, ...] = (
|
|||
)
|
||||
_ELECTRIC_BOILER_SAP_CODE_RANGE = range(191, 197)
|
||||
|
||||
# RdSAP main_heating_category values that denote a wet central-heating boiler:
|
||||
# 1 ("central heating with separate HW") and 2 ("boiler and radiators"). Both
|
||||
# imply a wet primary loop — the fuel (not the category) says gas/oil/electric.
|
||||
# Used only for the fallback classification of a lodgement that carries a
|
||||
# category but no `sap_main_heating_code` (mirrors the calculator's
|
||||
# `cert_to_inputs._is_wet_boiler_main`).
|
||||
_WET_BOILER_CATEGORIES: frozenset[int] = frozenset({1, 2})
|
||||
# Electricity main-fuel codes: the SAP Table 12 code (30, Elmhurst/cascade) and
|
||||
# the raw gov-API enum (29). An electric-fuelled boiler is the electrification
|
||||
# path, not a gas swap, so it is excluded from the gas-boiler upgrade even when
|
||||
# the SAP code is absent (the code range can't identify it).
|
||||
_ELECTRICITY_FUEL_CODES: frozenset[int] = frozenset({29, 30})
|
||||
|
||||
# Cylinder jacket end-state (from the after-cert): an 80 mm jacket
|
||||
# (`cylinder_insulation_type=2`). The jacket is added only when the existing
|
||||
# cylinder is below this thickness — bringing every cylinder up to 80 mm and
|
||||
|
|
@ -321,8 +335,7 @@ def _system_tune_up_options(
|
|||
absent). Each control option is offered only when it genuinely improves the
|
||||
existing controls — never a downgrade or a no-op (ADR-0024)."""
|
||||
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||
code: Optional[int] = main.sap_main_heating_code
|
||||
if code is None or not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
|
||||
if not _is_wet_boiler(main):
|
||||
return []
|
||||
control = main.main_heating_control
|
||||
control_code: Optional[int] = control if isinstance(control, int) else None
|
||||
|
|
@ -455,31 +468,70 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool:
|
|||
instead); a non-gas boiler is a fuel switch, so it is never gated on
|
||||
efficiency."""
|
||||
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||
code: Optional[int] = main.sap_main_heating_code
|
||||
if code is None:
|
||||
if not _is_wet_boiler(main):
|
||||
return False
|
||||
if not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
|
||||
return False
|
||||
if code in _ELECTRIC_BOILER_SAP_CODE_RANGE:
|
||||
if _is_electric_boiler(main):
|
||||
return False
|
||||
if not epc.sap_energy_source.gas_connection_available:
|
||||
return False
|
||||
if main.main_fuel_type in _GAS_FUEL_CODES and _already_condensing(code):
|
||||
if main.main_fuel_type in _GAS_FUEL_CODES and _already_condensing(main):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _already_condensing(sap_main_heating_code: int) -> bool:
|
||||
"""Whether an existing gas boiler already meets the new condensing boiler's
|
||||
winter efficiency (SAP 10.2 Table 4b). Non-Table-4b codes (e.g. solid fuel)
|
||||
have no comparable efficiency and so are never treated as already-condensing."""
|
||||
efficiencies: Optional[tuple[float, float]] = table_4b_seasonal_efficiencies_pct(
|
||||
sap_main_heating_code
|
||||
)
|
||||
if efficiencies is None:
|
||||
def _is_wet_boiler(main: MainHeatingDetail) -> bool:
|
||||
"""Whether the main system is a wet boiler the tune-up / boiler-upgrade
|
||||
generators can evaluate. Identifies by the Table 4a/4b `sap_main_heating_code`
|
||||
when lodged; when it is absent, falls back to the gov-EPC-API lodgement shape
|
||||
(a PCDB Table 105 gas/oil boiler index, or an RdSAP `main_heating_category`
|
||||
of 1/2 "central heating") — the same fallback the calculator's
|
||||
`cert_to_inputs._is_wet_boiler_main` uses, so the generators evaluate the
|
||||
same population the calculator already models as wet boilers (#1388 A#2).
|
||||
Heat pumps (category 4) are never wet boilers."""
|
||||
if main.main_heating_category == _HEAT_PUMP_CATEGORY:
|
||||
return False
|
||||
winter_efficiency_pct: float = efficiencies[0]
|
||||
return winter_efficiency_pct >= _NEW_BOILER_WINTER_EFFICIENCY_PCT
|
||||
code: Optional[int] = main.sap_main_heating_code
|
||||
if code is not None:
|
||||
return any(code in r for r in _WET_BOILER_SAP_CODE_RANGES)
|
||||
index: Optional[int] = main.main_heating_index_number
|
||||
if index is not None and gas_oil_boiler_record(index) is not None:
|
||||
return True
|
||||
return main.main_heating_category in _WET_BOILER_CATEGORIES
|
||||
|
||||
|
||||
def _is_electric_boiler(main: MainHeatingDetail) -> bool:
|
||||
"""Whether the wet boiler runs on electricity — the electrification path, not
|
||||
a gas swap, so it is excluded from the gas-boiler upgrade. Identified by the
|
||||
Table 4a electric-boiler code range when lodged, else by an electricity main
|
||||
fuel (the code range can't identify an electric boiler lodged with no SAP
|
||||
code)."""
|
||||
code: Optional[int] = main.sap_main_heating_code
|
||||
if code is not None:
|
||||
return code in _ELECTRIC_BOILER_SAP_CODE_RANGE
|
||||
return main.main_fuel_type in _ELECTRICITY_FUEL_CODES
|
||||
|
||||
|
||||
def _already_condensing(main: MainHeatingDetail) -> bool:
|
||||
"""Whether an existing gas boiler already meets the new condensing boiler's
|
||||
winter efficiency. Uses the SAP 10.2 Table 4b figure for the lodged
|
||||
`sap_main_heating_code`; when no code is lodged (the gov-EPC-API PCDB cohort),
|
||||
reads the boiler's declared winter efficiency from its PCDB Table 105 record
|
||||
instead. A boiler whose efficiency cannot be resolved (no Table 4b entry, no
|
||||
PCDB record) is not treated as already-condensing — the upgrade is offered."""
|
||||
code: Optional[int] = main.sap_main_heating_code
|
||||
if code is not None:
|
||||
efficiencies: Optional[tuple[float, float]] = (
|
||||
table_4b_seasonal_efficiencies_pct(code)
|
||||
)
|
||||
if efficiencies is None:
|
||||
return False
|
||||
return efficiencies[0] >= _NEW_BOILER_WINTER_EFFICIENCY_PCT
|
||||
index: Optional[int] = main.main_heating_index_number
|
||||
if index is not None:
|
||||
record = gas_oil_boiler_record(index)
|
||||
if record is not None and record.winter_efficiency_pct is not None:
|
||||
return record.winter_efficiency_pct >= _NEW_BOILER_WINTER_EFFICIENCY_PCT
|
||||
return False
|
||||
|
||||
|
||||
def _boiler_combi_overlay(epc: EpcPropertyData) -> HeatingOverlay:
|
||||
|
|
|
|||
|
|
@ -278,6 +278,8 @@ def test_ashp_bundle_carries_the_composite_per_dwelling_cost() -> None:
|
|||
def test_listed_building_yields_no_ashp_bundle() -> None:
|
||||
# Arrange — a listed building protects the fabric; an external ASHP unit is
|
||||
# not auto-offered (ADR-0024). The dwelling is on gas, so HHRSH is also out.
|
||||
# It IS a wet gas combi (PCDB index 10328, no SAP code), so a control tune-up
|
||||
# is legitimately offered — but never ASHP or HHRSH.
|
||||
baseline: EpcPropertyData = _gas_boiler_house()
|
||||
|
||||
# Act
|
||||
|
|
@ -285,8 +287,11 @@ def test_listed_building_yields_no_ashp_bundle() -> None:
|
|||
baseline, _StubProducts(), PlanningRestrictions(is_listed=True)
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert recommendation is None
|
||||
# Assert — no whole-system replacement (ASHP / HHRSH); a tune-up is fine.
|
||||
if recommendation is not None:
|
||||
measure_types = {o.measure_type for o in recommendation.options}
|
||||
assert "air_source_heat_pump" not in measure_types
|
||||
assert "high_heat_retention_storage_heaters" not in measure_types
|
||||
|
||||
|
||||
def test_conservation_area_still_yields_an_ashp_bundle() -> None:
|
||||
|
|
@ -507,6 +512,98 @@ def test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> None:
|
|||
}
|
||||
|
||||
|
||||
# --- PCDB-lodged gas boiler with no SAP code (#1388 Work item A#2) ----------
|
||||
|
||||
|
||||
def _pcdb_lodged_gas_boiler_baseline() -> EpcPropertyData:
|
||||
"""A mains-gas wet boiler lodged the way the gov EPC API cohort does — a
|
||||
`main_heating_category` (2, "boiler + radiators") and a PCDB Table 105
|
||||
`main_heating_index_number`, but NO `sap_main_heating_code`. Index 5928 =
|
||||
Ideal Classic, 77.1% winter (a pre-condensing 2001 boiler). Reproduces
|
||||
portfolio-814 pids 742304 / 742306 / 742322 / 742325 / 742336, which model
|
||||
to £0 works and a null SAP heating code because the generators keyed on
|
||||
`sap_main_heating_code` alone and never evaluated these boilers."""
|
||||
epc: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
|
||||
main = epc.sap_heating.main_heating_details[0]
|
||||
main.sap_main_heating_code = None
|
||||
main.main_heating_category = 2
|
||||
main.main_heating_index_number = 5928
|
||||
main.main_fuel_type = 26 # mains gas
|
||||
return epc
|
||||
|
||||
|
||||
def test_pcdb_lodged_gas_boiler_without_sap_code_yields_a_boiler_upgrade() -> None:
|
||||
# Arrange — a non-condensing (77.1%) mains-gas boiler lodged with only a PCDB
|
||||
# index + category (no SAP code). The calculator already treats this as a wet
|
||||
# boiler (cert_to_inputs._is_wet_boiler_main); the generator must too, so the
|
||||
# inefficient boiler is offered an upgrade instead of silently getting nothing.
|
||||
baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline()
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
|
||||
|
||||
# Assert — the boiler upgrade is offered, targeting the code-102 end-state.
|
||||
assert recommendation is not None
|
||||
options = {o.measure_type.value: o for o in recommendation.options}
|
||||
assert "gas_boiler_upgrade" in options
|
||||
overlay = options["gas_boiler_upgrade"].overlay.heating
|
||||
assert overlay is not None
|
||||
assert overlay.sap_main_heating_code == 102
|
||||
|
||||
|
||||
def test_pcdb_lodged_condensing_gas_boiler_without_sap_code_yields_no_upgrade() -> None:
|
||||
# Arrange — same lodgement shape but a condensing PCDB index (126 = Broag
|
||||
# Remeha, 91% winter ≥ the new boiler's 84%). A like-for-like gas swap gains
|
||||
# nothing, so no boiler upgrade — the dwelling gets a tune-up instead (the
|
||||
# PCDB efficiency stands in for the absent Table 4b code).
|
||||
baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline()
|
||||
baseline.sap_heating.main_heating_details[0].main_heating_index_number = 126
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
|
||||
|
||||
# Assert — no boiler upgrade; a control tune-up is still offered (the
|
||||
# fixture's controls are already standard 2106, so the zone tune-up is the
|
||||
# offered variant).
|
||||
assert recommendation is not None
|
||||
measure_types = {o.measure_type for o in recommendation.options}
|
||||
assert "gas_boiler_upgrade" not in measure_types
|
||||
assert "system_tune_up_zoned" in measure_types
|
||||
|
||||
|
||||
def test_pcdb_lodged_gas_boiler_without_sap_code_is_tune_up_eligible() -> None:
|
||||
# Arrange — the same non-condensing PCDB boiler must also be recognised as a
|
||||
# wet boiler by the tune-up gate (controls/cylinder), not just the upgrade.
|
||||
baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline()
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
|
||||
|
||||
# Assert — a tune-up option is offered (zone variant; controls are 2106).
|
||||
assert recommendation is not None
|
||||
assert "system_tune_up_zoned" in {o.measure_type for o in recommendation.options}
|
||||
|
||||
|
||||
def test_category_2_electric_boiler_without_sap_code_yields_no_gas_upgrade() -> None:
|
||||
# Arrange — a category-2 boiler with no SAP code and no PCDB index, on
|
||||
# electricity (an electric "boiler and radiators, electric" cohort lodges
|
||||
# category 2). Electrification, not a gas swap, is its path, so no gas boiler
|
||||
# upgrade even on a gas street.
|
||||
baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline()
|
||||
main = baseline.sap_heating.main_heating_details[0]
|
||||
main.main_heating_index_number = None
|
||||
main.main_fuel_type = 30 # electricity
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
|
||||
|
||||
# Assert
|
||||
if recommendation is not None:
|
||||
assert "gas_boiler_upgrade" not in {
|
||||
o.measure_type for o in recommendation.options
|
||||
}
|
||||
|
||||
|
||||
def _gas_combi_baseline() -> EpcPropertyData:
|
||||
"""A mains-gas combi (Table 4b code 112, no cylinder) with inadequate
|
||||
controls (2111 "TRVs and bypass" — no room thermostat)."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue