feat(overlay): GREEN — band sets a (winter,summer) efficiency slot on gas/oil boilers

band_seasonal_efficiency_pct maps (base_code, band) -> Table 4b (winter,summer)
for C-and-below; A/B pending Elmhurst (empty slot dict, no invented numbers);
non-banded boilers (solid/electric/CPSU) return None. main_heating_overlay_for
takes an optional band and sets seasonal_efficiency_override_pct, threaded onto
MainHeatingDetail via _fold_heating (ADR-0068). Calculator consumption next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-29 16:10:07 +00:00
parent 7a35d177b2
commit 4244b16d17
4 changed files with 126 additions and 13 deletions

View file

@ -125,6 +125,12 @@ class MainHeatingDetail:
)
main_heating_index_number: Optional[int] = None
sap_main_heating_code: Optional[int] = None # TODO: make enum?
# Boiler SEDBUK-band `(winter, summer)` seasonal efficiency (percent), set by
# the main-heating Landlord-Override overlay (ADR-0068 — the fifth Heating
# Companion). When present it overrides the Table 4b code default in the §206
# / Appendix D Eq D1 efficiency cascade; None keeps the code's Table 4b value.
# Override-supplied only — the lodged-cert path never sets it.
seasonal_efficiency_override_pct: Optional[tuple[float, float]] = None
main_heating_number: Optional[int] = None
main_heating_category: Optional[int] = None
main_heating_fraction: Optional[int] = None

View file

