diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index 88fea108..fcbe0e0c 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -126,11 +126,26 @@ _ASHP_OVERLAY = HeatingOverlay( _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. +# 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 +# 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} +) + # 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, @@ -241,7 +256,7 @@ def recommend_heating( if ashp_option is not None: options.append(ashp_option) - boiler_option = _boiler_upgrade_with_cylinder_option(epc, products) + boiler_option = _boiler_upgrade_option(epc, products) if boiler_option is not None: options.append(boiler_option) @@ -250,26 +265,34 @@ def recommend_heating( return Recommendation(surface=_HEATING_SURFACE, options=tuple(options)) -def _boiler_upgrade_with_cylinder_option( +def _boiler_upgrade_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).""" + """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 - if not epc.has_hot_water_cylinder: - 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=( - "Replace the boiler with a gas condensing boiler and insulate and " - "thermostat the hot-water cylinder" - ), - overlay=EpcSimulation(heating=_boiler_cylinder_overlay(epc)), + description=description, + overlay=EpcSimulation(heating=overlay), cost=Cost( total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate ), @@ -294,13 +317,32 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool: 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, + 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, 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.""" + 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): @@ -314,6 +356,7 @@ def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay: heat_emitter_type=_RADIATOR_EMITTER, sap_main_heating_code=_REGULAR_GAS_BOILER_SAP_CODE, fan_flue_present=True, + 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, @@ -329,6 +372,20 @@ def _cylinder_under_insulated(thickness_mm: Optional[int]) -> bool: 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/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/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 63ffb141..9d8ce4ff 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -785,6 +785,30 @@ def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None: _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) + + # --- 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 ad6392d5..9b011437 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -328,22 +328,6 @@ def test_boiler_upgrade_skips_thermostat_when_already_present() -> 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. @@ -376,3 +360,52 @@ def test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> 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, + main_heating_control=2106, + water_heating_code=901, + water_heating_fuel=26, + ) + + +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