diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index b4308ed6..ea2f3c4c 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -20,6 +20,7 @@ _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, "solar_pv": 0.15, } diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 1c851541..88fea108 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -27,6 +27,7 @@ _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 # Electricity main-fuel code (Elmhurst → SAP10 Table 12). _ELECTRICITY_FUEL = 30 @@ -111,6 +112,45 @@ _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 code for a regular gas boiler heating a cylinder. +_REGULAR_GAS_BOILER_SAP_CODE = 102 +# Water-heating code 901 — hot water from the main heating system. +_WATER_FROM_MAIN_SYSTEM_CODE = 901 + +# 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 +241,94 @@ def recommend_heating( if ashp_option is not None: options.append(ashp_option) + boiler_option = _boiler_upgrade_with_cylinder_option(epc, products) + if boiler_option is not None: + options.append(boiler_option) + if not options: return None return Recommendation(surface=_HEATING_SURFACE, options=tuple(options)) +def _boiler_upgrade_with_cylinder_option( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[MeasureOption]: + """The gas-condensing-boiler-with-cylinder bundle: a new regular gas boiler + (Table 4b code 102, fanned flue) for a dwelling whose existing wet boiler + heats a hot-water cylinder, plus the conditional cylinder fixes (a jacket + when under-insulated, a thermostat when absent). Offered only where a + mains-gas connection makes the gas end-state installable (ADR-0024 revised).""" + if not _boiler_upgrade_eligible(epc): + return None + if not epc.has_hot_water_cylinder: + return None + product = products.get(_GAS_BOILER_UPGRADE_MEASURE_TYPE) + return MeasureOption( + measure_type=_GAS_BOILER_UPGRADE_MEASURE_TYPE, + description=( + "Replace the boiler with a gas condensing boiler and insulate and " + "thermostat the hot-water cylinder" + ), + overlay=EpcSimulation(heating=_boiler_cylinder_overlay(epc)), + 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_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, 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, heating controls, and meter are left unchanged.""" + sap_heating = epc.sap_heating + 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, + 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 _ashp_option( epc: EpcPropertyData, products: ProductRepository, diff --git a/domain/modelling/measure_type.py b/domain/modelling/measure_type.py index d28bff39..b1c4bd9c 100644 --- a/domain/modelling/measure_type.py +++ b/domain/modelling/measure_type.py @@ -32,4 +32,5 @@ 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" SOLAR_PV = "solar_pv" diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 60caed17..2696d7ab 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -70,6 +70,7 @@ _MAIN_HEATING_FIELDS: tuple[str, ...] = ( "sap_main_heating_code", "main_heating_index_number", "main_heating_category", + "fan_flue_present", ) _SAP_HEATING_FIELDS: tuple[str, ...] = ( "water_heating_code", diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 8fdb1205..40d6fc95 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -118,6 +118,10 @@ 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 # sap_heating (top-level) water_heating_code: Optional[int] = None water_heating_fuel: Optional[int] = None diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json index ef21d58f..e15e4537 100644 --- a/harness/sample_catalogue.json +++ b/harness/sample_catalogue.json @@ -13,5 +13,6 @@ "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 }, "solar_pv": { "unit_cost_per_m2": 0.0 } } 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/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 77981ab2..63ffb141 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -755,6 +755,36 @@ 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) + + # --- 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..ad6392d5 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -250,3 +250,129 @@ 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, + 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_no_cylinder_dwelling_yields_no_boiler_with_cylinder_bundle() -> None: + # Arrange — a wet gas boiler with no hot-water cylinder (a combi); the with- + # cylinder option does not apply (the combi option lands in a later slice). + baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() + baseline.has_hot_water_cylinder = 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 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 + } diff --git a/tests/domain/modelling/test_measure_type.py b/tests/domain/modelling/test_measure_type.py index 60c1a816..56fd5a4c 100644 --- a/tests/domain/modelling/test_measure_type.py +++ b/tests/domain/modelling/test_measure_type.py @@ -26,6 +26,7 @@ _EXPECTED_VALUES = { "mechanical_ventilation", "high_heat_retention_storage_heaters", "air_source_heat_pump", + "gas_boiler_upgrade", "solar_pv", } diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 8b95e455..1adc9951 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -38,6 +38,7 @@ _GENERATOR_MEASURE_TYPES = ( "low_energy_lighting", "high_heat_retention_storage_heaters", "air_source_heat_pump", + "gas_boiler_upgrade", ) diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 8ef68627..3b72ba13 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -168,6 +168,14 @@ 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", + ), ] ) session.commit()