From 4244b16d17360903706dd937477fb4aa8fd4f1f1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:10:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(overlay):=20GREEN=20=E2=80=94=20band=20set?= =?UTF-8?q?s=20a=20(winter,summer)=20efficiency=20slot=20on=20gas/oil=20bo?= =?UTF-8?q?ilers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- datatypes/epc/domain/epc_property_data.py | 6 + .../main_heating_system_overlay.py | 126 ++++++++++++++++-- .../modelling/scoring/overlay_applicator.py | 1 + domain/modelling/simulation.py | 6 + 4 files changed, 126 insertions(+), 13 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index a51a843cf..e2d2906b0 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 651177065..8d631b63d 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -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 A–G 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 / 82–84 % 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: diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 1a9357033..5885d0eac 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -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", ) diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 55c52855e..cf0d33aa7 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -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).