fix(overlay): synthesise a coherent gas-boiler heating system on Landlord Override

A Landlord heating-system override was applied as a sparse patch, so the
replaced system's fields bled through. A storage flat reclassified as a gas
combi (property 728513) kept mains_gas=False, heating category 7, the 2401
storage charge control, a Dual meter and an electric-immersion cylinder — an
incoherent record that gated out the gas-boiler-upgrade Measure and made the
heating Generator read the dwelling as off-gas (offering HHRSH storage).

Extend the ADR-0035 drag-along to gas boilers (Table 4b 102/104/120): the
overlay now sets the whole coherent companion set — mains_gas, gas main fuel,
heating category 2, fanned flue, full modern controls (2106), a single-rate
meter, and hot water from the main system with the cylinder set from the boiler
type (combi → none, regular/CPSU → cylinder). The main_fuel overlay also flips
mains_gas=True for a "mains gas" fuel. Non-off-peak archetypes now drag an
explicit Single meter so a system switch never leaves a stale Dual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-26 12:48:33 +00:00
parent 015ea0a293
commit a680d65188
5 changed files with 281 additions and 34 deletions

View file

@ -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

View file

@ -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)
)

View file

@ -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),
)
)

View file

@ -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

View file

@ -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