From 5b4a1b49667b4a56117cabbf98d227f2540ccbc7 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Jul 2026 14:52:47 +0000 Subject: [PATCH] Classify PCDB-index gas boilers with no SAP code for heating recs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gov-API certs that lodge a gas boiler as main_heating_category=2 + a PCDB main_heating_index_number but omit sap_main_heating_code were carried with a null code, so the heating generators (which gated on sap_main_heating_code alone) never evaluated them for a boiler upgrade or a system tune-up — they silently got nothing (£0 works, plateau at D). The SAP calculator already handles this shape (cert_to_inputs._is_wet_boiler_main), so the calculator modelled these as wet boilers while the recommenders ignored them. Mirror the calculator's classification in the generator: - _is_wet_boiler / _is_electric_boiler fall back, when no SAP code is lodged, to a PCDB Table 105 boiler index or main_heating_category in {1,2} (heat pumps excluded); electric boilers stay excluded by the code range when lodged, else by an electricity main fuel. - _already_condensing reads the boiler's declared PCDB winter efficiency when there is no Table 4b code, so a modern condensing boiler lodged this way still isn't offered a pointless like-for-like swap (gets a tune-up). Verified read-only on portfolio 814 / scenario 1271: pids 742304, 742306, 742322, 742325, 742336 now get a gas_boiler_upgrade (two cross D->C on the boiler alone) where they had £0 works; predicted properties whose heating donor is a PCDB-shape boiler (e.g. 742095/742102/742105) also recover a tune-up. Fixes the A#2 slice of #1388 (and unblocks 742302/742367). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../generators/heating_recommendation.py | 88 +++++++++++---- .../modelling/test_heating_recommendation.py | 101 +++++++++++++++++- 2 files changed, 169 insertions(+), 20 deletions(-) diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 3af1108b3..a7ff845e1 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -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: diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 5a54ff812..02eb928db 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -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)."""