Model/domain/epc/property_overlays/main_heating_system_overlay.py
Khalim Conn-Kowlessar e02e6a76e1 Preserve an existing more-off-peak cert meter under a heating overlay 🟩
A landlord heating override DESCRIBES the dwelling, so its assumed off-peak meter
(Dual/E7) is a coherent default — it must not downgrade a cert that already
lodges a more-off-peak meter (e.g. property 709874's 24-hour all-low tariff). The
overlay opts in via keep_existing_off_peak_meter; _fold_heating then keeps the
cert's meter when both it and the desired meter are off-peak. Heating MEASURES
build HeatingOverlay directly and leave the flag False, so they still re-meter
for real (Elmhurst re-lodges 18-hour -> Dual on a storage install) — the cascade
pins stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:32:31 +00:00

173 lines
8.8 KiB
Python

"""Map a Landlord-Override main-heating-system value to a heating Simulation Overlay.
A main-heating-system value is one canonical system archetype ("Gas boiler,
combi", "Electric storage heaters, fan"). The calculator reads the primary
system's `sap_main_heating_code` (SAP Table 4a/4b), so the overlay maps the
archetype to a representative code and emits a whole-dwelling `HeatingOverlay`
targeting `main_heating_details[0]` (`building_part` is ignored). It composes
field-wise with the main_fuel / water_heating overlays.
**Coherent Heating System / drag-along (ADR-0035):** a landlord tells us the
*system*, not the dependent fields a coherent heating system carries — its
electricity tariff (meter) and, for storage heaters, its charge control. Rather
than hand-attach those per archetype (easy to forget when a new system is
added), they are **derived from the SAP code**: the off-peak meter from the
overlay's assumed-Dual classification (`_ASSUMED_DUAL_METER_CODES` — the §12
off-peak systems plus all-electric room-heater dwellings), and the conservative
manual charge control for storage heaters. So adding a heating archetype is just
adding its code — coherent companions fall out. Synthesis owns coherence; the
calculator never normalises a lodged cert.
The SEDBUK A-G efficiency band the Hyde "Heating" column carries is NOT honoured
yet (no efficiency slot on the overlay/MainHeatingDetail) -- archetypes map to
their modern/condensing Table 4b code, so an old low-rated boiler is currently
modelled at the condensing efficiency. Heat pumps and community heating (which
resolve via main_heating_index_number / community codes, not a Table 4b code)
are left UNKNOWN until modelled. Unresolvable values produce no overlay.
"""
from __future__ import annotations
from typing import Optional
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
from domain.sap10_calculator.tables.table_12a import (
OFF_PEAK_IMPLYING_HEATING_CODES,
)
# Off-peak (Economy 7) meter. Electric storage / CPSU systems charge overnight at
# 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"
# Electric room heaters (SAP Table 4a 691). They don't *require* off-peak the way
# storage/CPSU do, so they're absent from the calculator's §12
# `OFF_PEAK_IMPLYING_HEATING_CODES` (Rules 1-2). But a dwelling heated by them is
# all-electric and realistically billed on Economy 7 — its immersion hot water
# charges overnight and §12 Rule 3 gives the room heaters a 10-hour off-peak
# window. So when the landlord names only the system, the coherent meter to
# assume is Dual; the §12 dispatch then applies the realistic high/low split
# (not a single-rate over-penalty, nor an all-low over-credit).
_ROOM_HEATER_CODES = frozenset({691})
# Codes for which the overlay assumes a Dual (off-peak) meter: the §12-mandated
# off-peak systems plus the all-electric room-heater dwellings above.
_ASSUMED_DUAL_METER_CODES = OFF_PEAK_IMPLYING_HEATING_CODES | _ROOM_HEATER_CODES
# 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
# +0.7 C mean-internal-temperature adjustment is the largest of the storage
# controls (automatic / Celect +0.4, HHR 0), so it never over-credits an
# unobserved control. Scoped to storage *heaters* (Table 4a 401-409) — the only
# systems that take a charge control.
_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, 691 panel/convector/radiant electric room heaters
# (Table 4a — direct-acting, so a single-rate meter, NOT off-peak storage).
# 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,
"Gas CPSU": 120,
"Electric storage heaters, old": 401,
"Electric storage heaters, slimline": 402,
"Electric storage heaters, convector": 403,
"Electric storage heaters, fan": 404,
"Direct-acting electric": 191,
"Electric room heaters": 691,
}
def _meter_for(code: int) -> str:
"""The coherent meter a heating code implies: an off-peak ("Dual") meter for
the §12 off-peak systems and all-electric room-heater dwellings
(`_ASSUMED_DUAL_METER_CODES`), an explicit single-rate ("Single") meter for
every other system. Always set — never left to bleed."""
return _OFF_PEAK_METER if code in _ASSUMED_DUAL_METER_CODES else _SINGLE_RATE_METER
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(
main_heating_value: str, building_part: int
) -> Optional[EpcSimulation]:
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=_control_for(code),
# A landlord override describes the existing dwelling, so its assumed
# off-peak meter must not downgrade a more-off-peak cert meter
# (e.g. a 24-hour all-low tariff). Measures, which re-meter for real,
# build their HeatingOverlay directly and leave this False.
keep_existing_off_peak_meter=True,
)
)