@ -18,22 +18,31 @@ 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.
The SEDBUK A-G efficiency band the "Heating" column carries is honoured as the
fifth Heating Companion (ADR-0068): a gas/oil boiler archetype still maps to its
modern/condensing Table 4b code (which drives combi-vs-regular hot-water
behaviour), but the `band` argument sets a `(winter, summer)` efficiency slot the
calculator consumes ahead of that code's Table 4b default — so an old low-rated
boiler is modelled at its band efficiency, not the condensing 84 %. C-and-below
reuse accredited Table 4b values; A/B are pending Elmhurst validation (no slot
until pinned). Solid-fuel / electric / CPSU boilers are intrinsic-efficiency and
take no band. 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.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
from domain.sap10_calculator.tables.table_12a import (
OFF_PEAK_IMPLYING_HEATING_CODES,
)
from domain.sap10_calculator.tables.table_4b import (
table_4b_seasonal_efficiencies_pct,
)
# 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
@ -238,6 +247,71 @@ _FAN_FLUE_BOILER_CODES = _OIL_BOILER_CODES
# mains gas (clearing a storage dwelling's old electric-immersion arrangement).
_FROM_MAIN_WATER_HEATING_CODE = 901
# Boiler Efficiency Band → seasonal efficiency, the fifth Heating Companion
# (ADR-0068). Keyed by the resolved boiler's base `sap_main_heating_code`
# (102 gas regular, 104 gas combi, 127 oil regular, 130 oil combi — the four
# SEDBUK-rated wet boilers) + its SEDBUK AG band. The base code is unchanged
# (it still drives combi-vs-regular hot-water behaviour); only the efficiency
# slot changes.
#
# C-and-below reuse the *accredited Table 4b* `(winter, summer)` of the code the
# PRD/ADR maps each band to — recorded here as that code so the numbers stay in
# one place (`table_4b.py`). Oil's Table 4b codes are sparser than gas and do not
# line up cleanly with SEDBUK bands, so oil E/F/G are approximate nearest-fit
# (⚠ ADR-0068 open question): E/F→125, D/E→129, F/G→128.
_BAND_TO_TABLE_4B_CODE: dict[tuple[int, BoilerEfficiencyBand], int] = {
# Gas boiler, regular (base 102)
(102, BoilerEfficiencyBand.C): 102, # (84, 74)
(102, BoilerEfficiencyBand.D): 106, # (80, 70)
(102, BoilerEfficiencyBand.E): 101, # (74, 64)
(102, BoilerEfficiencyBand.F): 105, # (70, 60)
(102, BoilerEfficiencyBand.G): 115, # (66, 56)
# Gas boiler, combi (base 104)
(104, BoilerEfficiencyBand.C): 104, # (84, 75)
(104, BoilerEfficiencyBand.D): 108, # (80, 71)
(104, BoilerEfficiencyBand.E): 103, # (74, 65)
(104, BoilerEfficiencyBand.F): 107, # (70, 61)
(104, BoilerEfficiencyBand.G): 118, # (66, 57)
# Oil boiler, regular (base 127) — ⚠ E/F nearest-fit to code 125
(127, BoilerEfficiencyBand.C): 127, # (84, 72)
(127, BoilerEfficiencyBand.D): 126, # (80, 68)
(127, BoilerEfficiencyBand.E): 125, # (71, 59) ⚠
(127, BoilerEfficiencyBand.F): 125, # (71, 59)
(127, BoilerEfficiencyBand.G): 124, # (66, 54)
# Oil boiler, combi (base 130) — ⚠ D/E to 129, F/G to 128
(130, BoilerEfficiencyBand.C): 130, # (82, 73)
(130, BoilerEfficiencyBand.D): 129, # (77, 68)
(130, BoilerEfficiencyBand.E): 129, # (77, 68) ⚠
(130, BoilerEfficiencyBand.F): 128, # (71, 62)
(130, BoilerEfficiencyBand.G): 128, # (71, 62) ⚠
}
# A/B exceed Table 4b's 84 % gas / 8284 % oil ceiling, so they cannot borrow a
# Table 4b code — they take SEDBUK-derived `(winter, summer)` slot values that
# MUST be validated against an accredited Elmhurst build before they are enabled
# (ADR-0068 gated pre-req; ~14.6k A-rated homes move up + ripple into
# Recommendations / eligibility). PENDING VALIDATION: intentionally empty — until
# the pairs are pinned, an A/B boiler takes no slot and keeps its condensing code
# default, so no invented numbers ship.
_BAND_SLOT_EFFICIENCY_PCT: dict[tuple[int, BoilerEfficiencyBand], tuple[float, float]] = {}
def band_seasonal_efficiency_pct(
base_code: int, band: BoilerEfficiencyBand
) -> Optional[tuple[float, float]]:
"""The SEDBUK-band `(winter, summer)` seasonal efficiency (percent) for a
resolved boiler `base_code`, or ``None`` when the band does not apply a
non-banded boiler (solid fuel / electric / CPSU), an A/B band still pending
Elmhurst validation, or `UNKNOWN`. C-and-below resolve to the accredited
Table 4b pair of the code the band maps to (ADR-0068)."""
slot = _BAND_SLOT_EFFICIENCY_PCT.get((base_code, band))
if slot is not None:
return slot
table_4b_code = _BAND_TO_TABLE_4B_CODE.get((base_code, band))
if table_4b_code is None:
return None
return table_4b_seasonal_efficiencies_pct(table_4b_code)
# 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
@ -408,11 +482,27 @@ def _natural_fuel_for(code: int) -> Optional[int]:
return None
def _gas_boiler_overlay(code: int) -> HeatingOverlay:
def _band_slot(
code: int, band: Optional[BoilerEfficiencyBand]
) -> Optional[tuple[float, float]]:
"""The `(winter, summer)` efficiency slot the Boiler Efficiency Band forces
on a resolved boiler `code`, or ``None`` when no band applies (ADR-0068)."""
if band is None:
return None
return band_seasonal_efficiency_pct(code, band)
def _gas_boiler_overlay(
code: int, band: Optional[BoilerEfficiencyBand] = None
) -> 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)."""
main system (a combi has no cylinder; a regular boiler / CPSU keeps one).
The SEDBUK band (when supplied and applicable) sets the `(winter, summer)`
efficiency slot the calculator consumes ahead of the code's Table 4b default
the fifth Heating Companion (ADR-0068)."""
return HeatingOverlay(
sap_main_heating_code=code,
main_heating_category=_GAS_BOILER_CATEGORY,
@ -424,10 +514,13 @@ def _gas_boiler_overlay(code: int) -> HeatingOverlay:
water_heating_code=_FROM_MAIN_WATER_HEATING_CODE,
water_heating_fuel=_MAINS_GAS_FUEL,
has_hot_water_cylinder=code not in _COMBI_CODES,
seasonal_efficiency_override_pct=_band_slot(code, band),
)
def _fuel_boiler_overlay(code: int) -> HeatingOverlay:
def _fuel_boiler_overlay(
code: int, band: Optional[BoilerEfficiencyBand] = None
) -> HeatingOverlay:
"""The coherent companion set for a non-gas wet boiler (oil / solid fuel),
modelled on the gas-boiler pattern (ADR-0067): SAP Table 4a category 2, full
modern controls (Table 4e 2106), a single-rate meter, hot water from the main
@ -438,7 +531,11 @@ def _fuel_boiler_overlay(code: int) -> HeatingOverlay:
carbon); `gas_connection_available` is left UNSET (None) so it inherits the
lodged EPC an oil/solid boiler needs no gas main, unlike a gas boiler which
forces it True; and a solid-fuel boiler vents through a conventional
(non-fanned) flue."""
(non-fanned) flue.
The SEDBUK band applies only to the oil boilers here (`band_seasonal_
efficiency_pct` returns None for the solid-fuel code 151, which is intrinsic-
efficiency) the fifth Heating Companion (ADR-0068)."""
fuel = _natural_fuel_for(code)
return HeatingOverlay(
sap_main_heating_code=code,
@ -450,6 +547,7 @@ def _fuel_boiler_overlay(code: int) -> HeatingOverlay:
water_heating_code=_FROM_MAIN_WATER_HEATING_CODE,
water_heating_fuel=fuel,
has_hot_water_cylinder=code not in _OIL_BOILER_COMBI_CODES,
seasonal_efficiency_override_pct=_band_slot(code, band),
)
@ -464,15 +562,17 @@ def natural_fuel_for(main_heating_value: str) -> Optional[int]:
def main_heating_overlay_for(
main_heating_value: str, building_part: int
main_heating_value: str,
building_part: int,
band: Optional[BoilerEfficiencyBand] = None,
) -> 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=_gas_boiler_overlay(code, band))
if code in _FUEL_BOILER_CODES:
return EpcSimulation(heating=_fuel_boiler_overlay(code))
return EpcSimulation(heating=_fuel_boiler_overlay(code, band))
category = _category_for(code)
control = _control_for(code)
if category is None or control is None:

View file

@ -137,6 +137,7 @@ _MAIN_HEATING_FIELDS: tuple[str, ...] = (
"sap_main_heating_code",
"main_heating_index_number",
"main_heating_category",
"seasonal_efficiency_override_pct",
"fan_flue_present",
"boiler_flue_type",
)

View file

@ -168,6 +168,12 @@ class HeatingOverlay:
sap_main_heating_code: Optional[int] = None
main_heating_index_number: Optional[int] = None
main_heating_category: Optional[int] = None
# The boiler's SEDBUK-band `(winter, summer)` seasonal efficiency (percent),
# the fifth Heating Companion (ADR-0068). Set by the main-heating overlay from
# the Boiler Efficiency Band for a gas/oil boiler; the calculator consumes it
# ahead of the Table 4b code default (the slot the PCDB `winter_efficiency_pct`
# already occupies). `None` leaves the code's Table 4b efficiency in force.
seasonal_efficiency_override_pct: Optional[tuple[float, float]] = 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).