diff --git a/docs/adr/0035-coherent-heating-system-synthesis.md b/docs/adr/0035-coherent-heating-system-synthesis.md index 030a6001..3dbbcb00 100644 --- a/docs/adr/0035-coherent-heating-system-synthesis.md +++ b/docs/adr/0035-coherent-heating-system-synthesis.md @@ -44,7 +44,57 @@ A real lodged cert is scored exactly as lodged — the calculator must not "fix" genuinely single-rate storage dwelling (its existing Unknown-meter inference is a separate, spec-faithful net for certs that lodged *Unknown*, and does not fire on an explicit meter). A contract test guards the override path: every off-peak -code the archetype map can emit must drag a `Dual` meter. +code the archetype map can emit must drag a `Dual` meter, and every other code an +explicit `Single` meter — a system switch can never leave the previous system's +meter in place. + +### Amendment: the gas-boiler companion set (property 728513) + +The storage-heater drag-along above covered electric systems; a landlord-named +**gas boiler** was still synthesised as a sparse patch — only the SAP code (its +meter/charge-control companions are `None` for a boiler). The replaced system's +fields therefore bled through: an electric-storage flat reclassified as a gas +combi kept `mains_gas=False`, heating category 7, the 2401 storage charge +control, a `Dual` meter and an electric-immersion cylinder. That incoherent +record gated out the gas-boiler-upgrade Measure and made the heating Generator +read the dwelling as off-gas (offering HHRSH storage to a gas property). + +A gas-boiler archetype (Table 4b 102 regular / 104 combi / 120 CPSU) now drags +its **whole** coherent set, code-derived like the storage companions: + +- **`mains_gas=True` + `main_fuel_type=26`** — a gas boiler implies a mains-gas + connection and gas main fuel (so a heating-system-only override is + self-coherent, not dependent on a companion `main_fuel` override). The + `main_fuel` overlay independently sets `mains_gas=True` for a "mains gas" fuel; + the two agree (both 26 / True) so composition stays order-independent. A + non-gas `main_fuel` override (electricity/LPG/oil) leaves `mains_gas` **None** + (unchanged) — an electric main heating does not prove there is no gas hob. +- **`main_heating_category=2`** (gas boiler) and **`fan_flue_present=True`** — a + modern room-sealed condensing boiler. +- **`main_heating_control=2106`** (programmer + room thermostat + TRVs) — the + landlord names the boiler, not its controls. Unlike the storage charge control + (where the *conservative* lowest-SAP default is right because the control is + genuinely unknown), a gas boiler installed under modern Building Regs must + carry compliant controls, and the overlay already assumes the modern condensing + efficiency for the boiler — so **assuming modern controls is the coherent, + consistent default** (the alternative, a conservative "no controls" 2101, was + considered and rejected as inconsistent with the assumed-modern boiler). The + controls tune-up Measure still adds zone control on top. +- **Hot water from the main system** (`water_heating_code=901`, + `water_heating_fuel=26`) with the cylinder set from the boiler type — a combi + (104) has **no** cylinder (`has_hot_water_cylinder=False`), a regular boiler / + CPSU keeps one. (Mirrors Elmhurst: the cylinder choice is offered for a regular + condensing boiler and greyed out for a combi.) +- **`meter_type="Single"`** — a boiler is single-rate; the explicit Single meter + overwrites any inherited storage `Dual` (the symmetric mirror of storage→Dual). + +A `mains_gas` flag was added to the `main_fuel` overlay. The companion-fuel and +heating-system overlays now share the `mains_gas` / `main_fuel_type` fields +rather than being strictly disjoint — accepted because they only ever agree for +the mains-gas archetypes. The archetype map holds only mains-gas boilers, so a +gas-boiler override always implies mains gas; a *contradictory* fuel override +(gas boiler + a non-gas fuel) is out-of-scope incoherent landlord input (a +finaliser-level validation, not an overlay concern). ## Considered Options diff --git a/domain/epc/property_overlays/main_fuel_overlay.py b/domain/epc/property_overlays/main_fuel_overlay.py index cc482122..25e28247 100644 --- a/domain/epc/property_overlays/main_fuel_overlay.py +++ b/domain/epc/property_overlays/main_fuel_overlay.py @@ -31,6 +31,19 @@ _FUEL_CODES: dict[str, int] = { "biomass (community)": 31, } +# A "mains gas" main fuel asserts the dwelling has a mains-gas connection, so the +# overlay must also flip `sap_energy_source.mains_gas` — not just the fuel code. +# Without it the effective EPC says "fuel = mains gas" while `mains_gas` stays +# False, which (a) gates out the gas-boiler-upgrade Measure and (b) makes the +# heating Generator read the dwelling as off-gas and wrongly offer HHRSH storage +# (property 728513). Only the **private** mains-gas connection (code 26) sets it; +# community mains gas (code 20) is a heat network, not a gas-grid connection. +# Non-gas fuels leave the flag None ("unchanged") rather than clearing it: an +# electric (or LPG/oil) main fuel does not tell us there is no gas supply (a +# dwelling can heat electrically yet still have a gas hob), so only electrifying +# Measures clear `mains_gas`, never a Landlord fuel correction. +_MAINS_GAS_FUEL_VALUES: frozenset[str] = frozenset({"mains gas"}) + def fuel_overlay_for( main_fuel_value: str, building_part: int @@ -38,4 +51,7 @@ def fuel_overlay_for( code = _FUEL_CODES.get(main_fuel_value) if code is None: return None - return EpcSimulation(heating=HeatingOverlay(main_fuel_type=code)) + mains_gas = True if main_fuel_value in _MAINS_GAS_FUEL_VALUES else None + return EpcSimulation( + heating=HeatingOverlay(main_fuel_type=code, mains_gas=mains_gas) + ) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index aeedeed2..94d4947c 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -38,6 +38,12 @@ from domain.sap10_calculator.tables.table_12a import ( # the low rate and cannot run economically on a single-rate meter; "Dual" lets # the §12 dispatch resolve the specific tariff (storage 7-hour, CPSU 10-hour). _OFF_PEAK_METER = "Dual" +# Single-rate meter (SAP 10.2 Table 12a code 2 → STANDARD tariff). Every non-off- +# peak archetype synthesises this *explicitly* rather than leaving the meter +# untouched: switching OFF storage heaters must not let the dwelling's old "Dual" +# meter bleed through and bill the new gas/direct-acting system on an Economy-7 +# split (the mirror of the storage→Dual drag, ADR-0035). +_SINGLE_RATE_METER = "Single" # SAP Table 4e Group 4 storage charge-control code. Manual charge control is the # *conservative* assumption when the landlord didn't tell us the control: its @@ -48,12 +54,35 @@ _OFF_PEAK_METER = "Dual" _MANUAL_CHARGE_CONTROL = 2401 _STORAGE_HEATER_CODES = frozenset(range(401, 410)) +# SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The +# landlord names the boiler, not its controls — but a gas boiler installed under +# modern Building Regs must carry compliant controls, and this overlay already +# assumes the modern condensing efficiency for the boiler (A-G deferred), so +# assuming modern controls is the *coherent* default (decided with Khalim). It +# also overwrites any stale storage charge control (2401) the dwelling carried +# before the switch. (Conservative-vs-modern trade-off documented in ADR-0035.) +_FULL_BOILER_CONTROL = 2106 + +# Gas-boiler archetypes (Table 4b 102 regular / 104 combi / 120 CPSU) and the +# subset that heats hot water instantaneously (a combi has no cylinder; a regular +# boiler and a CPSU heat a cylinder). A gas boiler implies a mains-gas connection +# + a gas main fuel + a gas-boiler heating category (Table 4a cat 2) + a fanned +# room-sealed flue — all set so a heating-system-only override is self-coherent. +_GAS_BOILER_CODES = frozenset({102, 104, 120}) +_COMBI_CODES = frozenset({104}) +_GAS_BOILER_CATEGORY = 2 +_MAINS_GAS_FUEL = 26 +# SAP Table 4a "from the main system" water-heating code — a gas boiler heats hot +# water from itself, so the override routes water heating to the main system on +# mains gas (clearing a storage dwelling's old electric-immersion arrangement). +_FROM_MAIN_WATER_HEATING_CODE = 901 + # Canonical system archetype → representative SAP `sap_main_heating_code`. Codes # map to the modern/condensing variant (A-G efficiency deferred): 102 regular # condensing, 104 condensing combi, 120 CPSU, 401-404 storage heaters, 191 -# direct-acting electric. Companion fields (meter / charge control) are NOT -# listed here — they are derived from the code below, so a new archetype is just -# a code. +# direct-acting electric. Companion fields (meter / control / fuel / hot water) +# are NOT listed here — they are derived from the code below, so a new archetype +# is just a code (ADR-0035 drag-along). _MAIN_HEATING_CODES: dict[str, int] = { "Gas boiler, combi": 104, "Gas boiler, regular": 102, @@ -66,17 +95,42 @@ _MAIN_HEATING_CODES: dict[str, int] = { } -def _meter_for(code: int) -> Optional[str]: - """The coherent off-peak meter a heating code implies, or None when the - system is single-rate. Keyed off the calculator's §12 off-peak set so the - "which systems are off-peak" knowledge has one home.""" - return _OFF_PEAK_METER if code in OFF_PEAK_IMPLYING_HEATING_CODES else None +def _meter_for(code: int) -> str: + """The coherent meter a heating code implies: an off-peak ("Dual") meter for + the calculator's §12 off-peak systems, an explicit single-rate ("Single") + meter for every other system. Always set — never left to bleed.""" + return _OFF_PEAK_METER if code in OFF_PEAK_IMPLYING_HEATING_CODES else _SINGLE_RATE_METER -def _charge_control_for(code: int) -> Optional[int]: - """The conservative storage charge control to assume when unobserved, or - None for systems that don't take one.""" - return _MANUAL_CHARGE_CONTROL if code in _STORAGE_HEATER_CODES else None +def _control_for(code: int) -> Optional[int]: + """The control to assume when the landlord named only the system: a + conservative manual charge control for storage heaters, full modern controls + for a gas boiler, None for systems that take neither (direct-acting electric). + Overwrites a stale control inherited from the system being replaced.""" + if code in _STORAGE_HEATER_CODES: + return _MANUAL_CHARGE_CONTROL + if code in _GAS_BOILER_CODES: + return _FULL_BOILER_CONTROL + return None + + +def _gas_boiler_overlay(code: int) -> HeatingOverlay: + """The coherent gas-boiler companion set: a mains-gas connection + gas main + fuel, the gas-boiler heating category, a fanned room-sealed flue, full modern + controls, a single-rate meter, and a hot-water arrangement drawn from the + main system (a combi has no cylinder; a regular boiler / CPSU keeps one).""" + return HeatingOverlay( + sap_main_heating_code=code, + main_heating_category=_GAS_BOILER_CATEGORY, + main_fuel_type=_MAINS_GAS_FUEL, + mains_gas=True, + main_heating_control=_FULL_BOILER_CONTROL, + fan_flue_present=True, + meter_type=_SINGLE_RATE_METER, + water_heating_code=_FROM_MAIN_WATER_HEATING_CODE, + water_heating_fuel=_MAINS_GAS_FUEL, + has_hot_water_cylinder=code not in _COMBI_CODES, + ) def main_heating_overlay_for( @@ -85,10 +139,12 @@ def main_heating_overlay_for( code = _MAIN_HEATING_CODES.get(main_heating_value) if code is None: return None + if code in _GAS_BOILER_CODES: + return EpcSimulation(heating=_gas_boiler_overlay(code)) return EpcSimulation( heating=HeatingOverlay( sap_main_heating_code=code, meter_type=_meter_for(code), - main_heating_control=_charge_control_for(code), + main_heating_control=_control_for(code), ) ) diff --git a/tests/domain/epc/test_main_fuel_overlay.py b/tests/domain/epc/test_main_fuel_overlay.py index 9a1b1335..7de2a5e5 100644 --- a/tests/domain/epc/test_main_fuel_overlay.py +++ b/tests/domain/epc/test_main_fuel_overlay.py @@ -78,6 +78,35 @@ def test_community_mains_gas_is_a_distinct_fuel_code() -> None: assert simulation.heating.main_fuel_type == 20 +def test_mains_gas_fuel_sets_the_mains_gas_connection_flag() -> None: + # A "mains gas" fuel means the dwelling has a mains-gas connection, so the + # overlay must set sap_energy_source.mains_gas too — not only the fuel code. + # Without it the effective EPC says "fuel = mains gas" yet mains_gas=False, + # which suppresses the gas-boiler-upgrade path and wrongly offers HHRSH + # storage (the off-gas path). (Property 728513.) + simulation = fuel_overlay_for("mains gas", 0) + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.mains_gas is True + + +@pytest.mark.parametrize( + "main_fuel_value", + ["electricity", "LPG (bulk)", "bottled LPG", "oil", "house coal"], +) +def test_non_mains_gas_fuel_leaves_the_mains_gas_flag_unchanged( + main_fuel_value: str, +) -> None: + # Only an explicit "mains gas" fuel asserts a mains-gas connection. An + # electric (or LPG/oil) main fuel does NOT tell us there is no gas supply + # (the dwelling could still have a gas hob), so the flag is left None + # ("leave the baseline unchanged") rather than cleared to False. + simulation = fuel_overlay_for(main_fuel_value, 0) + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.mains_gas is None + + @pytest.mark.parametrize("main_fuel_value", ["Unknown", "", "no heating or hot water"]) def test_unresolvable_fuel_produces_no_overlay(main_fuel_value: str) -> None: # Act diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 27672ef2..e4bd544c 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -101,15 +101,18 @@ def test_storage_heaters_carry_an_off_peak_meter(main_heating_value: str) -> Non @pytest.mark.parametrize( "main_heating_value", ["Gas boiler, combi", "Direct-acting electric"] ) -def test_non_storage_heating_leaves_the_meter_untouched( +def test_non_storage_heating_resets_to_a_single_rate_meter( main_heating_value: str, ) -> None: - # Only storage heaters imply an off-peak tariff; gas and direct-acting - # electric (single-rate) keep whatever meter the dwelling already has. + # A single-rate system must drag a Single meter, not be left untouched: when + # the landlord switches OFF storage heaters the dwelling's old off-peak + # ("Dual") meter would otherwise bleed through and bill the new gas/direct- + # acting system on an Economy-7 split. Coherence is symmetric — off-peak + # codes synthesise Dual, every other code synthesises Single (ADR-0035). simulation = main_heating_overlay_for(main_heating_value, 0) assert simulation is not None assert simulation.heating is not None - assert simulation.heating.meter_type is None + assert simulation.heating.meter_type == "Single" @pytest.mark.parametrize( @@ -133,29 +136,89 @@ def test_storage_heaters_drag_along_conservative_manual_charge_control( assert simulation.heating.main_heating_control == 2401 -@pytest.mark.parametrize( - "main_heating_value", ["Gas boiler, combi", "Direct-acting electric"] -) -def test_non_storage_heating_does_not_drag_along_a_charge_control( - main_heating_value: str, -) -> None: - # Charge control is a storage-heater concept; other systems keep their own. - simulation = main_heating_overlay_for(main_heating_value, 0) +def test_direct_acting_electric_does_not_drag_along_a_control() -> None: + # Charge control is a storage concept and full boiler controls are a wet- + # system concept; direct-acting electric panel heaters take neither, so the + # overlay leaves the control untouched. + simulation = main_heating_overlay_for("Direct-acting electric", 0) assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_heating_control is None -def test_off_peak_archetypes_drag_a_dual_meter_others_do_not() -> None: - # Contract (the drag-along guard): the off-peak meter is derived from the SAP - # code via the calculator's single off-peak classification, so any heating - # archetype in the map whose code implies off-peak MUST synthesise a Dual - # meter — adding an off-peak system can never silently leave it single-rate — - # and a single-rate system must never gain one. +@pytest.mark.parametrize( + "main_heating_value", ["Gas boiler, combi", "Gas boiler, regular", "Gas CPSU"] +) +def test_gas_boiler_assumes_full_modern_controls(main_heating_value: str) -> None: + # The landlord names the boiler, not its controls. A gas boiler installed + # under modern Building Regs carries programmer + room thermostat + TRVs + # (SAP Table 4c code 2106), and the overlay already assumes the modern + # condensing efficiency for the boiler — so assuming modern controls is the + # coherent, consistent default (Khalim). It also overwrites any stale storage + # charge control (2401) the dwelling carried before the switch. + simulation = main_heating_overlay_for(main_heating_value, 0) + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_control == 2106 + + +@pytest.mark.parametrize( + "main_heating_value", ["Gas boiler, combi", "Gas boiler, regular", "Gas CPSU"] +) +def test_gas_boiler_drags_a_coherent_mains_gas_system(main_heating_value: str) -> None: + # A gas boiler implies a mains-gas connection and a gas main fuel, a gas- + # boiler heating category (Table 4a cat 2), and a fanned room-sealed flue + # (a modern condensing boiler). Setting these on the system overlay makes a + # heating-system-only override self-coherent — it does not depend on a + # separate main_fuel override also being present. + simulation = main_heating_overlay_for(main_heating_value, 0) + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.mains_gas is True + assert heating.main_fuel_type == 26 + assert heating.main_heating_category == 2 + assert heating.fan_flue_present is True + + +def test_gas_combi_has_no_hot_water_cylinder() -> None: + # A combi heats hot water instantaneously — there is no cylinder. Elmhurst + # greys out the cylinder choice for a combi (Khalim). The overlay clears the + # cylinder + routes hot water from the main system so a storage dwelling's + # old electric-immersion cylinder doesn't bleed through. + simulation = main_heating_overlay_for("Gas boiler, combi", 0) + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.has_hot_water_cylinder is False + assert simulation.heating.water_heating_code == 901 + assert simulation.heating.water_heating_fuel == 26 + + +@pytest.mark.parametrize("main_heating_value", ["Gas boiler, regular", "Gas CPSU"]) +def test_regular_gas_boiler_keeps_a_hot_water_cylinder( + main_heating_value: str, +) -> None: + # A regular boiler (and a gas CPSU) heats a hot-water cylinder from the main + # system. Elmhurst lets you pick a cylinder for a regular condensing boiler + # (Khalim). + simulation = main_heating_overlay_for(main_heating_value, 0) + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.has_hot_water_cylinder is True + assert simulation.heating.water_heating_code == 901 + assert simulation.heating.water_heating_fuel == 26 + + +def test_off_peak_archetypes_drag_dual_others_drag_single() -> None: + # Contract (the drag-along guard): the meter is derived from the SAP code via + # the calculator's single off-peak classification, so any archetype whose + # code implies off-peak MUST synthesise a Dual meter and every other code + # MUST synthesise a Single meter — a system switch can never silently leave + # the previous system's meter in place. for value, code in _MAIN_HEATING_CODES.items(): simulation = main_heating_overlay_for(value, 0) assert simulation is not None and simulation.heating is not None - expected = "Dual" if code in OFF_PEAK_IMPLYING_HEATING_CODES else None + expected = "Dual" if code in OFF_PEAK_IMPLYING_HEATING_CODES else "Single" assert simulation.heating.meter_type == expected, value @@ -189,6 +252,39 @@ def test_main_heating_override_remaps_the_primary_system_code() -> None: assert result.sap_heating.main_heating_details[0].sap_main_heating_code == 102 +def test_gas_boiler_override_onto_a_storage_baseline_leaves_no_stale_fields() -> None: + # Regression for property 728513: a landlord reclassified an electric-storage + # flat as a gas combi via the main_fuel + water_heating + main_heating_system + # overrides. Before the coherent drag-along the override set only the heating + # code + fuel and left the storage system's fields in place — mains_gas False, + # category 7, the 2401 charge control, a Dual meter, an electric-immersion + # cylinder — an incoherent record that gated out the gas-boiler path and made + # the engine read the dwelling as off-gas (offering HHRSH). The override must + # now overwrite ALL of them, regardless of the (undefined) overlay order. + baseline = build_epc() + overlays = [ + fuel_overlay_for("mains gas", 0), + water_heating_overlay_for("From main system, mains gas", 0), + main_heating_overlay_for("Gas boiler, combi", 0), + ] + assert all(o is not None for o in overlays) + + # Act + result = apply_simulations(baseline, [o for o in overlays if o is not None]) + + # Assert — a fully coherent mains-gas combi, no electric-storage residue. + main = result.sap_heating.main_heating_details[0] + assert main.sap_main_heating_code == 104 + assert main.main_fuel_type == 26 + assert main.main_heating_category == 2 + assert main.main_heating_control == 2106 + assert result.sap_energy_source.mains_gas is True + assert result.sap_energy_source.meter_type == "Single" + assert result.has_hot_water_cylinder is False + assert result.sap_heating.water_heating_code == 901 + assert result.sap_heating.water_heating_fuel == 26 + + def test_the_three_heating_overrides_compose_without_conflict() -> None: # Arrange — main_fuel, water_heating and main_heating_system all fold onto one # HeatingOverlay surface but set DISJOINT fields, so they compose (the