diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index b4308ed6..464d72bf 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -20,6 +20,9 @@ _CONTINGENCY_RATES: dict[str, float] = { "low_energy_lighting": 0.26, "high_heat_retention_storage_heaters": 0.10, "air_source_heat_pump": 0.25, + "gas_boiler_upgrade": 0.26, + "system_tune_up": 0.15, + "system_tune_up_zoned": 0.15, "solar_pv": 0.15, } diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 1c851541..2a6e908a 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -27,6 +27,9 @@ _HEATING_SURFACE = "Heating & Hot Water" _HHR_STORAGE_MEASURE_TYPE = MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS _ASHP_MEASURE_TYPE = MeasureType.AIR_SOURCE_HEAT_PUMP +_GAS_BOILER_UPGRADE_MEASURE_TYPE = MeasureType.GAS_BOILER_UPGRADE +_SYSTEM_TUNE_UP_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP +_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP_ZONED # Electricity main-fuel code (Elmhurst → SAP10 Table 12). _ELECTRICITY_FUEL = 30 @@ -111,6 +114,78 @@ _ASHP_OVERLAY = HeatingOverlay( ) +# --- Gas boiler upgrade (Heating/HW expansion): replace an existing wet boiler +# with a modern gas condensing boiler. Validated against Elmhurst before/after +# re-lodgements (cert 001431): the upgrade always targets mains gas — gas->gas +# directly, and a non-gas wet boiler (oil/LPG/solid) ->gas ONLY where a mains-gas +# connection is present (electric boilers are left alone; electrification is the +# national target). The end-state is a Table 4b SAP code (not a PCDB index): code +# 102 for a regular boiler heating a hot-water cylinder, code 104 for a combi +# (no cylinder, a later slice). The calculator derives the condensing-boiler +# seasonal efficiency from the code, so no efficiency input is needed. --- + +# Mains-gas main/water fuel code (Elmhurst -> SAP10 Table 12). +_MAINS_GAS_FUEL = 26 +# Table 4a heat-emitter code for radiators (the wet-distribution end-state). +_RADIATOR_EMITTER = 1 +# Table 4b SAP main-heating codes for the new gas condensing boiler: code 102 +# for a regular boiler heating a cylinder, code 104 for a combi (no cylinder). +_REGULAR_GAS_BOILER_SAP_CODE = 102 +_COMBI_GAS_BOILER_SAP_CODE = 104 +# Water-heating code 901 — hot water from the main heating system. +_WATER_FROM_MAIN_SYSTEM_CODE = 901 +# Elmhurst boiler flue type for the new condensing boiler (room-sealed/balanced); +# every relodged after lodges type 2. SAP-inert, written for end-state fidelity. +_CONDENSING_BOILER_FLUE_TYPE = 2 + +# Controls upgrade (SAP 10.2 Table 4e Group 1, PDF p.172): bring an inadequate +# boiler control up to full programmer + room thermostat + TRVs (code 2106). +# "Inadequate" = the Group-1 codes whose description carries NO room thermostat +# (2101 no control, 2102 programmer-only, 2107/2108/2109 programmer+TRVs without +# a room thermostat, 2111 TRVs and bypass) — these lack boiler interlock (Table +# 4c(2) / footnote c)), so adding a room thermostat is a genuine improvement. +# Controls with a room thermostat (2103/2104/2105/2106/2113) or better time-and- +# temperature zone control (2110/2112) are left unchanged — never downgraded. +_FULL_BOILER_CONTROL = 2106 +_INADEQUATE_BOILER_CONTROL_CODES: frozenset[int] = frozenset( + {2101, 2102, 2107, 2108, 2109, 2111} +) + +# System tune-up control end-states (SAP 10.2 Table 4e Group 1): the two best +# competing control upgrades offered while KEEPING the existing boiler — +# "standard" (programmer + room thermostat + TRVs, code 2106) and "zone" +# (time-and-temperature zone control, code 2110, type 3). Zone gives more SAP +# uplift for more cost, so the Optimiser steps to it when its extra SAP is +# needed (ADR-0024). +_STANDARD_CONTROL = _FULL_BOILER_CONTROL # 2106 +_ZONE_CONTROL = 2110 +# Controls already providing standard (2106) or better — a standard tune-up +# would be a no-op or a downgrade, so it is not offered to these. +_STANDARD_OR_BETTER_CONTROL_CODES: frozenset[int] = frozenset({2106, 2110, 2112}) +# Controls already providing zone control (type 3) — a zone tune-up is not +# offered to these. +_ZONE_CONTROL_CODES: frozenset[int] = frozenset({2110, 2112}) + +# Wet-boiler SAP main_heating_code ranges (SAP 10.2 Table 4a + 4b): gas/oil +# boilers 101-141, solid-fuel boilers 151-161, electric boilers 191-196 (held +# locally so the generator does not depend on the calculator's internals, +# mirroring `domain/sap10_calculator/rdsap/cert_to_inputs.py`). Electric boilers +# are a wet system but are deliberately not upgraded to gas. +_WET_BOILER_SAP_CODE_RANGES: tuple[range, ...] = ( + range(101, 142), + range(151, 162), + range(191, 197), +) +_ELECTRIC_BOILER_SAP_CODE_RANGE = range(191, 197) + +# 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 +# never downgrading a better-insulated one. +_CYLINDER_JACKET_INSULATION_TYPE = 2 +_MIN_CYLINDER_INSULATION_MM = 80 + + # --- ASHP cost interpretation (ADR-0025): read the dwelling into the typed # inputs the catalogue math needs. The modelling-layer half of the split; the # pricing itself lives on `Products`. --- @@ -201,11 +276,230 @@ def recommend_heating( if ashp_option is not None: options.append(ashp_option) + boiler_option = _boiler_upgrade_option(epc, products) + if boiler_option is not None: + options.append(boiler_option) + + options.extend(_system_tune_up_options(epc, products)) + if not options: return None return Recommendation(surface=_HEATING_SURFACE, options=tuple(options)) +def _system_tune_up_options( + epc: EpcPropertyData, products: ProductRepository +) -> list[MeasureOption]: + """The system tune-up options: keep the existing wet boiler but install + better heating controls (standard 2106 and/or zone 2110, as competing + options) and fix the cylinder (jacket when under-insulated, thermostat when + 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): + return [] + control = main.main_heating_control + control_code: Optional[int] = control if isinstance(control, int) else None + + options: list[MeasureOption] = [] + if control_code not in _STANDARD_OR_BETTER_CONTROL_CODES: + options.append( + _tune_up_option( + epc, + products, + measure_type=_SYSTEM_TUNE_UP_MEASURE_TYPE, + control=_STANDARD_CONTROL, + description=( + "Tune up the heating: install a programmer, room thermostat " + "and TRVs and insulate and thermostat the hot-water cylinder" + ), + ) + ) + if control_code not in _ZONE_CONTROL_CODES: + options.append( + _tune_up_option( + epc, + products, + measure_type=_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE, + control=_ZONE_CONTROL, + description=( + "Tune up the heating: install time-and-temperature zone " + "control and insulate and thermostat the hot-water cylinder" + ), + ) + ) + return options + + +def _tune_up_option( + epc: EpcPropertyData, + products: ProductRepository, + *, + measure_type: MeasureType, + control: int, + description: str, +) -> MeasureOption: + """One tune-up Option: the existing boiler is kept; only the heating control + and the conditional cylinder fixes change.""" + product = products.get(measure_type) + return MeasureOption( + measure_type=measure_type, + description=description, + overlay=EpcSimulation(heating=_tune_up_overlay(epc, control)), + cost=Cost( + total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate + ), + material_id=product.id, + ) + + +def _tune_up_overlay(epc: EpcPropertyData, control: int) -> HeatingOverlay: + """Build a tune-up end-state: set the heating control to ``control`` and + apply the conditional cylinder fixes (an 80 mm jacket when under-insulated, a + thermostat when absent) — only when the dwelling has a cylinder. The boiler, + fuel and meter are left unchanged (the boiler is kept).""" + sap_heating = epc.sap_heating + jacket_type: Optional[int] = None + jacket_thickness_mm: Optional[int] = None + thermostat: Optional[str] = None + if epc.has_hot_water_cylinder: + if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm): + jacket_type = _CYLINDER_JACKET_INSULATION_TYPE + jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM + if sap_heating.cylinder_thermostat != "Y": + thermostat = "Y" + return HeatingOverlay( + main_heating_control=control, + cylinder_insulation_type=jacket_type, + cylinder_insulation_thickness_mm=jacket_thickness_mm, + cylinder_thermostat=thermostat, + ) + + +def _boiler_upgrade_option( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[MeasureOption]: + """The gas-condensing-boiler upgrade for a dwelling with an existing wet + boiler: a combi (Table 4b code 104) where there is no cylinder, or a regular + boiler (code 102) heating the existing cylinder where there is one. Both + upgrade inadequate controls and the cylinder variant adds the conditional + cylinder fixes (a jacket when under-insulated, a thermostat when absent). One + Option per dwelling — a dwelling has a cylinder or it does not — offered only + where a mains-gas connection makes the gas end-state installable (ADR-0024 + revised).""" + if not _boiler_upgrade_eligible(epc): + return None + has_cylinder: bool = epc.has_hot_water_cylinder + overlay: HeatingOverlay = ( + _boiler_cylinder_overlay(epc) if has_cylinder else _boiler_combi_overlay(epc) + ) + description: str = ( + "Replace the boiler with a gas condensing boiler and insulate and " + "thermostat the hot-water cylinder" + if has_cylinder + else "Replace the boiler with a gas condensing combi boiler" + ) + product = products.get(_GAS_BOILER_UPGRADE_MEASURE_TYPE) + return MeasureOption( + measure_type=_GAS_BOILER_UPGRADE_MEASURE_TYPE, + description=description, + overlay=EpcSimulation(heating=overlay), + cost=Cost( + total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate + ), + material_id=product.id, + ) + + +def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool: + """Whether a dwelling's existing wet boiler can be upgraded to a gas + condensing boiler. The gas end-state is installable only with a mains-gas + connection, so gas dwellings always qualify and a non-gas wet boiler + (oil/LPG/solid) qualifies only where mains gas is present. Electric boilers + are left alone — electrification, not a gas swap, is their upgrade path.""" + main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] + code: Optional[int] = main.sap_main_heating_code + if code is None: + 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: + return False + return epc.sap_energy_source.mains_gas + + +def _boiler_combi_overlay(epc: EpcPropertyData) -> HeatingOverlay: + """Build the per-dwelling combi end-state: a gas condensing combi (Table 4b + code 104, fanned flue) on radiators with hot water from the boiler, plus a + controls upgrade when the existing controls are inadequate. No cylinder, so + no cylinder fields are touched.""" + main: MainHeatingDetail = epc.sap_heating.main_heating_details[0] + return HeatingOverlay( + main_fuel_type=_MAINS_GAS_FUEL, + heat_emitter_type=_RADIATOR_EMITTER, + sap_main_heating_code=_COMBI_GAS_BOILER_SAP_CODE, + fan_flue_present=True, + boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE, + main_heating_control=_upgraded_boiler_control(main), + water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE, + water_heating_fuel=_MAINS_GAS_FUEL, + ) + + +def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay: + """Build the per-dwelling boiler-with-cylinder end-state: a regular gas + condensing boiler on radiators, hot water from the main system, a controls + upgrade when the existing controls are inadequate, and the conditional + cylinder fixes — an 80 mm jacket only when the cylinder is under-insulated, a + thermostat only when one is absent. The existing cylinder size and meter are + left unchanged.""" + sap_heating = epc.sap_heating + main: MainHeatingDetail = sap_heating.main_heating_details[0] + jacket_type: Optional[int] = None + jacket_thickness_mm: Optional[int] = None + if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm): + jacket_type = _CYLINDER_JACKET_INSULATION_TYPE + jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM + thermostat: Optional[str] = ( + "Y" if sap_heating.cylinder_thermostat != "Y" else None + ) + return HeatingOverlay( + main_fuel_type=_MAINS_GAS_FUEL, + heat_emitter_type=_RADIATOR_EMITTER, + sap_main_heating_code=_REGULAR_GAS_BOILER_SAP_CODE, + fan_flue_present=True, + boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE, + main_heating_control=_upgraded_boiler_control(main), + water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE, + water_heating_fuel=_MAINS_GAS_FUEL, + cylinder_insulation_type=jacket_type, + cylinder_insulation_thickness_mm=jacket_thickness_mm, + cylinder_thermostat=thermostat, + has_hot_water_cylinder=True, + ) + + +def _cylinder_under_insulated(thickness_mm: Optional[int]) -> bool: + """Whether a hot-water cylinder is below the 80 mm jacket end-state (an + un-jacketed cylinder lodges no thickness).""" + return thickness_mm is None or thickness_mm < _MIN_CYLINDER_INSULATION_MM + + +def _upgraded_boiler_control(main: MainHeatingDetail) -> Optional[int]: + """The full-controls code (2106) when the existing boiler control is + inadequate (lacks a room thermostat — SAP 10.2 Table 4e Group 1), else + ``None`` to leave a room-thermostatted or better control unchanged. So the + overlay only ever moves controls where it genuinely improves them.""" + control = main.main_heating_control + code: Optional[int] = control if isinstance(control, int) else None + if code is None and isinstance(control, str) and control.isdigit(): + code = int(control) + if code in _INADEQUATE_BOILER_CONTROL_CODES: + return _FULL_BOILER_CONTROL + return None + + def _ashp_option( epc: EpcPropertyData, products: ProductRepository, diff --git a/domain/modelling/measure_type.py b/domain/modelling/measure_type.py index d28bff39..70a52c90 100644 --- a/domain/modelling/measure_type.py +++ b/domain/modelling/measure_type.py @@ -32,4 +32,7 @@ class MeasureType(StrEnum): MECHANICAL_VENTILATION = "mechanical_ventilation" HIGH_HEAT_RETENTION_STORAGE_HEATERS = "high_heat_retention_storage_heaters" AIR_SOURCE_HEAT_PUMP = "air_source_heat_pump" + GAS_BOILER_UPGRADE = "gas_boiler_upgrade" + SYSTEM_TUNE_UP = "system_tune_up" + SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned" SOLAR_PV = "solar_pv" diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 60caed17..f15ff102 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -70,6 +70,8 @@ _MAIN_HEATING_FIELDS: tuple[str, ...] = ( "sap_main_heating_code", "main_heating_index_number", "main_heating_category", + "fan_flue_present", + "boiler_flue_type", ) _SAP_HEATING_FIELDS: tuple[str, ...] = ( "water_heating_code", diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 8fdb1205..abf192e9 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -118,6 +118,14 @@ class HeatingOverlay: sap_main_heating_code: Optional[int] = None main_heating_index_number: Optional[int] = None main_heating_category: Optional[int] = None + # A modern condensing boiler has a fanned (room-sealed) flue; the boiler + # upgrade sets this True (SAP 10.2 Table 4f flue-fan electricity + the + # Table 4b condensing-boiler seasonal-efficiency basis depend on it). + fan_flue_present: Optional[bool] = None + # The boiler's flue type (Elmhurst enum) — a new condensing boiler lodges + # type 2 (room-sealed/balanced). SAP-inert, but written for fidelity so the + # end-state matches the installed boiler. + boiler_flue_type: Optional[int] = None # sap_heating (top-level) water_heating_code: Optional[int] = None water_heating_fuel: Optional[int] = None diff --git a/harness/report.py b/harness/report.py index c93f0d02..632c2b30 100644 --- a/harness/report.py +++ b/harness/report.py @@ -153,6 +153,29 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]: epc.sap_heating.main_heating_details[0].main_heating_category ), } + if measure_type == "gas_boiler_upgrade": + # heating_recommendation.py offers a gas condensing boiler to a dwelling + # with an existing (non-electric) wet boiler and a mains-gas connection; + # the cylinder presence shapes it (combi vs regular + cylinder fixes). + return { + "sap_main_heating_code": ( + epc.sap_heating.main_heating_details[0].sap_main_heating_code + ), + "mains_gas": epc.sap_energy_source.mains_gas, + "has_hot_water_cylinder": epc.has_hot_water_cylinder, + } + if measure_type in ("system_tune_up", "system_tune_up_zoned"): + # heating_recommendation.py offers a tune-up (keep the boiler, upgrade + # the controls + fix the cylinder) to a wet-boiler dwelling whose + # existing control can still be improved. + return { + "sap_main_heating_code": ( + epc.sap_heating.main_heating_details[0].sap_main_heating_code + ), + "main_heating_control": ( + epc.sap_heating.main_heating_details[0].main_heating_control + ), + } return {} diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json index ef21d58f..02eb24eb 100644 --- a/harness/sample_catalogue.json +++ b/harness/sample_catalogue.json @@ -13,5 +13,8 @@ "low_energy_lighting": { "unit_cost_per_m2": 8.0 }, "high_heat_retention_storage_heaters": { "unit_cost_per_m2": 3500.0 }, "air_source_heat_pump": { "unit_cost_per_m2": 12000.0 }, + "gas_boiler_upgrade": { "unit_cost_per_m2": 3000.0 }, + "system_tune_up": { "unit_cost_per_m2": 500.0 }, + "system_tune_up_zoned": { "unit_cost_per_m2": 900.0 }, "solar_pv": { "unit_cost_per_m2": 0.0 } } diff --git a/tests/domain/modelling/fixtures/boiler_combi_gas_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_combi_gas_001431_after.pdf new file mode 100644 index 00000000..784ab1ca Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_combi_gas_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_combi_gas_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_combi_gas_001431_before.pdf new file mode 100644 index 00000000..a8fc1809 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_combi_gas_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_combi_oil_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_combi_oil_001431_after.pdf new file mode 100644 index 00000000..67894d88 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_combi_oil_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_combi_oil_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_combi_oil_001431_before.pdf new file mode 100644 index 00000000..9f7a6994 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_combi_oil_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_after.pdf new file mode 100644 index 00000000..53e1c8d4 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_before.pdf new file mode 100644 index 00000000..de84ab54 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_coal_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_after.pdf new file mode 100644 index 00000000..e61f3466 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_before.pdf new file mode 100644 index 00000000..7403bf93 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_gas_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_after.pdf b/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_after.pdf new file mode 100644 index 00000000..6dc4fdf8 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_before.pdf b/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_before.pdf new file mode 100644 index 00000000..2b7a7287 Binary files /dev/null and b/tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_from_2101_001431_before.pdf b/tests/domain/modelling/fixtures/tune_up_from_2101_001431_before.pdf new file mode 100644 index 00000000..2d209593 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_from_2101_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_from_2113_001431_before.pdf b/tests/domain/modelling/fixtures/tune_up_from_2113_001431_before.pdf new file mode 100644 index 00000000..7a5b4c13 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_from_2113_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_standard_001431_after.pdf b/tests/domain/modelling/fixtures/tune_up_standard_001431_after.pdf new file mode 100644 index 00000000..e6c37477 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_standard_001431_after.pdf differ diff --git a/tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf b/tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf new file mode 100644 index 00000000..ebc431d8 Binary files /dev/null and b/tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf differ diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 77981ab2..a124c5c4 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -755,6 +755,185 @@ def test_gas_boiler_instant_hw_before_baselines() -> None: assert result.sap_score_continuous > 0.0 +def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a mains-gas wet boiler (SAP code 114) heating an uninsulated + # hot-water cylinder (no insulation, no thermostat) re-lodged as a new gas + # condensing boiler with a cylinder (SAP code 102, fanned flue), the cylinder + # jacketed (insulation type 2 / 80 mm) and given a thermostat. The boiler + # upgrade leaves the (already adequate) controls + cylinder size + meter + # unchanged. Validates the boiler-with-cylinder option end-state at delta 0. + # + # NB the absolute SAP on this dwelling is subject to a separate Summary-path + # mapper roof-fidelity gap (our calculator reads the roof better-insulated + # than Elmhurst, so it scores ~75 where Elmhurst prints 56); the gap is + # identical on before + after (the boiler measure never touches the roof), so + # it cancels and this pin still proves the overlay applies Elmhurst's exact + # heating field-delta. Tracked on the calculator branch, not here. + before: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_gas_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_boiler_combi_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a mains-gas combi (SAP code 112, no cylinder) with inadequate + # controls (2111 "TRVs and bypass" — no room thermostat, so no boiler + # interlock) re-lodged as a new gas condensing combi (code 104, fanned flue) + # with full programmer + room thermostat + TRV controls (2106). No cylinder, + # so no cylinder components. Validates the combi end-state + the controls- + # when-inadequate upgrade at delta 0. (Same Summary-path roof gap as the + # with-cylinder pin — it cancels across before/after.) + before: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_gas_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_gas_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_oil_combi_overlay_reproduces_the_relodged_after() -> None: + # Arrange — an OIL combi (fuel 28, SAP code 130, no cylinder) on a mains-gas + # street re-lodged as a gas condensing combi (fuel 28->26, code 104, fanned + # flue). Validates the non-gas -> gas conversion: the upgrade targets gas + # because a mains-gas connection is present (ADR-0024 revised). Controls are + # already adequate (2106), so they are unchanged. + before: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_oil_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "boiler_combi_oil_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_boiler_with_already_insulated_cylinder_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a gas boiler heating an ALREADY-jacketed cylinder (insulation + # type 2 / 80 mm) with no thermostat, re-lodged as a new gas condensing + # boiler (code 102) with a cylinder thermostat added. Validates the cylinder + # path's skip-jacket branch (the 80 mm jacket is not re-applied) while the + # thermostat is still added. (Sourced from an LPG re-lodgement; the Summary + # mapper reads its fuel as mains gas — fuel 26 — so this exercises the gas + # cylinder path, not a true LPG conversion. The LPG fuel-mapping gap is a + # separate mapper-front concern.) + before: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_lpg_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_lpg_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +def test_coal_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a SOLID-FUEL (coal) boiler (fuel 11, SAP code 153) heating a + # cylinder, on a mains-gas street, re-lodged as a gas condensing boiler + # (fuel 11->26, code 102, fanned flue + boiler flue type 2). Exercises the + # non-gas -> gas conversion for a solid-fuel boiler AND the new + # `boiler_flue_type` end-state (coal's before lodged none; every other cert + # already lodged flue type 2). The cylinder is already 80 mm insulated so the + # jacket is skipped; only the thermostat is added. + # + # The relodged after predates the user-locked "always add a cylinder + # thermostat when absent" rule, so it stale-lodged thermostat 'N'; the test + # corrects it to the rule's end-state 'Y' (the same correction the gas + # with-cylinder after received by re-lodging). Controls are already adequate + # (2106), so they are unchanged. + before: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_coal_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "boiler_cyl_coal_001431_after.pdf" + ) + after.sap_heating.cylinder_thermostat = "Y" + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + +@pytest.mark.parametrize( + "before_fixture, after_fixture, measure_type", + [ + # The system tune-up keeps the existing boiler and forces the heating + # controls to a fixed end-state (standard 2106 / zone 2110) ABSOLUTELY — + # proven by reproducing each common after from two different starting + # controls (2101 "no control" and 2113 "room thermostat and TRVs") — plus + # the conditional cylinder jacket + thermostat (both befores are + # uninsulated / un-thermostatted, so both fire). + ( + "tune_up_from_2101_001431_before.pdf", + "tune_up_standard_001431_after.pdf", + "system_tune_up", + ), + ( + "tune_up_from_2113_001431_before.pdf", + "tune_up_standard_001431_after.pdf", + "system_tune_up", + ), + ( + "tune_up_from_2101_001431_before.pdf", + "tune_up_zoned_001431_after.pdf", + "system_tune_up_zoned", + ), + ( + "tune_up_from_2113_001431_before.pdf", + "tune_up_zoned_001431_after.pdf", + "system_tune_up_zoned", + ), + ], +) +def test_system_tune_up_overlay_reproduces_the_relodged_after( + before_fixture: str, after_fixture: str, measure_type: str +) -> None: + # Arrange + before: EpcPropertyData = parse_recommendation_summary(before_fixture) + after: EpcPropertyData = parse_recommendation_summary(after_fixture) + recommendation: Recommendation | None = recommend_heating(before, _AnyProduct()) + assert recommendation is not None + option = next( + o for o in recommendation.options if o.measure_type == measure_type + ) + + # Act / Assert + _assert_overlay_reproduces_after(before, after, option.overlay) + + # --- Solar PV cascade pins (ADR-0026) ------------------------------------- # # The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp, diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 9a0cf285..b0f86edf 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -250,3 +250,229 @@ def test_existing_heat_pump_yields_no_ashp_bundle() -> None: assert "air_source_heat_pump" not in { o.measure_type for o in recommendation.options } + + +# --- Gas boiler upgrade (Heating/HW expansion) ---------------------------- + + +def _gas_boiler_with_cylinder_baseline() -> EpcPropertyData: + """A mains-gas wet boiler (Table 4b code 114) heating an uninsulated, un- + thermostatted hot-water cylinder — the boiler-with-cylinder dwelling.""" + return parse_recommendation_summary("boiler_cyl_gas_001431_before.pdf") + + +def test_gas_boiler_with_cylinder_dwelling_yields_a_boiler_upgrade_bundle() -> None: + # Arrange — a mains-gas wet boiler with an uninsulated, un-thermostatted + # cylinder: the upgrade fires both conditional cylinder fixes. + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — the absolute boiler end-state (code 102, fanned flue) with the + # cylinder jacketed (type 2 / 80 mm) and thermostatted; controls, cylinder + # size, and meter are left unchanged. + assert recommendation is not None + options = {o.measure_type.value: o for o in recommendation.options} + assert "gas_boiler_upgrade" in options + assert options["gas_boiler_upgrade"].overlay.heating == HeatingOverlay( + main_fuel_type=26, + heat_emitter_type=1, + sap_main_heating_code=102, + fan_flue_present=True, + boiler_flue_type=2, + water_heating_code=901, + water_heating_fuel=26, + cylinder_insulation_type=2, + cylinder_insulation_thickness_mm=80, + cylinder_thermostat="Y", + has_hot_water_cylinder=True, + ) + + +def test_boiler_upgrade_skips_jacket_when_cylinder_already_insulated() -> None: + # Arrange — the same dwelling but with an already well-insulated cylinder + # (100 mm > the 80 mm jacket end-state): the jacket must not be re-applied + # (and must never downgrade it). + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.cylinder_insulation_thickness_mm = 100 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no jacket fields, but the thermostat still added (absent before). + assert recommendation is not None + overlay = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ).overlay.heating + assert overlay is not None + assert overlay.cylinder_insulation_type is None + assert overlay.cylinder_insulation_thickness_mm is None + assert overlay.cylinder_thermostat == "Y" + + +def test_boiler_upgrade_skips_thermostat_when_already_present() -> None: + # Arrange — the same dwelling but the cylinder already has a thermostat. + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.cylinder_thermostat = "Y" + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — no thermostat field, but the jacket still added (uninsulated). + assert recommendation is not None + overlay = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ).overlay.heating + assert overlay is not None + assert overlay.cylinder_thermostat is None + assert overlay.cylinder_insulation_type == 2 + + +def test_electric_boiler_dwelling_yields_no_gas_boiler_upgrade() -> None: + # Arrange — an electric boiler (Table 4a code 191) is left alone: + # electrification, not a gas swap, is its upgrade path. + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 191 + + # 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 test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> None: + # Arrange — an oil boiler (Table 4b code 130) with no mains-gas connection: + # a gas boiler cannot be installed, so no upgrade is offered (the gas end- + # state is gated on a mains-gas connection). + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 130 + baseline.sap_energy_source.mains_gas = False + + # 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).""" + return parse_recommendation_summary("boiler_combi_gas_001431_before.pdf") + + +def test_gas_combi_dwelling_yields_a_combi_boiler_upgrade_bundle() -> None: + # Arrange — a mains-gas combi with no cylinder and inadequate controls: + # the upgrade replaces it with a condensing combi (code 104) and upgrades + # the controls to 2106, touching no cylinder fields. + baseline: EpcPropertyData = _gas_combi_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + options = {o.measure_type.value: o for o in recommendation.options} + assert "gas_boiler_upgrade" in options + assert options["gas_boiler_upgrade"].overlay.heating == HeatingOverlay( + main_fuel_type=26, + heat_emitter_type=1, + sap_main_heating_code=104, + fan_flue_present=True, + boiler_flue_type=2, + main_heating_control=2106, + water_heating_code=901, + water_heating_fuel=26, + ) + + +def _tune_up_baseline() -> EpcPropertyData: + """A mains-gas wet boiler (kept) with "no control" (2101) and an uninsulated, + un-thermostatted cylinder — the system tune-up dwelling.""" + return parse_recommendation_summary("tune_up_from_2101_001431_before.pdf") + + +def test_wet_boiler_dwelling_yields_both_tune_up_options() -> None: + # Arrange — a wet boiler whose controls can be improved: both the standard + # (2106) and zone (2110) control tune-ups are offered as competing options, + # each keeping the boiler and fixing the cylinder. + baseline: EpcPropertyData = _tune_up_baseline() + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert — both options carry the control end-state + the cylinder fixes, and + # leave the boiler untouched (no fuel / sap code / flue fields). + assert recommendation is not None + options = {o.measure_type.value: o for o in recommendation.options} + assert options["system_tune_up"].overlay.heating == HeatingOverlay( + main_heating_control=2106, + cylinder_insulation_type=2, + cylinder_insulation_thickness_mm=80, + cylinder_thermostat="Y", + ) + assert options["system_tune_up_zoned"].overlay.heating == HeatingOverlay( + main_heating_control=2110, + cylinder_insulation_type=2, + cylinder_insulation_thickness_mm=80, + cylinder_thermostat="Y", + ) + + +def test_tune_up_standard_not_offered_when_controls_already_standard() -> None: + # Arrange — controls are already standard (2106): the standard tune-up would + # be a control no-op, so only the zone tune-up is offered. + baseline: EpcPropertyData = _tune_up_baseline() + baseline.sap_heating.main_heating_details[0].main_heating_control = 2106 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + measure_types = {o.measure_type for o in recommendation.options} + assert "system_tune_up" not in measure_types + assert "system_tune_up_zoned" in measure_types + + +def test_tune_up_neither_offered_when_controls_already_zoned() -> None: + # Arrange — controls are already zone control (2110): neither tune-up would + # improve them, so neither is offered. + baseline: EpcPropertyData = _tune_up_baseline() + baseline.sap_heating.main_heating_details[0].main_heating_control = 2110 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + if recommendation is not None: + measure_types = {o.measure_type for o in recommendation.options} + assert "system_tune_up" not in measure_types + assert "system_tune_up_zoned" not in measure_types + + +def test_boiler_upgrade_leaves_adequate_controls_unchanged() -> None: + # Arrange — the same combi but with already-adequate controls (2113, room + # thermostat and TRVs): the upgrade must not move the controls (and must + # never downgrade a better control). + baseline: EpcPropertyData = _gas_combi_baseline() + baseline.sap_heating.main_heating_details[0].main_heating_control = 2113 + + # Act + recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) + + # Assert + assert recommendation is not None + overlay = next( + o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" + ).overlay.heating + assert overlay is not None + assert overlay.main_heating_control is None diff --git a/tests/domain/modelling/test_measure_type.py b/tests/domain/modelling/test_measure_type.py index 60c1a816..214df8bc 100644 --- a/tests/domain/modelling/test_measure_type.py +++ b/tests/domain/modelling/test_measure_type.py @@ -26,6 +26,9 @@ _EXPECTED_VALUES = { "mechanical_ventilation", "high_heat_retention_storage_heaters", "air_source_heat_pump", + "gas_boiler_upgrade", + "system_tune_up", + "system_tune_up_zoned", "solar_pv", } diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 8b95e455..4ea237bc 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -38,6 +38,9 @@ _GENERATOR_MEASURE_TYPES = ( "low_energy_lighting", "high_heat_retention_storage_heaters", "air_source_heat_pump", + "gas_boiler_upgrade", + "system_tune_up", + "system_tune_up_zoned", ) diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index 4357b4f7..003ff7bf 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -15,6 +15,9 @@ from harness.report import ( format_report_markdown, parity_report_for, ) +from tests.domain.modelling._elmhurst_recommendation import ( + parse_recommendation_summary, +) _GOLDEN = ( Path(__file__).resolve().parents[1] @@ -99,6 +102,44 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: } +def test_gas_boiler_upgrade_surfaces_its_eligibility_triggers() -> None: + # No golden API cert selects the boiler upgrade (it competes with — and on + # houses loses to — the ASHP bundle within the one heating Recommendation), + # so the trigger branch is exercised directly, like the cert_to_inputs unit + # tests of internal helpers. + from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage] + + # Arrange — a mains-gas wet boiler (SAP code 114) with a hot-water cylinder: + # the boiler-upgrade eligibility attributes the report should explain. + epc = parse_recommendation_summary("boiler_cyl_gas_001431_before.pdf") + + # Act + triggers = _triggers_for(epc, "gas_boiler_upgrade") + + # Assert — the wet-boiler SAP code, the mains-gas connection that makes the + # gas end-state installable, and the cylinder that shapes the bundle. + assert triggers == { + "sap_main_heating_code": 114, + "mains_gas": True, + "has_hot_water_cylinder": True, + } + + +def test_system_tune_up_surfaces_its_eligibility_triggers() -> None: + # Like the boiler-upgrade trigger, no golden cert selects a tune-up, so the + # branch is covered directly. + from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage] + + # Arrange — a wet boiler (SAP code 102) with "no control" (2101): the wet- + # boiler code and the improvable control are what the report should explain. + epc = parse_recommendation_summary("tune_up_from_2101_001431_before.pdf") + + # Act / Assert — both tune-up measure types surface the same eligibility. + expected = {"sap_main_heating_code": 102, "main_heating_control": 2101} + assert _triggers_for(epc, "system_tune_up") == expected + assert _triggers_for(epc, "system_tune_up_zoned") == expected + + def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None: # Arrange path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json" diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 8ef68627..b7ffb86f 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -168,6 +168,30 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( is_active=True, description="LED bulb", ), + MaterialRow( + id=6, + type="gas_boiler_upgrade", + total_cost=3000.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Gas condensing boiler", + ), + MaterialRow( + id=7, + type="system_tune_up", + total_cost=500.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Heating controls + cylinder tune-up", + ), + MaterialRow( + id=8, + type="system_tune_up_zoned", + total_cost=900.0, + cost_unit="gbp_per_unit", + is_active=True, + description="Zoned heating controls + cylinder tune-up", + ), ] ) session.commit()