From 4844d030d7aba7948374da45c424ca108acc8a18 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:03:14 +0000 Subject: [PATCH 01/22] =?UTF-8?q?test(overrides):=20RED=20=E2=80=94=20Boil?= =?UTF-8?q?erEfficiencyBand=20enum=20+=20SEDBUK=20band=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boiler Efficiency Band is the 5th Heating Companion (ADR-0068). The shared parser extracts the SEDBUK A-G band from the Landlord 'Heating' description (`Boiler: rated ...`), primary-system-1 on multi-system cells, None when absent. Single source of truth for read/backfill/classifier. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_boiler_efficiency_band_guard.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/domain/epc/test_boiler_efficiency_band_guard.py diff --git a/tests/domain/epc/test_boiler_efficiency_band_guard.py b/tests/domain/epc/test_boiler_efficiency_band_guard.py new file mode 100644 index 000000000..a8a6de7e1 --- /dev/null +++ b/tests/domain/epc/test_boiler_efficiency_band_guard.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand +from domain.epc.property_overrides.boiler_efficiency_band_guard import ( + boiler_efficiency_band_guard, +) + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + # The standard Landlord "Heating" column phrasing (portfolio 796): + # `Boiler: rated `. + ("Boiler: A rated Combi", BoilerEfficiencyBand.A), + ("Boiler: B rated Combi", BoilerEfficiencyBand.B), + ("Boiler: C rated Regular Boiler", BoilerEfficiencyBand.C), + ("Boiler: D rated Regular Boiler", BoilerEfficiencyBand.D), + ("Boiler: E rated Combi", BoilerEfficiencyBand.E), + ("Boiler: F rated Regular Boiler", BoilerEfficiencyBand.F), + ("Boiler: G rated Regular Boiler", BoilerEfficiencyBand.G), + # Band present even when the boiler *type* is "NA" (an electric boiler + # the fuel resolution reclassifies — the band letter is still parseable). + ("Boiler: A rated NA", BoilerEfficiencyBand.A), + # Case-insensitive (the backfill lowercases the stored description). + ("boiler: c rated combi", BoilerEfficiencyBand.C), + ], +) +def test_guard_parses_the_sedbuk_band( + description: str, expected: BoilerEfficiencyBand +) -> None: + assert boiler_efficiency_band_guard(description) is expected + + +def test_guard_takes_the_primary_system_1_band_on_a_multi_system_cell() -> None: + # `System 2:` marks the SECOND system; the primary (system 1) band leads the + # cell, so a dual-system boiler is scored on its own band, not system 2's. + description = "Boiler: A rated Combi, System 2: Boiler: C rated Combi" + + assert boiler_efficiency_band_guard(description) is BoilerEfficiencyBand.A + + +@pytest.mark.parametrize( + "description", + [ + # No band letter — a plain boiler, or a non-boiler heating system. + "Gas boiler", + "Community Heating Systems: Community boilers only (RdSAP)", + "Solid fuel room heaters: Open fire with back boiler (no radiators)", + "", + # A stray letter that isn't a SEDBUK band phrasing must not match. + "Boiler: H rated Combi", + "A rated", + ], +) +def test_guard_returns_none_when_no_band_is_present(description: str) -> None: + # None (not UNKNOWN): absence of a band is modelled as no value, so the + # boiler keeps its Table 4b code default and no band override row is stored. + assert boiler_efficiency_band_guard(description) is None From b41c25e756cb1e15a6ee3b7de5a76828c89b0cf3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:04:06 +0000 Subject: [PATCH 02/22] =?UTF-8?q?feat(overrides):=20GREEN=20=E2=80=94=20Bo?= =?UTF-8?q?ilerEfficiencyBand=20enum=20+=20SEDBUK=20band=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoilerEfficiencyBand (A-G + UNKNOWN sentinel) and boiler_efficiency_band_guard, the shared deterministic parser for the fifth Heating Companion (ADR-0068). UNKNOWN is never persisted; absence of a band -> no value -> Table 4b default. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../boiler_efficiency_band.py | 35 +++++++++++++++++ .../boiler_efficiency_band_guard.py | 39 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 domain/epc/property_overrides/boiler_efficiency_band.py create mode 100644 domain/epc/property_overrides/boiler_efficiency_band_guard.py diff --git a/domain/epc/property_overrides/boiler_efficiency_band.py b/domain/epc/property_overrides/boiler_efficiency_band.py new file mode 100644 index 000000000..1031745a7 --- /dev/null +++ b/domain/epc/property_overrides/boiler_efficiency_band.py @@ -0,0 +1,35 @@ +from enum import Enum + + +class BoilerEfficiencyBand(Enum): + """The SEDBUK A-G efficiency band a landlord carries in the Boiler Landlord + Description (``Boiler: C rated Combi``) — the fifth Heating Companion + (ADR-0068). + + An *attribute* of a ``main_heating_system`` override, orthogonal to the + ``MainHeatingSystemType`` archetype: the archetype names the *system*, the + band names *how well it burns*. It is deliberately NOT a per-band archetype + — that enum is FE-owned ([[main-heating-system-pgenum-is-fe-owned]]), and 7 + bands x N boiler types would explode a cross-team contract. + + Each band resolves (via the main-heating overlay) to a ``(winter, summer)`` + seasonal-efficiency pair the calculator consumes ahead of the Table 4b code + default — applied to the SEDBUK-rated wet boilers only (gas/LPG/biogas and + oil); solid-fuel / electric / CPSU boilers carry an intrinsic fixed + efficiency and take no band. + + ``UNKNOWN`` is the classifier's "no band recognised" sentinel (a plain + boiler, or a non-boiler heating system). It is never persisted as an + override — absence of a band is modelled as *no value*, so the boiler keeps + its Table 4b code default — mirroring how the finaliser drops the other + ``UNKNOWN`` classifier outputs. + """ + + A = "A" + B = "B" + C = "C" + D = "D" + E = "E" + F = "F" + G = "G" + UNKNOWN = "Unknown" diff --git a/domain/epc/property_overrides/boiler_efficiency_band_guard.py b/domain/epc/property_overrides/boiler_efficiency_band_guard.py new file mode 100644 index 000000000..011eb5bd9 --- /dev/null +++ b/domain/epc/property_overrides/boiler_efficiency_band_guard.py @@ -0,0 +1,39 @@ +"""Parse the SEDBUK A-G efficiency band out of a Landlord "Heating" description. + +The single source of truth for the band, shared by three call sites so they +cannot drift (mirroring how ``main_heating_guard`` backs both the live +classifier path and ``reclassify_main_heating``): + +* the modelling read path (``overlays_from`` → the main-heating overlay slot), +* the ``boiler_efficiency_band`` backfill, +* the live classifier column (as its deterministic guard). + +The Landlord "Heating" column carries the band as ``Boiler: rated +``. A cell can name a second system after ``System 2:``; +the primary (system 1) band leads the cell, so the first ``Boiler: +rated`` occurrence is the one that scores the dwelling's main system. A +description with no band phrasing (a plain boiler, or a non-boiler heating +system) returns ``None`` — absence of a band, not a guess. +""" + +from __future__ import annotations + +import re +from typing import Optional + +from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand + +# `Boiler: rated` — the SEDBUK band phrasing. Case-insensitive (the +# backfill lowercases the stored description). `\b` after the letter rejects a +# stray "H rated" or a multi-letter token; the leading "Boiler:" anchors it so a +# bare "A rated" elsewhere in free text cannot match. +_BAND_RE = re.compile(r"boiler:\s*([a-g])\s+rated\b", re.IGNORECASE) + + +def boiler_efficiency_band_guard(description: str) -> Optional[BoilerEfficiencyBand]: + """The SEDBUK band of the primary (system 1) boiler in a Landlord "Heating" + description, or ``None`` when the description carries no band phrasing.""" + match = _BAND_RE.search(description) + if match is None: + return None + return BoilerEfficiencyBand(match.group(1).upper()) From 7a35d177b2b1de69a43e21606556f4a06f23ac12 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:06:43 +0000 Subject: [PATCH 03/22] =?UTF-8?q?test(overlay):=20RED=20=E2=80=94=20band?= =?UTF-8?q?=20->=20(winter,summer)=20slot=20on=20gas/oil=20boiler=20overla?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-and-below reuse accredited Table 4b pairs; A/B pending Elmhurst (no slot yet, no invented numbers); non-banded boilers (solid/electric/CPSU) ignore the band. The base code is unchanged; only the efficiency slot is set (ADR-0068). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_boiler_efficiency_band_overlay.py | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/domain/epc/test_boiler_efficiency_band_overlay.py diff --git a/tests/domain/epc/test_boiler_efficiency_band_overlay.py b/tests/domain/epc/test_boiler_efficiency_band_overlay.py new file mode 100644 index 000000000..bc4dd7b72 --- /dev/null +++ b/tests/domain/epc/test_boiler_efficiency_band_overlay.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Optional + +import pytest + +from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand +from domain.epc.property_overlays.main_heating_system_overlay import ( + band_seasonal_efficiency_pct, + main_heating_overlay_for, +) + + +@pytest.mark.parametrize( + ("base_code", "band", "expected"), + [ + # Gas regular (base 102) — C-and-below reuse the accredited Table 4b + # (winter, summer) of the code the band maps to (ADR-0068). + (102, BoilerEfficiencyBand.C, (84.0, 74.0)), # code 102 + (102, BoilerEfficiencyBand.D, (80.0, 70.0)), # code 106 + (102, BoilerEfficiencyBand.E, (74.0, 64.0)), # code 101 + (102, BoilerEfficiencyBand.F, (70.0, 60.0)), # code 105 + (102, BoilerEfficiencyBand.G, (66.0, 56.0)), # code 115 + # Gas combi (base 104). + (104, BoilerEfficiencyBand.C, (84.0, 75.0)), # code 104 + (104, BoilerEfficiencyBand.D, (80.0, 71.0)), # code 108 + (104, BoilerEfficiencyBand.G, (66.0, 57.0)), # code 118 + # Oil regular (base 127) — E/F nearest-fit to code 125 (⚠ ADR-0068). + (127, BoilerEfficiencyBand.C, (84.0, 72.0)), # code 127 + (127, BoilerEfficiencyBand.D, (80.0, 68.0)), # code 126 + (127, BoilerEfficiencyBand.E, (71.0, 59.0)), # code 125 + (127, BoilerEfficiencyBand.G, (66.0, 54.0)), # code 124 + # Oil combi (base 130) — D/E to code 129, F/G to code 128 (⚠ ADR-0068). + (130, BoilerEfficiencyBand.C, (82.0, 73.0)), # code 130 + (130, BoilerEfficiencyBand.D, (77.0, 68.0)), # code 129 + (130, BoilerEfficiencyBand.F, (71.0, 62.0)), # code 128 + ], +) +def test_c_and_below_bands_resolve_to_the_table_4b_pair( + base_code: int, band: BoilerEfficiencyBand, expected: tuple[float, float] +) -> None: + assert band_seasonal_efficiency_pct(base_code, band) == expected + + +@pytest.mark.parametrize("band", [BoilerEfficiencyBand.A, BoilerEfficiencyBand.B]) +@pytest.mark.parametrize("base_code", [102, 104, 127, 130]) +def test_a_and_b_bands_are_pending_elmhurst_validation( + base_code: int, band: BoilerEfficiencyBand +) -> None: + # A/B exceed Table 4b's 84% ceiling — they are judgement values that MUST be + # pinned against an accredited Elmhurst build before they are enabled + # (ADR-0068 gated pre-req). Until then: no slot, so an A/B boiler keeps its + # condensing code default (no invented numbers ship). + assert band_seasonal_efficiency_pct(base_code, band) is None + + +@pytest.mark.parametrize( + "base_code", + [ + 151, # Solid fuel boiler — Table 4a 75%, intrinsic (SEDBUK doesn't rate it) + 191, # Electric boiler — Table 4a 100%, intrinsic + 192, # Electric CPSU — intrinsic electric + 120, # Gas CPSU — non-condensing, not SEDBUK-banded + ], +) +@pytest.mark.parametrize("band", list(BoilerEfficiencyBand)) +def test_non_banded_boilers_never_take_a_slot( + base_code: int, band: BoilerEfficiencyBand +) -> None: + assert band_seasonal_efficiency_pct(base_code, band) is None + + +@pytest.mark.parametrize( + ("archetype", "band", "expected"), + [ + ("Gas boiler, regular", BoilerEfficiencyBand.D, (80.0, 70.0)), + ("Gas boiler, combi", BoilerEfficiencyBand.G, (66.0, 57.0)), + ("Oil boiler, regular", BoilerEfficiencyBand.E, (71.0, 59.0)), + ("Oil boiler, combi", BoilerEfficiencyBand.D, (77.0, 68.0)), + ], +) +def test_overlay_sets_the_band_efficiency_slot_on_gas_and_oil_boilers( + archetype: str, band: BoilerEfficiencyBand, expected: tuple[float, float] +) -> None: + simulation = main_heating_overlay_for(archetype, 0, band) + + assert simulation is not None + assert simulation.heating is not None + # The base code is unchanged (it still drives combi/regular HW behaviour); + # only the efficiency slot is set from the band. + assert simulation.heating.seasonal_efficiency_override_pct == expected + + +@pytest.mark.parametrize( + ("archetype", "band"), + [ + # No band → no slot (today's behaviour: keep the condensing code default). + ("Gas boiler, combi", None), + # A/B pending Elmhurst → no slot yet. + ("Gas boiler, combi", BoilerEfficiencyBand.A), + # C on gas regular equals the code default — still set, but a no-op value. + # Non-banded boilers ignore the band letter entirely. + ("Electric boiler", BoilerEfficiencyBand.D), + ("Solid fuel boiler", BoilerEfficiencyBand.G), + ("Gas CPSU", BoilerEfficiencyBand.D), + ], +) +def test_overlay_sets_no_slot_when_band_absent_or_not_applicable( + archetype: str, band: Optional[BoilerEfficiencyBand] +) -> None: + simulation = main_heating_overlay_for(archetype, 0, band) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.seasonal_efficiency_override_pct is None + + +def test_overlay_defaults_to_no_band_when_not_supplied() -> None: + # Back-compat: the band arg is optional; existing callers pass none. + simulation = main_heating_overlay_for("Gas boiler, combi", 0) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.seasonal_efficiency_override_pct is None From 4244b16d17360903706dd937477fb4aa8fd4f1f1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:10:07 +0000 Subject: [PATCH 04/22] =?UTF-8?q?feat(overlay):=20GREEN=20=E2=80=94=20band?= =?UTF-8?q?=20sets=20a=20(winter,summer)=20efficiency=20slot=20on=20gas/oi?= =?UTF-8?q?l=20boilers?= 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). From be0fb08e6552e8ae60549987b7788b9c04c32694 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:13:39 +0000 Subject: [PATCH 05/22] =?UTF-8?q?test(calculator):=20RED=20=E2=80=94=20ove?= =?UTF-8?q?rlay=20(winter,summer)=20slot=20wins=20over=20Table=204b=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The override-supplied seasonal efficiency must take precedence in both the §206 winter path (main_heating_efficiency) and the Appendix D Eq D1 summer water path, the same precedence slot the PCDB winter_efficiency_pct occupies (ADR-0068). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rdsap/test_cert_to_inputs.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index d20230c18..adf75734d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -2885,6 +2885,65 @@ def test_living_area_rounds_half_up_at_2_dp_decimal_boundary_per_rdsap_15() -> N assert abs(fla_off_boundary - (14.06 / 46.87)) <= 1e-12 +def _epc_with_override_boiler( + base: object, seasonal_efficiency_override_pct: Optional[tuple[float, float]] +): + """A copy of the typical semi-detached cert whose primary gas boiler (Table 4b + code 102) carries a Landlord-Override `(winter, summer)` efficiency slot — the + fifth Heating Companion (ADR-0068).""" + import dataclasses + + typical = _typical_semi_detached_epc() + detail = dataclasses.replace( + _gas_boiler_detail(sap_main_heating_code=102), + seasonal_efficiency_override_pct=seasonal_efficiency_override_pct, + ) + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code="1", + sap_building_parts=typical.sap_building_parts, + sap_windows=typical.sap_windows, + sap_heating=make_sap_heating(main_heating_details=[detail]), + ) + + +def test_seasonal_efficiency_override_wins_over_the_table_4b_code_default() -> None: + # A gas boiler override forces the SEDBUK-band efficiency ahead of the Table + # 4b code default: a G-rated boiler on code 102 (84%) is modelled at 66%, not + # the condensing 84% — the fix at the heart of #1704 / ADR-0068. "Override + # wins": the band-derived efficiency replaces the code's, symmetric up or down. + epc = _epc_with_override_boiler(None, (66.0, 56.0)) # G band (winter, summer) + + inputs = cert_to_inputs(epc) + + assert inputs.main_heating_efficiency == 0.66 + + +def test_no_override_keeps_the_table_4b_code_efficiency() -> None: + # Absent the slot, the code default stands (today's behaviour) — the override + # is the only thing that moves it. + epc = _epc_with_override_boiler(None, None) + + inputs = cert_to_inputs(epc) + + assert inputs.main_heating_efficiency == 0.84 + + +def test_seasonal_efficiency_override_feeds_the_eq_d1_water_summer_efficiency() -> None: + # The slot is a `(winter, summer)` pair; the summer leg drives SAP Appendix D + # §D2.1 Eq D1 water-heating efficiency (WHC 901, from main). A lower-band + # boiler burns less efficiently in summer, so it needs MORE hot-water fuel — + # a direction check that the override reaches the Eq D1 branch, not just §206. + default_code = _epc_with_override_boiler(None, None) # code 102 → summer 74% + g_band = _epc_with_override_boiler(None, (66.0, 56.0)) # summer 56% + + hw_default = cert_to_inputs(default_code).hot_water_kwh_per_yr + hw_g_band = cert_to_inputs(g_band).hot_water_kwh_per_yr + + assert hw_g_band > hw_default + + def test_main_heating_efficiency_reads_sap_main_heating_code() -> None: # Arrange — Direction check: a gas combi (Table 4b code 102, 84% eff) # vs a non-condensing gas boiler (code 105, 70% eff) must show through From 72840cbdf796fa8918583aea63d634cd60e4c445 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:16:09 +0000 Subject: [PATCH 06/22] =?UTF-8?q?feat(calculator):=20GREEN=20=E2=80=94=20c?= =?UTF-8?q?onsume=20overlay=20(winter,summer)=20ahead=20of=20Table=204b=20?= =?UTF-8?q?code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _main_heating_detail_efficiency uses the override winter (§206) and the Eq D1 water branch uses the override (winter,summer), both ahead of the PCDB/Table 4b defaults — the same precedence the PCDB winter_efficiency_pct occupies. Guarded on WHC 901 + not-electric-immersion for the water leg. Override-supplied only, so the lodged-cert corpus is unmoved (ADR-0068). 256 calculator tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 3ec43e86b..03919fc8d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2112,7 +2112,18 @@ def _main_heating_detail_efficiency( if main is not None and main.main_heating_index_number is not None else None ) - if pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None: + override_pct = ( + main.seasonal_efficiency_override_pct if main is not None else None + ) + if override_pct is not None: + # Landlord-Override SEDBUK-band efficiency — the fifth Heating Companion + # (ADR-0068). "Override wins": a system-replacing override makes the cert + # efficiency stale, so its `(winter, summer)` slot takes precedence over + # the Table 4b code default, occupying the same slot the PCDB + # `winter_efficiency_pct` does. Override-supplied only (lodged certs never + # set it), so the corpus is unmoved. + eff = override_pct[0] / 100.0 + elif pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None: eff = pcdb_main.winter_efficiency_pct / 100.0 else: eff = seasonal_efficiency(main_code, main_category, main_fuel) @@ -8154,8 +8165,21 @@ def cert_to_inputs( # primary loss but ARE still eligible for Eq D1 — the spec's # §D2.1 (2) test is "summer < winter" + "boiler provides both", # not the primary-loss test. + override_pct = main.seasonal_efficiency_override_pct if main is not None else None eq_d1_winter_summer_pct: Optional[tuple[float, float]] = None if ( + override_pct is not None + and epc.sap_heating.water_heating_code == _WHC_FROM_MAIN_HEATING + # Not when DHW is a separate electric immersion (WHC 903): the boiler + # does not provide water heating, so its efficiency must not reach Eq D1. + and not dhw_is_electric_immersion + ): + # Landlord-Override SEDBUK-band `(winter, summer)` — the fifth Heating + # Companion (ADR-0068). It wins the Eq D1 water slot the same way it wins + # the §206 winter efficiency, ahead of the PCDB/Table 4b defaults, so a + # banded boiler's summer efficiency drives its hot-water fuel too. + eq_d1_winter_summer_pct = override_pct + elif ( pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None and pcdb_main.summer_efficiency_pct is not None From e91e714221f14a5c8b0a8aa573268461d036548d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:18:53 +0000 Subject: [PATCH 07/22] =?UTF-8?q?test(overrides):=20RED=20=E2=80=94=20thre?= =?UTF-8?q?ad=20the=20Boiler=20Efficiency=20Band=20to=20the=20overlay=20ca?= =?UTF-8?q?ll=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse-on-read from the main_heating_system row's original_spreadsheet_description (ships before FE); an explicit boiler_efficiency_band override row wins when present; band applied after fuel resolution (oil re-point); no band -> no slot. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_landlord_override_overlays.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py index e6e60b4e3..37bf4bc19 100644 --- a/tests/repositories/property/test_landlord_override_overlays.py +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -227,6 +227,92 @@ def test_main_heating_system_row_produces_a_heating_overlay() -> None: assert overlays[0].heating.sap_main_heating_code == 104 +def test_band_parsed_from_the_heating_description_sets_the_efficiency_slot() -> None: + # Ship-before-FE (parse-on-read): the SEDBUK band is already carried in the + # main_heating_system row's original_spreadsheet_description, so the modelling + # honours it without waiting for the new boiler_efficiency_band override row. + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", + 0, + "Gas boiler, regular", + original_spreadsheet_description="Boiler: G rated Regular Boiler", + ), + ) + ) + + overlays = overlays_from(overrides) + + assert len(overlays) == 1 + assert overlays[0].heating is not None + # G-rated gas regular → Table 4b (66, 56), not the condensing 84% default. + assert overlays[0].heating.seasonal_efficiency_override_pct == (66.0, 56.0) + + +def test_explicit_band_override_row_wins_over_the_parsed_description() -> None: + # Once the FE classifier writes a structured boiler_efficiency_band row, it is + # the authority — it wins over the on-read parse of the raw description. + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", + 0, + "Gas boiler, regular", + original_spreadsheet_description="Boiler: G rated Regular Boiler", + ), + ResolvedPropertyOverride("boiler_efficiency_band", 0, "D"), + ) + ) + + overlays = overlays_from(overrides) + + heating = next(o.heating for o in overlays if o.heating is not None) + # D wins over the description's G → Table 4b (80, 70). + assert heating.seasonal_efficiency_override_pct == (80.0, 70.0) + + +def test_band_repoints_with_the_resolved_fuel_for_an_oil_boiler() -> None: + # The band is applied AFTER fuel resolution (ADR-0067): a gas archetype on an + # oil main_fuel resolves to the oil boiler, and the band keys off the oil code. + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", + 0, + "Gas boiler, regular", + original_spreadsheet_description="Boiler: D rated Regular Boiler", + ), + ResolvedPropertyOverride("main_fuel", 0, "oil"), + ) + ) + + overlays = overlays_from(overrides) + + heating = next(o.heating for o in overlays if o.heating is not None) + assert heating.sap_main_heating_code == 127 # oil regular + assert heating.seasonal_efficiency_override_pct == (80.0, 68.0) # oil D → code 126 + + +def test_no_band_information_leaves_the_efficiency_slot_unset() -> None: + # A plain boiler description carries no band → no slot → condensing default. + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", + 0, + "Gas boiler, combi", + original_spreadsheet_description="Gas boiler", + ), + ) + ) + + overlays = overlays_from(overrides) + + heating = next(o.heating for o in overlays if o.heating is not None) + assert heating.seasonal_efficiency_override_pct is None + + def test_unresolvable_rows_are_skipped() -> None: # Arrange — an "Unknown" property type and an unmapped wall material. overrides = ResolvedPropertyOverrides( From ce803ecedc05204e49d61a3fae0e1c495b1ff530 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:21:14 +0000 Subject: [PATCH 08/22] =?UTF-8?q?feat(overrides):=20GREEN=20=E2=80=94=20th?= =?UTF-8?q?read=20Boiler=20Efficiency=20Band=20to=20the=20overlay=20call?= =?UTF-8?q?=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolvedPropertyOverride now carries original_spreadsheet_description (populated by both postgres readers). overlays_from builds the main-heating overlay directly via _main_heating_overlay, resolving the band: an explicit boiler_efficiency_band row wins, else parse-on-read from the heating description (ships before FE). Band applied after the ADR-0067 fuel re-point. 28 overlay tests green, pyright clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../property/landlord_override_overlays.py | 78 ++++++++++++++++--- .../property_overrides_postgres_reader.py | 2 + .../property/property_overrides_reader.py | 5 ++ 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index 21d9a8c1a..550d39f7b 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -44,13 +44,20 @@ from domain.epc.property_overlays.main_heating_system_overlay import ( main_heating_overlay_for, natural_fuel_for, ) +from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand +from domain.epc.property_overrides.boiler_efficiency_band_guard import ( + boiler_efficiency_band_guard, +) from domain.epc.property_overlays.water_heating_overlay import ( water_heating_overlay_for, ) from domain.epc.property_overlays.roof_type_overlay import roof_overlay_for from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for from domain.modelling.simulation import EpcSimulation -from repositories.property.property_overrides_reader import ResolvedPropertyOverrides +from repositories.property.property_overrides_reader import ( + ResolvedPropertyOverride, + ResolvedPropertyOverrides, +) logger = logging.getLogger(__name__) @@ -87,23 +94,70 @@ def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: overrides.rows, key=lambda row: row.override_component in _APPLY_LAST ) for row in ordered_rows: - mapper = _COMPONENT_OVERLAYS.get(row.override_component) - if mapper is None: - continue - value = row.override_value if row.override_component == "main_heating_system": - # A fuel-agnostic gas-boiler archetype re-points to the oil / solid-fuel - # member matching this property's `main_fuel`, so an oil/solid boiler - # scores its own code — not the gas default the LLM assigned (ADR-0067). - value = resolve_boiler_archetype( - value, _override_value(overrides, "main_fuel") - ) - overlay = mapper(value, row.building_part) + # The main-heating overlay carries two joins the generic mapper can't: + # the fuel re-point (ADR-0067) and the Boiler Efficiency Band slot + # (ADR-0068), so it is built directly rather than via _COMPONENT_OVERLAYS. + overlay = _main_heating_overlay(overrides, row) + else: + mapper = _COMPONENT_OVERLAYS.get(row.override_component) + if mapper is None: + # `boiler_efficiency_band` rows carry no standalone overlay — they + # are consumed by the main_heating_system row above — so they, and + # any other unmapped component, produce nothing here. + continue + overlay = mapper(row.override_value, row.building_part) if overlay is not None: overlays.append(overlay) return overlays +def _main_heating_overlay( + overrides: ResolvedPropertyOverrides, row: ResolvedPropertyOverride +) -> Optional[EpcSimulation]: + """The main-heating overlay for one row, with its two resolution-time joins: + the fuel re-point (a fuel-agnostic gas-boiler archetype → the oil/solid-fuel + member matching the property's `main_fuel`, ADR-0067) and the SEDBUK Boiler + Efficiency Band → `(winter, summer)` efficiency slot (ADR-0068).""" + value = resolve_boiler_archetype( + row.override_value, _override_value(overrides, "main_fuel") + ) + band = _boiler_efficiency_band_for(overrides, row) + return main_heating_overlay_for(value, row.building_part, band) + + +def _boiler_efficiency_band_for( + overrides: ResolvedPropertyOverrides, row: ResolvedPropertyOverride +) -> Optional[BoilerEfficiencyBand]: + """The SEDBUK band for a main-heating row: an explicit `boiler_efficiency_band` + override row for the same building part (the FE classifier's structured output) + wins; absent one, the band is parsed on-read from the row's + `original_spreadsheet_description` (`Boiler: C rated Combi`), so the band ships + before the FE-owned band override row exists. `None` when no band is present.""" + for other in overrides.rows: + if ( + other.override_component == "boiler_efficiency_band" + and other.building_part == row.building_part + ): + band = _band_from_override_value(other.override_value) + if band is not None: + return band + if row.original_spreadsheet_description is not None: + return boiler_efficiency_band_guard(row.original_spreadsheet_description) + return None + + +def _band_from_override_value(value: str) -> Optional[BoilerEfficiencyBand]: + """A stored `boiler_efficiency_band` override value → the enum member, or + `None` for an unrecognised value or the `UNKNOWN` sentinel (never persisted, + but treated as absent if it ever appears).""" + try: + band = BoilerEfficiencyBand(value) + except ValueError: + return None + return None if band is BoilerEfficiencyBand.UNKNOWN else band + + # Coarse fuel family per RdSAP `main_fuel` code (main_fuel_overlay._FUEL_CODES), # for the plausibility check. The natural fuel a solid-fuel archetype drags # (house coal) is a *default* across the ambiguous solid family, so a same-family diff --git a/repositories/property/property_overrides_postgres_reader.py b/repositories/property/property_overrides_postgres_reader.py index c3aa2b1c3..c4720b61f 100644 --- a/repositories/property/property_overrides_postgres_reader.py +++ b/repositories/property/property_overrides_postgres_reader.py @@ -40,6 +40,7 @@ def _resolve_overrides(session: Session, property_id: int) -> ResolvedPropertyOv override_component=row.override_component, building_part=row.building_part, override_value=row.override_value, + original_spreadsheet_description=row.original_spreadsheet_description, ) for row in rows ) @@ -65,6 +66,7 @@ def _resolve_overrides_many( override_component=row.override_component, building_part=row.building_part, override_value=row.override_value, + original_spreadsheet_description=row.original_spreadsheet_description, ) ) return { diff --git a/repositories/property/property_overrides_reader.py b/repositories/property/property_overrides_reader.py index 29e574db9..a3e5ee630 100644 --- a/repositories/property/property_overrides_reader.py +++ b/repositories/property/property_overrides_reader.py @@ -24,6 +24,11 @@ class ResolvedPropertyOverride: override_component: str building_part: int override_value: str + # The raw landlord cell the value resolved from. Carried so the modelling can + # parse the SEDBUK Boiler Efficiency Band (`Boiler: C rated Combi`) on-read + # (ADR-0068) — the band ships before the FE-owned boiler_efficiency_band + # override row exists. Optional so in-memory/test snapshots may omit it. + original_spreadsheet_description: Optional[str] = None @dataclass(frozen=True) From 291717ca8fd96ef18a84064019430f0d89d58e8b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:26:47 +0000 Subject: [PATCH 09/22] =?UTF-8?q?test(finaliser):=20RED=20=E2=80=94=20boil?= =?UTF-8?q?er=5Fefficiency=5Fband=20alongside=20heating;=20UNKNOWN=20band?= =?UTF-8?q?=20skips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band rides the Heating source column and yields its own override row; unlike the mandatory components an UNKNOWN band is a legitimate no-value and must be skipped, not fail the finalise (ADR-0068). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...test_bulk_upload_finaliser_orchestrator.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py b/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py index 335a3e919..3ce8b8dcc 100644 --- a/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py +++ b/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py @@ -232,6 +232,75 @@ def test_finalise_writes_overrides_for_uprn_rows_splitting_by_part() -> None: assert (prop_type.building_part, prop_type.override_value) == (0, "Semi-detached house") +def test_finalise_writes_the_boiler_efficiency_band_alongside_the_heating_system() -> None: + # The band rides the SAME "Heating" source column as main_heating_system + # (like Property Type feeds property_type + built_form), so a banded boiler + # cell yields two override rows: the archetype and its SEDBUK band (ADR-0068). + combiner = [{"address2uprn_uprn": "100023", "source_row_id": "row-a"}] + classifier = [{"Heating": "Boiler: D rated Regular Boiler", "source_row_id": "row-a"}] + vocab = { + "main_heating_system": {"boiler: d rated regular boiler": "Gas boiler, regular"}, + "boiler_efficiency_band": {"boiler: d rated regular boiler": "D"}, + } + orchestrator, overrides = _overrides_orchestrator({100023: 555}, vocab) + + orchestrator.finalise( + combiner, + portfolio_id=7, + task_id=uuid4(), + classifier_rows=classifier, + multi_entry_ordering={}, + column_mapping={ + "main_heating_system": "Heating", + "boiler_efficiency_band": "Heating", + }, + ) + + (band,) = [ + o for o in overrides.upserted if o.override_component == "boiler_efficiency_band" + ] + assert (band.building_part, band.override_value) == (0, "D") + + +def test_finalise_skips_an_unknown_boiler_efficiency_band_without_failing() -> None: + # A non-boiler heating system (or a plain boiler) carries no band, so the band + # classifier returns UNKNOWN. Unlike the mandatory components, an UNKNOWN band + # is a legitimate "no value" — skip it, don't fail the finalise (ADR-0068). + combiner = [{"address2uprn_uprn": "100023", "source_row_id": "row-a"}] + classifier = [ + {"Heating": "Community Heating Systems: Community boilers only (RdSAP)", + "source_row_id": "row-a"} + ] + vocab = { + "main_heating_system": { + "community heating systems: community boilers only (rdsap)": + "Community heating, boilers" + }, + "boiler_efficiency_band": { + "community heating systems: community boilers only (rdsap)": "Unknown" + }, + } + orchestrator, overrides = _overrides_orchestrator({100023: 555}, vocab) + + orchestrator.finalise( + combiner, + portfolio_id=7, + task_id=uuid4(), + classifier_rows=classifier, + multi_entry_ordering={}, + column_mapping={ + "main_heating_system": "Heating", + "boiler_efficiency_band": "Heating", + }, + ) + + # The heating system row is written; the UNKNOWN band produced no row. + assert any(o.override_component == "main_heating_system" for o in overrides.upserted) + assert not any( + o.override_component == "boiler_efficiency_band" for o in overrides.upserted + ) + + def test_finalise_fails_loudly_on_unresolved_description() -> None: combiner = [ {"address2uprn_uprn": "100023", "source_row_id": "row-a"}, From 6a4039c4860f2f31c0be104ea38099f1c4c0bd97 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:32:42 +0000 Subject: [PATCH 10/22] =?UTF-8?q?feat(classifier):=20GREEN=20=E2=80=94=20c?= =?UTF-8?q?lassify=20+=20finalise=20the=20Boiler=20Efficiency=20Band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New boiler_efficiency_band classifier column (handler.py) reading the shared Heating source header via the deterministic guard (LLM fallback -> UNKNOWN), cached in landlord_boiler_efficiency_band_overrides. override_component mirror + cache-table pgEnum are FE-owned (deferred/Class-A/B — no deploy until the Drizzle migration lands). Finaliser treats the band as an OPTIONAL component: UNKNOWN -> skip (no row), not fail-loud like the mandatory ones (ADR-0068). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../landlord_description_overrides/handler.py | 26 +++++++ ...d_boiler_efficiency_band_override_table.py | 71 +++++++++++++++++++ .../postgres/property_override_table.py | 4 ++ .../bulk_upload_finaliser_orchestrator.py | 17 +++++ 4 files changed, 118 insertions(+) create mode 100644 infrastructure/postgres/landlord_boiler_efficiency_band_override_table.py diff --git a/applications/landlord_description_overrides/handler.py b/applications/landlord_description_overrides/handler.py index 236b28894..12d49c78c 100644 --- a/applications/landlord_description_overrides/handler.py +++ b/applications/landlord_description_overrides/handler.py @@ -15,6 +15,10 @@ from domain.epc.property_overrides.main_fuel_type import MainFuelType from domain.epc.property_overrides.main_fuel_guard import main_fuel_guard from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType from domain.epc.property_overrides.main_heating_guard import main_heating_guard +from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand +from domain.epc.property_overrides.boiler_efficiency_band_guard import ( + boiler_efficiency_band_guard, +) from domain.epc.property_overrides.property_type import PropertyType from domain.epc.property_overrides.property_type_guard import property_type_guard from domain.epc.property_overrides.roof_type import RoofType @@ -50,6 +54,9 @@ from infrastructure.postgres.landlord_main_fuel_override_table import ( from infrastructure.postgres.landlord_main_heating_system_override_table import ( LandlordMainHeatingSystemOverrideRow, ) +from infrastructure.postgres.landlord_boiler_efficiency_band_override_table import ( + LandlordBoilerEfficiencyBandOverrideRow, +) from infrastructure.postgres.landlord_water_heating_override_table import ( LandlordWaterHeatingOverrideRow, ) @@ -222,6 +229,25 @@ def _build_columns( session, LandlordMainHeatingSystemOverrideRow ), ), + "boiler_efficiency_band": lambda src: ClassifiableColumn( + name="boiler_efficiency_band", + source_column=src, + # The SEDBUK band rides the SAME "Heating" source column as + # main_heating_system (like Property Type feeds property_type + + # built_form_type). The deterministic guard extracts the structured + # `Boiler: rated ...` band — authoritative for the format — and + # the LLM is the fallback for oddities, returning UNKNOWN (never + # stored) for a plain boiler or a non-boiler heating system (ADR-0068). + classifier=GuardedColumnClassifier( + guard=boiler_efficiency_band_guard, + fallback=ChatGptColumnClassifier( + chat_gpt, BoilerEfficiencyBand, BoilerEfficiencyBand.UNKNOWN + ), + ), + repo=LandlordOverridesRepository[BoilerEfficiencyBand]( + session, LandlordBoilerEfficiencyBandOverrideRow + ), + ), } columns: list[ClassifiableColumn[Any]] = [] diff --git a/infrastructure/postgres/landlord_boiler_efficiency_band_override_table.py b/infrastructure/postgres/landlord_boiler_efficiency_band_override_table.py new file mode 100644 index 000000000..10b6854f5 --- /dev/null +++ b/infrastructure/postgres/landlord_boiler_efficiency_band_override_table.py @@ -0,0 +1,71 @@ +"""SQLModel mirror of the ``landlord_boiler_efficiency_band_overrides`` table. + +The classifier cache for the SEDBUK Boiler Efficiency Band (ADR-0068): one +``(portfolio_id, description) -> A–G`` row per distinct Landlord "Heating" +description that carries a band, written ``source=classifier`` as a reviewed +cache — exactly like ``landlord_main_heating_system_overrides``. + +The schema source of truth lives in the ``assessment-model`` TS repo +(`src/app/db/schema/landlord_overrides.ts`); the migrations are owned there (the +``boiler_efficiency_band`` pgEnum is FE-owned, cf. +[[main-heating-system-pgenum-is-fe-owned]]). This class only mirrors the columns +so the Python lambda can read/write once that migration lands (deferred / the +Class-A/B pattern — no deploy until the FE type exists). Shape mirrors +``LandlordMainHeatingSystemOverrideRow``. +""" + +from datetime import datetime, timezone +from typing import ClassVar +from uuid import UUID, uuid4 + +from sqlalchemy import BigInteger, Column, UniqueConstraint +from sqlalchemy import Enum as SAEnum +from sqlmodel import Field, SQLModel + +from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand +from infrastructure.postgres.landlord_override_enums import override_source_sa_enum + + +class LandlordBoilerEfficiencyBandOverrideRow(SQLModel, table=True): + __tablename__: ClassVar[str] = "landlord_boiler_efficiency_band_overrides" # pyright: ignore[reportIncompatibleVariableOverride] + __table_args__: ClassVar[tuple[UniqueConstraint, ...]] = ( # pyright: ignore[reportIncompatibleVariableOverride] + # Shortened to stay within PostgreSQL's 63-char identifier limit; mirrors + # the Drizzle name. + UniqueConstraint( + "portfolio_id", + "description", + name="landlord_boiler_efficiency_band_portfolio_description_unique", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + + portfolio_id: int = Field( + sa_column=Column(BigInteger, nullable=False, index=True), + ) + + description: str = Field(nullable=False) + + value: BoilerEfficiencyBand = Field( + sa_column=Column( + SAEnum( + BoilerEfficiencyBand, + name="boiler_efficiency_band", + values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType] + ), + nullable=False, + ), + ) + + source: str = Field( + sa_column=Column(override_source_sa_enum, nullable=False), + ) + + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + nullable=False, + ) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + nullable=False, + ) diff --git a/infrastructure/postgres/property_override_table.py b/infrastructure/postgres/property_override_table.py index 404901443..f2344f39c 100644 --- a/infrastructure/postgres/property_override_table.py +++ b/infrastructure/postgres/property_override_table.py @@ -32,6 +32,10 @@ override_component_sa_enum = SAEnum( "construction_age_band", "water_heating", "main_heating_system", + # The SEDBUK Boiler Efficiency Band (ADR-0068) — a boiler *attribute* riding + # alongside main_heating_system, its value one of A–G. FE-owned pgEnum value + # (deferred / Class-A/B: no deploy until the Drizzle migration adds it). + "boiler_efficiency_band", name="override_component", ) diff --git a/orchestration/bulk_upload_finaliser_orchestrator.py b/orchestration/bulk_upload_finaliser_orchestrator.py index 1d707a8d7..5d0bd9568 100644 --- a/orchestration/bulk_upload_finaliser_orchestrator.py +++ b/orchestration/bulk_upload_finaliser_orchestrator.py @@ -61,6 +61,13 @@ UNKNOWN_VALUES = frozenset( } ) +# Override components that are OPTIONAL per cell: present on some, legitimately +# absent on others. An unresolved/UNKNOWN value is skipped (no row) rather than +# failing the finalise the way a mandatory component does. The Boiler Efficiency +# Band (ADR-0068) exists only for a SEDBUK-rated boiler, so a plain boiler or a +# non-boiler heating cell has none. +_OPTIONAL_COMPONENTS = frozenset({"boiler_efficiency_band"}) + def _split_entries(cell: Any) -> list[str]: """Split a multi-valued cell into per-building-part entries — mirrors the @@ -309,6 +316,16 @@ class BulkUploadFinaliserOrchestrator: for building_part, file_pos in enumerate(permutation): raw = entries[file_pos] value = component_vocab.get(raw.lower()) + if component in _OPTIONAL_COMPONENTS and ( + value is None or value in UNKNOWN_VALUES + ): + # An OPTIONAL override is present on some cells and absent + # on others by design — a Boiler Efficiency Band only + # exists for a SEDBUK-rated boiler (ADR-0068), so a plain + # boiler or a non-boiler heating cell resolves to UNKNOWN. + # That is a legitimate "no value": skip it (no row), don't + # fail the finalise the way a mandatory component does. + continue if value is None or value in UNKNOWN_VALUES: raise ValueError( f"Unresolved {component} description {raw!r} " From c690cd3183474b80b6eb8387e83df672d05931cb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:34:53 +0000 Subject: [PATCH 11/22] feat(backfill): boiler_efficiency_band rows from existing heating descriptions Idempotent, dry-run-by-default script (mirrors reclassify_main_heating). Parses the SEDBUK band off each main_heating_system row's original_spreadsheet_description with the SAME guard as the live classifier (no drift), upserting a boiler_efficiency_band row per boiler that carries one. Pure core band_backfill_rows unit-tested. FE-enum-gated writes (Class-A/B deferred) (ADR-0068). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/backfill_boiler_efficiency_band.py | 175 ++++++++++++++++++ .../test_backfill_boiler_efficiency_band.py | 54 ++++++ 2 files changed, 229 insertions(+) create mode 100644 scripts/backfill_boiler_efficiency_band.py create mode 100644 tests/scripts/test_backfill_boiler_efficiency_band.py diff --git a/scripts/backfill_boiler_efficiency_band.py b/scripts/backfill_boiler_efficiency_band.py new file mode 100644 index 000000000..2d95309dd --- /dev/null +++ b/scripts/backfill_boiler_efficiency_band.py @@ -0,0 +1,175 @@ +"""Backfill the ``boiler_efficiency_band`` override for already-ingested boilers. + +The SEDBUK band (ADR-0068) is losslessly present in every ``main_heating_system`` +override's ``original_spreadsheet_description`` (``Boiler: C rated Combi``) but was +dropped at classification. This one-off populates the new ``boiler_efficiency_band`` +override rows for the existing stock by re-parsing that text with the SAME guard the +live classifier uses (``boiler_efficiency_band_guard``), so the backfill and the +forward path cannot drift. + +One ``boiler_efficiency_band`` row per ``main_heating_system`` row whose description +carries a band (``A``–``G``), keyed to the same ``(property_id, building_part)``. +Descriptions with no band (a plain boiler, a non-boiler heating system) get no row. +The modelling gate (gas/oil boilers only, ADR-0068) is applied at overlay time, not +here — so a band parsed off an electric ``Boiler: A rated NA`` is stored for +fidelity but ignored by the calculator, exactly as the live classifier stores it. + +GATED: the ``boiler_efficiency_band`` value of the FE-owned ``override_component`` +pgEnum must exist first (the Drizzle migration in the assessment-model repo). Until +then this writes nothing in prod — the Class-A/B deferred pattern. + +DRY-RUN BY DEFAULT: prints the counts it would write and writes nothing. Pass +``--apply`` to execute inside a transaction. Idempotent — a row already carrying the +target band is left untouched, so re-running is a no-op. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable +from dataclasses import dataclass + +from sqlalchemy import Connection, text + +from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand +from domain.epc.property_overrides.boiler_efficiency_band_guard import ( + boiler_efficiency_band_guard, +) +from scripts.e2e_common import build_engine, load_env + +_OVERRIDE_COMPONENT = "boiler_efficiency_band" + + +@dataclass(frozen=True) +class MainHeatingRow: + """The fields of a ``main_heating_system`` ``property_overrides`` row the + backfill reads.""" + + property_id: int + portfolio_id: int + building_part: int + original_spreadsheet_description: str + + +@dataclass(frozen=True) +class BandBackfillRow: + """A ``boiler_efficiency_band`` override row to upsert for a boiler.""" + + property_id: int + portfolio_id: int + building_part: int + override_value: str + original_spreadsheet_description: str + + +def band_backfill_rows(rows: Iterable[MainHeatingRow]) -> list[BandBackfillRow]: + """The ``boiler_efficiency_band`` rows to write for a set of + ``main_heating_system`` rows — one per row whose description carries a + parseable SEDBUK band, keyed to the same property + building part. Rows with + no band (or the ``UNKNOWN`` sentinel) produce nothing. + + Pure and DB-free, so the parse/decide logic is unit-tested without a + database; the connection wrapper below just feeds it rows and upserts.""" + backfill: list[BandBackfillRow] = [] + for row in rows: + band = boiler_efficiency_band_guard(row.original_spreadsheet_description) + if band is None or band is BoilerEfficiencyBand.UNKNOWN: + continue + backfill.append( + BandBackfillRow( + property_id=row.property_id, + portfolio_id=row.portfolio_id, + building_part=row.building_part, + override_value=band.value, + original_spreadsheet_description=row.original_spreadsheet_description, + ) + ) + return backfill + + +_SELECT_MAIN_HEATING = text( + """ + SELECT property_id, portfolio_id, building_part, original_spreadsheet_description + FROM property_overrides + WHERE override_component = 'main_heating_system' + """ +) +# Idempotent upsert on the (property, component, part) unique constraint — a row +# already carrying the target band is left untouched (updated_at unchanged). +_UPSERT_BAND = text( + """ + INSERT INTO property_overrides + (id, property_id, portfolio_id, building_part, override_component, + override_value, original_spreadsheet_description, created_at, updated_at) + VALUES + (gen_random_uuid(), :property_id, :portfolio_id, :building_part, + 'boiler_efficiency_band', :override_value, :original_spreadsheet_description, + now(), now()) + ON CONFLICT (property_id, override_component, building_part) + DO UPDATE SET override_value = EXCLUDED.override_value, + original_spreadsheet_description = + EXCLUDED.original_spreadsheet_description, + updated_at = now() + WHERE property_overrides.override_value <> EXCLUDED.override_value + """ +) + + +def backfill(conn: Connection, *, apply: bool) -> int: + """Backfill the ``boiler_efficiency_band`` rows off the existing + ``main_heating_system`` descriptions. Returns the number of band rows the run + writes (or would write, in dry-run).""" + rows = [ + MainHeatingRow( + property_id=r.property_id, + portfolio_id=r.portfolio_id, + building_part=r.building_part, + original_spreadsheet_description=r.original_spreadsheet_description or "", + ) + for r in conn.execute(_SELECT_MAIN_HEATING) + ] + to_write = band_backfill_rows(rows) + if apply: + for band_row in to_write: + conn.execute( + _UPSERT_BAND, + { + "property_id": band_row.property_id, + "portfolio_id": band_row.portfolio_id, + "building_part": band_row.building_part, + "override_value": band_row.override_value, + "original_spreadsheet_description": ( + band_row.original_spreadsheet_description + ), + }, + ) + return len(to_write) + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--apply", + action="store_true", + help="execute the writes (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + engine = build_engine() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + total = backfill(conn, apply=args.apply) + + verb = "backfilled" if args.apply else "would backfill" + print( + f"{verb} {total} boiler_efficiency_band override row(s) from the existing " + "main_heating_system descriptions (Boiler: rated ...). The modelling " + "gate (gas/oil boilers only) is applied at overlay time (ADR-0068)." + ) + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/tests/scripts/test_backfill_boiler_efficiency_band.py b/tests/scripts/test_backfill_boiler_efficiency_band.py new file mode 100644 index 000000000..c94220201 --- /dev/null +++ b/tests/scripts/test_backfill_boiler_efficiency_band.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from scripts.backfill_boiler_efficiency_band import ( + BandBackfillRow, + MainHeatingRow, + band_backfill_rows, +) + + +def _row(desc: str, *, property_id: int = 1, building_part: int = 0) -> MainHeatingRow: + return MainHeatingRow( + property_id=property_id, + portfolio_id=796, + building_part=building_part, + original_spreadsheet_description=desc, + ) + + +def test_backfills_a_band_row_per_boiler_with_a_parseable_band() -> None: + rows = [ + _row("Boiler: D rated Regular Boiler", property_id=10), + _row("Boiler: G rated Combi", property_id=11, building_part=1), + ] + + result = band_backfill_rows(rows) + + assert result == [ + BandBackfillRow(10, 796, 0, "D", "Boiler: D rated Regular Boiler"), + BandBackfillRow(11, 796, 1, "G", "Boiler: G rated Combi"), + ] + + +def test_leaves_descriptions_without_a_band_untouched() -> None: + rows = [ + _row("Gas boiler"), + _row("Community Heating Systems: Community boilers only (RdSAP)"), + _row(""), + ] + + assert band_backfill_rows(rows) == [] + + +def test_uses_the_same_guard_as_the_live_path_for_multi_system_and_electric() -> None: + rows = [ + # Multi-system: primary (system 1) band wins — matches the guard. + _row("Boiler: A rated Combi, System 2: Boiler: C rated Combi", property_id=20), + # Band letter on an electric "NA" boiler is stored for fidelity; the + # gas/oil modelling gate ignores it at overlay time (ADR-0068). + _row("Boiler: A rated NA", property_id=21), + ] + + result = band_backfill_rows(rows) + + assert [(r.property_id, r.override_value) for r in result] == [(20, "A"), (21, "A")] From c35fb0afb9272ef89e4e965ba12d73534cc2f209 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:40:50 +0000 Subject: [PATCH 12/22] docs(adr): ADR-0068 accepted+implemented; CONTEXT Boiler Efficiency Band glossary Folds the design docs (previously PR #1705, docs-only) into the implementation PR so it is self-contained: ADR-0068 status -> accepted/implemented, and the CONTEXT glossary gains the Boiler Efficiency Band term + the fifth-Heating-Companion note. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 8 +- ...ncy-band-is-the-fifth-heating-companion.md | 157 ++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md diff --git a/CONTEXT.md b/CONTEXT.md index 01623858a..85e65f000 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -159,8 +159,12 @@ _Avoid_: roof warning (unspecific), re-roof recommendation (there is no engine m Resolving a **Landlord Description** (unbounded free-text a landlord supplies for one component — "CWI" / "Cav filled" / "cavity insulated" all name one thing) onto a **Recognised Internal Description** via an LLM classifier, persisted in the `landlord_*_overrides` table (`source=classifier`) as a reviewed cache. Four vocabularies are kept **distinct** and must not be conflated: a **Landlord Description** (unbounded input); a **Recognised Internal Description** (the closed target taxonomy — e.g. a `MainHeatingSystemType` archetype — each binding to a Simulation Overlay); a **Lodged Description** (the gov-EPC `main_heating[].description` rendering, e.g. "Room heaters, electric" — only an example of which system *types* occur, never a map key); and the **SAP main heating code** (Table 4a/4b, what the calculator consumes). The classifier maps Landlord → Recognised Internal → SAP code. When it cannot confidently place the text it emits **`None`** (no overlay → the lodged EPC stands, surfaced to the user as "no suitable match"), **never the nearest wrong archetype** — the target taxonomy must be complete enough that a real system always has a correct home, so the classifier never overflows into a garbage-drawer archetype (ADR-0041). **Heating Companion Set**: -The coherent SAP inputs a **main heating system** override drags alongside the **SAP main heating code** so the **Effective EPC** reads as one internally-consistent system, not a hybrid of the new code and the *replaced* system's leftovers — heating **category** (SAP Table 4a group: gas→2, heat pump→4, network→6, storage→7, electric underfloor→8, warm air→9, room heater→10), **charge/heating control** (Table 4e group), **natural fuel**, and **meter** (see **Off-Peak Meter**). Companions divide by how firmly the archetype fixes them (ADR-0048): **category and control are archetype-forced** — always written, never inherited, because a wrong value silently mis-scores or mis-bills (a leftover storage **category 7** makes the Table 12a resolver bill peaky room heaters at the all-night rate; a leftover storage **control 2401** adds a +0.7 °C Table-4e penalty a room heater should not carry). **Fuel and meter defer** only where multiple values are genuinely coherent — fuel for an ambiguous carrier (community heating: gas CHP / biomass / waste heat), meter for a meter-flexible system (room heaters run on either; screed underfloor per ADR-0046) — while a meter-*locked* system still forces (storage/CPSU/HHRSH→Dual, non-electric→Single, ADR-0035). Because the overlay composes last-wins, an **unset (`None`)** forced companion is *inherited from the replaced system*, not cleared — so an archetype the overlay cannot yet fully companion (heat pump / community / underfloor / electric boiler control defaults are unmapped) is **logged as an error and continues** (log-not-raise, matching `flag_fuel_mismatch`), making the gap visible for review rather than shipping a silently-incoherent cert; the intended end-state is to fill each and flip the log to a raise. -_Avoid_: coherent companions (informal), dragged fields, heating defaults +The coherent SAP inputs a **main heating system** override drags alongside the **SAP main heating code** so the **Effective EPC** reads as one internally-consistent system, not a hybrid of the new code and the *replaced* system's leftovers — heating **category** (SAP Table 4a group: gas→2, heat pump→4, network→6, storage→7, electric underfloor→8, warm air→9, room heater→10), **charge/heating control** (Table 4e group), **natural fuel**, and **meter** (see **Off-Peak Meter**). Companions divide by how firmly the archetype fixes them (ADR-0048): **category and control are archetype-forced** — always written, never inherited, because a wrong value silently mis-scores or mis-bills (a leftover storage **category 7** makes the Table 12a resolver bill peaky room heaters at the all-night rate; a leftover storage **control 2401** adds a +0.7 °C Table-4e penalty a room heater should not carry). **Fuel and meter defer** only where multiple values are genuinely coherent — fuel for an ambiguous carrier (community heating: gas CHP / biomass / waste heat), meter for a meter-flexible system (room heaters run on either; screed underfloor per ADR-0046) — while a meter-*locked* system still forces (storage/CPSU/HHRSH→Dual, non-electric→Single, ADR-0035). Because the overlay composes last-wins, an **unset (`None`)** forced companion is *inherited from the replaced system*, not cleared — so an archetype the overlay cannot yet fully companion (heat pump / community / underfloor / electric boiler control defaults are unmapped) is **logged as an error and continues** (log-not-raise, matching `flag_fuel_mismatch`), making the gap visible for review rather than shipping a silently-incoherent cert; the intended end-state is to fill each and flip the log to a raise. A **fifth** archetype-forced companion — **boiler efficiency**, keyed by the **Boiler Efficiency Band** — is implemented per ADR-0068 (the override wins over the cert's efficiency, as a replacement makes the lodged value stale). +_Avoid_: coherent companions (informal), dragged fields, heating defaults; treating efficiency as a *deferring* companion (it is forced — the override is the surveyed current boiler) + +**Boiler Efficiency Band**: +The SEDBUK **A–G** rating a landlord carries in the **Landlord Description** for a boiler override (`Boiler: C rated Combi`, `Boiler: D rated Regular Boiler`) — an *attribute* of the override, **not** a **Recognised Internal Description** archetype. It names *how well the boiler burns*, orthogonal to the `MainHeatingSystemType` archetype that names the *system*; it must never mint per-band archetypes because that enum is **FE-owned** ([[main-heating-system-pgenum-is-fe-owned]]). It resolves to a **`(winter, summer)`** seasonal-efficiency pair — the fifth **Heating Companion** (ADR-0068) — that the calculator consumes ahead of the Table 4b code default (as a PCDB `winter_efficiency_pct` already does). C-and-below reuse accredited Table 4b code values (D→80 %, E→74 %, F→70 %, G→66 % winter); **A/B exceed Table 4b's 84 % gas ceiling** and take SEDBUK-derived values validated against an accredited Elmhurst build. Dropping the band (the pre-ADR-0068 overlay mapped every band to the flat condensing 84 %) over-credits D–G and under-credits A/B. It applies to the **SEDBUK-rated wet boilers only — gas/LPG/biogas *and* oil** (both were inflated to their condensing code); **solid-fuel (Table 4a 75 %), electric (100 %) and CPSU** boilers carry an *intrinsic* fixed efficiency and take no band. Stored as its own `boiler_efficiency_band` override row (A–G); absence is modelled as no value (no slot → the code's Table 4b default stands). +_Avoid_: "SEDBUK band" as a synonym for the **SAP main heating code** (the band picks the efficiency, not the code); conflating with the gov-EPC `mainheat_energy_eff` element rating (an A–G *presentation* band on the cert, not this override attribute); "boiler rating" (ambiguous with the EPC band) _Avoid_: "the LLM mapper is unreliable" (the failure mode is a too-small target taxonomy, not LLM language ability); conflating the landlord input vocabulary with the gov-EPC lodged rendering or the RdSAP entry-tool catalogue; treating a deterministic dict as a *replacement* for the LLM rather than a reviewed cache of its output **Lodgement Sentinels (ND / NI / AB)**: diff --git a/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md b/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md new file mode 100644 index 000000000..e71e92e7c --- /dev/null +++ b/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md @@ -0,0 +1,157 @@ +# Boiler efficiency band is the fifth Heating Companion + +## Status + +accepted — implemented (band parser + overlay `(winter, summer)` slot + +calculator precedence + classifier/backfill; C-and-below live, A/B pending the +Elmhurst validation below) + +## Context + +A `main_heating_system` **Landlord Override** maps a **Recognised Internal +Description** archetype to a **SAP main heating code** and drags a **Heating +Companion Set** (ADR-0048): category and control are *archetype-forced*, fuel +and meter *defer*. The overlay +(`domain/epc/property_overlays/main_heating_system_overlay.py`) maps every gas +boiler to a single modern/condensing Table 4b code — `Gas boiler, regular` → 102, +`Gas boiler, combi` → 104, both **84 % winter / 74–75 % summer**. + +The Landlord spreadsheet's **SEDBUK A–G efficiency band** — carried in the +**Landlord Description** as `Boiler: C rated Combi`, `Boiler: D rated Regular +Boiler` — is **dropped at classification**: the `override_value` collapses to the +bare archetype (`Gas boiler, combi`) and the band survives only in +`property_overrides.original_spreadsheet_description`. So the companion set has +**no efficiency dimension** (the overlay docstring already flags "no efficiency +slot on the overlay/`MainHeatingDetail`"), and *every* overridden gas boiler is +modelled at the condensing efficiency regardless of its real band. + +Portfolio-796 audit evidence (2026-07-28), of 24 555 overridden gas boilers: + +| SEDBUK band | count | SEDBUK eff | modelled at 84 % is… | +|---|---:|---|---| +| A | 14 590 | ≥ 90 % | **under**-credited | +| B | 260 | 86–90 % | slightly under | +| C | 6 251 | 82–86 % | ~right | +| D | 1 691 | 78–82 % | over | +| E | 1 196 | 74–78 % | over | +| F | 206 | 70–74 % | over | +| G | 360 | < 70 % | over-credited | + +This is a **code-fidelity** gap (the overlay throws away information the landlord +gave us), distinct from the sibling **fuel-override data-quality** question +(#1701 — whether a landlord's `main_fuel` assertion is itself correct). + +## Decision + +1. **Efficiency becomes the fifth Heating Companion, in the *archetype-forced* + tier** (alongside category and control). When the override's band-derived + efficiency disagrees with the lodged cert, **the override wins** — a + system-replacing override is the landlord telling us the current truth, so the + cert efficiency is treated as stale. + +2. **The SEDBUK band is a new *attribute*, not a new archetype.** The + `MainHeatingSystemType` value stays `Gas boiler, combi`; a separate + **Boiler Efficiency Band** field rides alongside it. The archetype describes + the *system*, the band describes *how well it burns* — orthogonal. Crucially, + the `MainHeatingSystemType` PG enum is **FE-owned** + ([[main-heating-system-pgenum-is-fe-owned]]); minting `Gas-boiler-combi-D-rated` + archetypes would explode a shared cross-team contract 7×. + +3. **Full fidelity (option a), symmetric — not a downward-only cap.** The + companion carries every band's efficiency: A/B rise **above** 84 %, D–G fall. + "Override wins" is symmetric by construction; a one-sided clamp would encode a + policy ("we only correct downward") that a later reader would silently "fix" + into (a) anyway. + +4. **Mechanism: `(archetype + fuel) → base code`, then `band → (winter, summer) + efficiency slot` that overrides the code's default.** The **archetype and its + resolved fuel** pick the *base* `sap_main_heating_code` — this is already + ADR-0067's "resolve fuel at resolution" (`Gas boiler, regular` + an oil + `main_fuel` → `Oil boiler, regular` → an oil code), and the code still drives + system-type behaviour (combi vs regular hot water, etc.). The **band** then + sets a `(winter, summer)` seasonal-efficiency pair on the overlay / + `MainHeatingDetail` that the calculator consumes **ahead of** the code's + Table 4b default — mirroring the existing PCDB precedence + (`pcdb_main.winter_efficiency_pct`, `cert_to_inputs.py:2103`). So efficiency is + looked up by **(fuel family, boiler type, band)**, never a single annual number. + +5. **The band mechanism covers exactly the SEDBUK-rated wet boilers — gas and + oil — and both are currently inflated.** Mains gas, LPG and biogas **share** the + gas codes **101–119**; oil is the separate liquid-fuel range **124–132**. The + overlay today defaults **both** to their condensing/modern variant — gas 102/104 + (84 %) *and* oil 127/130 (84 %/82 %) — so the D–G over-credit exists for oil as + well as gas. Every other boiler archetype we hold has **no SEDBUK band** and is + already modelled at a sensible, non-inflated fixed efficiency, so it needs no + band and is recorded here as *intrinsic-efficiency*: + + | Boiler archetype | Base code | Efficiency | Treatment | + |---|---|---|---| + | Gas boiler, regular / combi | 102 / 104 | Table 4b, condensing | **SEDBUK band** (below) | + | Oil boiler, regular / combi | 127 / 130 | Table 4b, condensing | **SEDBUK band** (below) | + | Solid fuel boiler | 151 | Table 4a **75 %** (`cert_to_inputs.py:2309`) | intrinsic — SEDBUK doesn't rate solid fuel; a PCDB Table-122 product index refines it | + | Electric boiler | 191 | Table 4a **100 %** (direct-acting) | intrinsic — electric | + | Electric CPSU | 192 | Table 4a, electric | intrinsic — electric | + | Gas CPSU | 120 | Table 4b 74 % (non-condensing default) | intrinsic — not inflated; CPSU isn't SEDBUK-banded | + + CPSU (120–123) and range cookers (133–141) are distinct archetypes, not the + regular/combi boiler a band describes. **Solid-fuel efficiency *granularity* + (manual vs auto-feed / pellet) is an archetype question, not a band one** — the + single `Solid fuel boiler` member (all coal/wood/dual collapse to it, ADR-0067) + is a separate follow-up if finer solid-fuel efficiency is wanted. + + The per-fuel band → efficiency lookup for the two banded fuels, `(winter, summer)`: + + | Band | Gas regular / combi | Oil regular / combi | Source | + |---|---|---|---| + | A | **slot** ~(90, 80) | **slot** ~(88, 78) | SEDBUK ≥ 90 %, **needs oracle** | + | B | **slot** ~(88, 78) | **slot** ~(87, 77) | SEDBUK 86–90 %, **needs oracle** | + | C | 102 (84,74) / 104 (84,75) | 127 (84,72) / 130 (82,73) | Table 4b (accredited) | + | D | 106 (80,70) / 108 (80,71) | 126 (80,68) / 129 (77,68) | Table 4b | + | E | 101 (74,64) / 103 (74,65) | 125 (71,59) / 129 (77,68) ⚠ | Table 4b | + | F | 105 (70,60) / 107 (70,61) | 125 (71,59) / 128 (71,62) | Table 4b | + | G | 115 (66,56) / 118 (66,57) | 124 (66,54) / 128 (71,62) ⚠ | Table 4b | + + **C-and-below reuse accredited Table 4b code values** — no oracle needed. + **A/B exceed Table 4b's 84 % ceiling** (gas) / 82–84 % (oil), so they *must* + use the slot with SEDBUK-derived values — the only judgement calls, carrying + the validation obligation below. ⚠ **Oil's code set is sparser** than gas and + does not line up cleanly with SEDBUK bands (no oil code in the 74–78 % E range + for regular; nothing below ~66/71 % for G-combi), so oil D–G are *approximate* + nearest-fit — a follow-up may extend the slot to more oil bands rather than + borrow a mismatched code. + +6. **Data / migration.** Add a `boiler_efficiency_band` to the override + representation; **backfill** it for already-ingested overrides by re-parsing + `original_spreadsheet_description`, and extend the **Landlord Description → + Recognised Internal Description** classifier to capture the band going forward. + Then **re-model** the affected overridden-boiler population. + +## Considered options + +- **Code-only (reuse existing Table 4b codes per band), no slot.** Rejected as + the *sole* mechanism: Table 4b's non-PCDB gas codes cap at 84 %, so A/B cannot + rise — that silently delivers the downward-only cap, not full fidelity. Retained + for C-and-below, where accredited codes exist and no slot is required. +- **Downward-only clamp (ship "cap the over-credit" first).** Rejected: "override + wins" is symmetric; a clamp encodes a temporary policy that reads as a bug later. +- **A new archetype per band.** Rejected: explodes the FE-owned + `MainHeatingSystemType` enum. +- **Defer to the cert efficiency when it "looks fine".** Rejected: the divergent + case *is* the entire bug, and a replacement override makes the cert efficiency + stale — so the check only spares the cases that were never wrong. + +## Consequences + +- **Positive.** Over-credited D–G baselines corrected down to published Table 4b + values; A/B corrected up; the override finally encodes the efficiency the + landlord surveyed. +- **Cost / risk — the A/B upside.** It moves ~14 850 accredited baselines **up** + (~+1–3 SAP each, small per home but **systematic**); some cross a band boundary, + rippling into **Recommendations** and **funding eligibility**. Therefore the + A/B `(winter, summer)` numbers (rows A, B only) **must be validated against an + accredited Elmhurst SAP 10.2 build** before the upside ships. The C-and-below + downside uses published Table 4b values and needs no oracle. +- **Migration + re-model** of the overridden-boiler population; the stored + baselines change, so this is an operational re-base, not a silent code swap. +- Sibling data-quality reconciliation: the cheaper-direction `main_fuel` + overrides (#1701). From c7edb1df0bfe2a8daf7ef8d782bb3bdf0d2af21f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 17:09:20 +0000 Subject: [PATCH 13/22] test(elmhurst): A/B-band validation certs + Elmhurst input sheets (ADR-0068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A-band: uprn 15017550 (gas combi lodged at generic 84% code 104 — the A under- credit). B-band: uprn 37020227 (already lodges a real B-rated boiler, PCDB 9900 Ideal isar 88.1/79.5). Sheets carry the special instruction to report Elmhurst's SAP + boiler winter/summer efficiency, to pin _BAND_SLOT_EFFICIENCY_PCT. Candidates from accredited PCDB: A gas ~(90.0, 80.0), B gas ~(88.0, 79.5). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../uprn_37020227/elmhurst_inputs.md | 82 +++++ .../RdSAP-Schema-18.0/uprn_37020227/epc.json | 299 +++++++++++++++ .../uprn_15017550/elmhurst_inputs.md | 87 +++++ .../uprn_15017550/epc.json | 340 ++++++++++++++++++ 4 files changed, 808 insertions(+) create mode 100644 backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_37020227/elmhurst_inputs.md create mode 100644 backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_37020227/epc.json create mode 100644 backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_15017550/elmhurst_inputs.md create mode 100644 backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_15017550/epc.json diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_37020227/elmhurst_inputs.md b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_37020227/elmhurst_inputs.md new file mode 100644 index 000000000..f63274bf4 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_37020227/elmhurst_inputs.md @@ -0,0 +1,82 @@ +# Elmhurst RdSAP inputs — UPRN 37020227 (cert 0560-2863-6214-9928-3741, RdSAP-Schema-18.0) + +**Lodged SAP:** 70 **Our engine:** 68.5 ← compare Elmhurst against this +**Purpose:** ADR-0068 **B-band** validation. Landlord override = `Boiler: B rated Combi`. + +> ## ⭐ SPECIAL — this build is to pin the B-band boiler efficiency +> Unlike the A-rated case, this cert **already lodges a real B-rated boiler**: PCDB index +> **9900 = Ideal *isar* combi**, winter **88.1 %** / summer **79.5 %**. So this is the tightest +> validation — reproduce the cert **as-is** (keep the Ideal isar / a B-rated 86–90 % combi), +> then **report back**: +> 1. the **SAP score** Elmhurst gives (expect ≈ our 68.5 / lodged 70), +> 2. the boiler **winter** efficiency Elmhurst shows (expect ≈ 88 %), and +> 3. the **summer** efficiency (expect ≈ 79–80 %). +> +> Candidate to confirm: **B-band (winter, summer) ≈ (88.0, 79.5)** — from the real lodged +> product and the PCDB 86–90 % median (88.4, 80.0). + +## Property Description +| Elmhurst field | Value | Notes | +|---|---|---| +| Property type | **Maisonette** | gov property_type=2 | +| Built form | **Semi-Detached** | gov built_form=2 | +| Age band | **D — 1950–1966** | construction_age_band D | +| Position | dwelling **above** (party ceiling) | roof heat loss = 0 (see Roofs) | + +## Dimensions +| Elmhurst field | Value | Notes | +|---|---|---| +| Total floor area | **45.3 m²** | TFA, single storey | +| Storey height | **2.34 m** | RAW room height | +| No. of storeys | 1 | | + +## Walls +| Elmhurst field | Value | Notes | +|---|---|---| +| Construction | **Solid** | wall_construction=8, 395 mm measured | +| Insulation | **None (as built)** | wall_insulation=NI; engine walls = 75.2 W/K (uninsulated solid) | + +## Roofs +| Elmhurst field | Value | Notes | +|---|---|---| +| Roof | **Another dwelling above (party ceiling)** | roof heat loss = 0 W/K — do NOT enter a pitched/flat roof | + +## Floors +| Elmhurst field | Value | Notes | +|---|---|---| +| Floor | (as lodged) | engine floor = 27.6 W/K | + +## Openings +| Elmhurst field | Value | Notes | +|---|---|---| +| Windows | **Double glazing** | glazing code 2; 4 windows synthesised from glazed-area band | +| Doors | 1 (typical) | engine doors = 5.6 W/K | + +## Ventilation & Lighting +| Elmhurst field | Value | Notes | +|---|---|---| +| Ventilation | Natural | mechanical_ventilation=0 | +| Low-energy lighting | **100 %** | | + +## Space Heating — Main Heating 1 ⭐ +| Elmhurst field | Value | Notes | +|---|---|---| +| Main heat | **Gas condensing combi — Ideal isar (PCDB 9900), B-rated** | winter 88.1 / summer 79.5 | +| Fuel | Mains gas | | +| Controls | **Programmer + room thermostat + TRVs** | control 2106 | +| Meter | Single-rate | | + +## Water Heating +| Elmhurst field | Value | Notes | +|---|---|---| +| Water heating | **From main heating system** | WHC 901 | +| Cylinder | **None (combi)** | | + +## Fields to clear in Elmhurst (do NOT map) +| Elmhurst field | Set to | Why absent | +|---|---|---| +| Roof insulation | n/a | party ceiling — no roof element | +| Extensions / Room-in-Roof | (blank) | single building part | +| Conservatory / PV / Wind / Hydro | none | not lodged | +| Cylinder / Solar Water / WWHRS / FGHRS | none | combi, none lodged | +| Secondary heating | none | not lodged | diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_37020227/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_37020227/epc.json new file mode 100644 index 000000000..7cc41bc43 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-18.0/uprn_37020227/epc.json @@ -0,0 +1,299 @@ +{ + "uprn": 37020227, + "roofs": [ + { + "description": { + "value": "(another dwelling above)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": { + "value": "System built, as built, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + } + ], + "floors": [ + { + "description": { + "value": "Solid, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": { + "value": "Fully double glazed", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "addendum": { + "system_build": "true" + }, + "lighting": { + "description": { + "value": "Low energy lighting in all fixed outlets", + "language": "1" + }, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "PO12 4TR", + "hot_water": { + "description": { + "value": "From main system", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "GOSPORT", + "built_form": 2, + "created_at": "2018-10-07 20:52:43.000000", + "door_count": 1, + "glazed_area": 1, + "region_code": 16, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "central_heating_pump_age": 0, + "main_heating_data_source": 1, + "main_heating_index_number": 9900 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.93, + "schema_type": "RdSAP-Schema-18.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": { + "value": "Boiler and radiators, mains gas", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": { + "value": "Ground-floor flat", + "language": "1" + }, + "language_code": 1, + "property_type": 2, + "address_line_1": "Flat 3 Garland Court", + "address_line_2": "Forton Road", + "assessment_type": "RdSAP", + "completion_date": "2018-10-07", + "inspection_date": "2018-09-27", + "extensions_count": 0, + "measurement_type": 1, + "sap_flat_details": { + "level": 1, + "top_storey": "N", + "flat_location": 0, + "heat_loss_corridor": 2, + "unheated_corridor_length": { + "value": 4.4, + "quantity": "metres" + } + }, + "total_floor_area": 45, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 2, + "registration_date": "2018-10-07", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "photovoltaic_supply": { + "none_or_no_details": { + "pv_connection": 0, + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": { + "value": "None", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 395, + "floor_heat_loss": 7, + "roof_construction": 3, + "wall_construction": 8, + "building_part_number": 1, + "sap_alternative_wall": { + "wall_area": 10.296, + "sheltered_wall": "Y", + "wall_dry_lined": "N", + "wall_construction": 8, + "wall_insulation_type": 4, + "wall_thickness_measured": "N", + "wall_insulation_thickness": "NI" + }, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.34, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 45.26, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 7.3, + "quantity": "metres" + }, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 19.7, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "D", + "party_wall_construction": 1, + "wall_thickness_measured": "Y", + "roof_insulation_location": "ND", + "roof_insulation_thickness": "ND", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 100, + "solar_water_heating": "N", + "habitable_room_count": 2, + "heating_cost_current": { + "value": 320, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 1.7, + "energy_rating_average": 60, + "energy_rating_current": 70, + "lighting_cost_current": { + "value": 35, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": { + "value": "Programmer, room thermostat and TRVs", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 2, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 291, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 75, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 29, + "currency": "GBP" + }, + "indicative_cost": "\u00a34,000 - \u00a36,000", + "improvement_type": "W2", + "improvement_details": { + "improvement_number": 58 + }, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 73 + } + ], + "co2_emissions_potential": 1.6, + "energy_rating_potential": 72, + "lighting_cost_potential": { + "value": 35, + "currency": "GBP" + }, + "schema_version_original": "LIG-18.0", + "hot_water_cost_potential": { + "value": 76, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 1626, + "space_heating_existing_dwelling": 4686 + }, + "energy_consumption_current": 218, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "2.1.0.0", + "energy_consumption_potential": 198, + "environmental_impact_current": 70, + "fixed_lighting_outlets_count": 7, + "windows_transmission_details": { + "u_value": 2, + "data_source": 2, + "solar_transmittance": 0.72 + }, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 73, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "C", + "co2_emissions_current_per_floor_area": 38, + "low_energy_fixed_lighting_outlets_count": 7 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_15017550/elmhurst_inputs.md b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_15017550/elmhurst_inputs.md new file mode 100644 index 000000000..654768c61 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_15017550/elmhurst_inputs.md @@ -0,0 +1,87 @@ +# Elmhurst RdSAP inputs — UPRN 15017550 (cert 0010-2504-7060-2529-3175, RdSAP-Schema-20.0.0) + +**Lodged SAP:** 66 **Our engine:** 66.5 ← compare Elmhurst against this +**Purpose:** ADR-0068 **A-band** validation. Landlord override = `Boiler: A rated Combi`. + +> ## ⭐ SPECIAL — this build is to pin the A-band boiler efficiency +> The lodged cert models this gas combi at the generic **condensing code (84 %)** — the +> under-credit ADR-0068 fixes. Build the dwelling faithfully **but set the main boiler to a +> representative A-rated (SEDBUK ≥ 90 %) condensing combi**, then **report back three numbers**: +> 1. the **SAP score** Elmhurst gives, +> 2. the **winter** seasonal efficiency Elmhurst applies to the boiler (worksheet (206)), and +> 3. the **summer** seasonal efficiency (Appendix D / water-heating line). +> +> Enter the boiler via the **SEDBUK/PCDB database** picking any A-rated combi (winter ≥ 90 %), +> **or** manually as SEDBUK winter **90.3 %** / summer **81.5 %** (the PCDB A-rated combi median). +> Candidate to confirm: **A-band (winter, summer) ≈ (90.0, 80.0)**. + +## Property Description +| Elmhurst field | Value | Notes | +|---|---|---| +| Property type | **Flat** | gov property_type=1 | +| Built form | **Mid-Terrace** | gov built_form=3 | +| Age band | **H — 1983–1990** | construction_age_band H | +| Position in block / floor level | mid-floor flat | single storey, no roof/ground exposure beyond party | + +## Dimensions +| Elmhurst field | Value | Notes | +|---|---|---| +| Total floor area | **57.9 m²** | TFA, single storey (part 0, floor 0) | +| Storey height (ground/only) | **2.68 m** | RAW room height — do NOT add +0.25 (single storey) | +| No. of storeys | 1 | | + +## Walls +| Elmhurst field | Value | Notes | +|---|---|---| +| Construction | **Cavity** | wall_construction=4, 300 mm measured | +| Insulation | **As built / no insulation (assumed)** | wall_insulation_thickness=NI | +| Wall U-value | (let Elmhurst derive) | engine walls = 21.9 W/K | + +## Roof +| Elmhurst field | Value | Notes | +|---|---|---| +| Type | **Pitched, access to loft** | roof_construction_type | +| Insulation | **100 mm at joists** | roof_insulation_thickness=100 mm, location=2 (joists) | + +## Floors +| Elmhurst field | Value | Notes | +|---|---|---| +| Ground floor | **Suspended, not timber** | floor_construction_type; engine floor = 41.1 W/K | + +## Openings +| Elmhurst field | Value | Notes | +|---|---|---| +| Windows | **Triple / double glazing** | glazing code 3; 4 windows synthesised from glazed-area band × TFA (not real geometry) | +| Glazed area | typical | engine windows = 23.7 W/K | +| Doors | 1 (typical) | engine doors = 11.1 W/K | + +## Ventilation & Lighting +| Elmhurst field | Value | Notes | +|---|---|---| +| Ventilation | Natural (no mech vent) | mechanical_ventilation=0 | +| Low-energy lighting | **67 %** | | + +## Space Heating — Main Heating 1 ⭐ +| Elmhurst field | Value | Notes | +|---|---|---| +| Main heat | **Gas condensing combi, A-rated** | ⭐ set to SEDBUK ≥ 90 % (lodged code 104 = generic 84 %; we are pinning the A-band) | +| Fuel | Mains gas | main_fuel 26 | +| Controls | **Programmer + room thermostat + TRVs** | control 2106 | +| Meter | Single-rate | | + +## Water Heating +| Elmhurst field | Value | Notes | +|---|---|---| +| Water heating | **From main heating system** | WHC 901 | +| Cylinder | **None (combi)** | has_hot_water_cylinder=False | + +## Fields to clear in Elmhurst (do NOT map) +| Elmhurst field | Set to | Why absent | +|---|---|---| +| Extensions (1st–4th) age/area | (blank) | single building part | +| Room-in-Roof | (blank) | none lodged | +| Conservatory | none | not lodged | +| Water Heating · cylinder | none | combi — no cylinder | +| Solar Water / WWHRS / FGHRS | none | not lodged | +| PV / Wind / Hydro | none | photo_supply none | +| Secondary heating | none | not lodged | diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_15017550/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_15017550/epc.json new file mode 100644 index 000000000..13f504a49 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_15017550/epc.json @@ -0,0 +1,340 @@ +{ + "uprn": 15017550, + "roofs": [ + { + "description": { + "value": "Pitched, 100 mm loft insulation", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": { + "value": "Cavity wall, filled cavity", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": { + "value": "Suspended, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": { + "value": "Fully double glazed", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "lighting": { + "description": { + "value": "Low energy lighting in 67% of fixed outlets", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "NN3 2BE", + "hot_water": { + "description": { + "value": "From main system", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "NORTHAMPTON", + "built_form": 3, + "created_at": "2021-08-02 11:49:18.043921", + "door_count": 2, + "glazed_area": 1, + "glazing_gap": "16+", + "region_code": 6, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "N", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 104, + "central_heating_pump_age": 0, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": "NA", + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.94, + "schema_type": "RdSAP-Schema-20.0.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": { + "value": "Boiler and radiators, mains gas", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": { + "value": "end-terrace bungalow", + "language": "1" + }, + "language_code": 1, + "property_type": 1, + "address_line_1": "1 HOWARD BILEY GARDENS", + "assessment_type": "RdSAP", + "completion_date": "2021-08-02", + "inspection_date": "2021-06-24", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 58, + "transaction_type": 13, + "conservatory_type": 1, + "heated_room_count": 2, + "pvc_window_frames": "true", + "registration_date": "2021-08-02", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 2, + "photovoltaic_supply": { + "none_or_no_details": { + "pv_connection": 0, + "percent_roof_area": 0 + } + }, + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": { + "value": "None", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 300, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.68, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 57.93, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 4.51, + "quantity": "metres" + }, + "floor_construction": 3, + "heat_loss_perimeter": { + "value": 27.93, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 2, + "construction_age_band": "H", + "party_wall_construction": 2, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + } + ], + "low_energy_lighting": 67, + "solar_water_heating": "N", + "habitable_room_count": 2, + "heating_cost_current": { + "value": 482, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.8, + "energy_rating_average": 60, + "energy_rating_current": 66, + "lighting_cost_current": { + "value": 67, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": { + "value": "Programmer, room thermostat and TRVs", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 395, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 88, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 70, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 30, + "currency": "GBP" + }, + "indicative_cost": "\u00a3100 - \u00a3350", + "improvement_type": "A", + "improvement_details": { + "improvement_number": 5 + }, + "improvement_category": 5, + "energy_performance_rating": 67, + "environmental_impact_rating": 65 + }, + { + "sequence": 2, + "typical_saving": { + "value": 59, + "currency": "GBP" + }, + "indicative_cost": "\u00a3800 - \u00a31,200", + "improvement_type": "W1", + "improvement_details": { + "improvement_number": 57 + }, + "improvement_category": 5, + "energy_performance_rating": 71, + "environmental_impact_rating": 70 + }, + { + "sequence": 3, + "typical_saving": { + "value": 15, + "currency": "GBP" + }, + "indicative_cost": "\u00a310", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 71, + "environmental_impact_rating": 70 + }, + { + "sequence": 4, + "typical_saving": { + "value": 29, + "currency": "GBP" + }, + "indicative_cost": "\u00a34,000 - \u00a36,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 73, + "environmental_impact_rating": 73 + }, + { + "sequence": 5, + "typical_saving": { + "value": 346, + "currency": "GBP" + }, + "indicative_cost": "\u00a33,500 - \u00a35,500", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 86, + "environmental_impact_rating": 84 + } + ], + "co2_emissions_potential": 1.1, + "energy_rating_potential": 86, + "lighting_cost_potential": { + "value": 50, + "currency": "GBP" + }, + "schema_version_original": "LIG-19.0", + "hot_water_cost_potential": { + "value": 59, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 1827, + "impact_of_loft_insulation": -645, + "space_heating_existing_dwelling": 7947 + }, + "energy_consumption_current": 272, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "2.1.0.1", + "energy_consumption_potential": 105, + "environmental_impact_current": 63, + "fixed_lighting_outlets_count": 6, + "windows_transmission_details": { + "u_value": 2.6, + "data_source": 2, + "solar_transmittance": 0.76 + }, + "current_energy_efficiency_band": "D", + "environmental_impact_potential": 84, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 48, + "low_energy_fixed_lighting_outlets_count": 4 +} \ No newline at end of file From 063eb17a895a3c0c83da0fb2b453dbf18bd305aa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 18:37:10 +0000 Subject: [PATCH 14/22] =?UTF-8?q?feat(overlay):=20enable=20gas=20A/B=20ban?= =?UTF-8?q?ds=20=E2=80=94=20Elmhurst-validated=20(winter,summer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gas A = (90.6, 81.6) [PCDB 18790 Worcester Greenstar 8000 Life], B = (88.0, 79.4) [PCDB 15029 Baxi Duo-tec Combi 24 HE], read off accredited Elmhurst RdSAP10 worksheets (206)/(217). Applied to gas regular (102) + combi (104); gas/LPG/biogas share the codes. A-rated real cert 15017550: +1.78 SAP (the under-credit fix). Oil A/B still pending (no slot). Corpus unmoved; 797 tests green (ADR-0068). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 26 ++++++++---- .../A rated boiler/Summary_000567.pdf | Bin 0 -> 79187 bytes .../A rated boiler/U985-0001-000567 (1).pdf | Bin 0 -> 44137 bytes .../B rated boiler/Summary_000567.pdf | Bin 0 -> 78280 bytes .../B rated boiler/U985-0001-000567 (1).pdf | Bin 0 -> 43211 bytes .../test_boiler_efficiency_band_overlay.py | 39 ++++++++++++++---- 6 files changed, 49 insertions(+), 16 deletions(-) create mode 100644 sap worksheets/A rated boiler/Summary_000567.pdf create mode 100644 sap worksheets/A rated boiler/U985-0001-000567 (1).pdf create mode 100644 sap worksheets/B rated boiler/Summary_000567.pdf create mode 100644 sap worksheets/B rated boiler/U985-0001-000567 (1).pdf diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 8d631b63d..6e51b4b00 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -287,13 +287,25 @@ _BAND_TO_TABLE_4B_CODE: dict[tuple[int, BoilerEfficiencyBand], int] = { } # 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]] = {} +# Table 4b code — they take SEDBUK-derived `(winter, summer)` slot values +# validated against an **accredited Elmhurst build** (ADR-0068 gated pre-req). +# +# GAS (validated 2026-07-29 on Elmhurst RdSAP10, dwelling ref 000567): +# A → PCDB 18790 (Worcester Greenstar 8000 Life): worksheet (206)=90.6 / (217)=81.6 +# B → PCDB 15029 (Baxi Duo-tec Combi 24 HE class): worksheet (206)=88.0 / (217)=79.4 +# Gas/LPG/biogas share codes 101–119, so one pair per band covers all three; applied +# to both gas regular (102) and combi (104). (Enabling A moves ~14.6k A-rated homes +# up ~+1–3 SAP — flagged to stakeholders; some cross a band → Recs/eligibility.) +# +# OIL (124–132): still pending — no oil slot yet, so an oil A/B boiler keeps its +# condensing code default (127/130). Oil A/B is a tiny population; a follow-up can +# pin it from an oil PCDB product or an oil Elmhurst build. No invented numbers ship. +_BAND_SLOT_EFFICIENCY_PCT: dict[tuple[int, BoilerEfficiencyBand], tuple[float, float]] = { + (102, BoilerEfficiencyBand.A): (90.6, 81.6), + (104, BoilerEfficiencyBand.A): (90.6, 81.6), + (102, BoilerEfficiencyBand.B): (88.0, 79.4), + (104, BoilerEfficiencyBand.B): (88.0, 79.4), +} def band_seasonal_efficiency_pct( diff --git a/sap worksheets/A rated boiler/Summary_000567.pdf b/sap worksheets/A rated boiler/Summary_000567.pdf new file mode 100644 index 0000000000000000000000000000000000000000..16e471cb387849c62a6a7e9cf925a39afe4ef215 GIT binary patch literal 79187 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 G+ad+1!g5h46#7 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 Y=ImnZ^oE2$*)A7)T=WdHyG literal 0 HcmV?d00001 diff --git a/sap worksheets/B rated boiler/Summary_000567.pdf b/sap worksheets/B rated boiler/Summary_000567.pdf new file mode 100644 index 0000000000000000000000000000000000000000..69f419c50ef853ff70aaa2b5f0eccc3b6f9d0c87 GIT binary patch literal 78280 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 vQUt@|C3d52YXjhGZzzQD@RucXU4x>c_X_& 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 C{I5R% literal 0 HcmV?d00001 diff --git a/tests/domain/epc/test_boiler_efficiency_band_overlay.py b/tests/domain/epc/test_boiler_efficiency_band_overlay.py index bc4dd7b72..46efbc2e7 100644 --- a/tests/domain/epc/test_boiler_efficiency_band_overlay.py +++ b/tests/domain/epc/test_boiler_efficiency_band_overlay.py @@ -42,15 +42,33 @@ def test_c_and_below_bands_resolve_to_the_table_4b_pair( assert band_seasonal_efficiency_pct(base_code, band) == expected +@pytest.mark.parametrize( + ("base_code", "band", "expected"), + [ + # Gas A/B validated against accredited Elmhurst (ADR-0068): A → PCDB 18790 + # (Worcester Greenstar 8000 Life) 90.6/81.6; B → PCDB 15029 (Baxi Duo-tec + # Combi 24 HE) 88.0/79.4. Gas/LPG/biogas share codes — one pair per band on + # both regular (102) and combi (104). + (102, BoilerEfficiencyBand.A, (90.6, 81.6)), + (104, BoilerEfficiencyBand.A, (90.6, 81.6)), + (102, BoilerEfficiencyBand.B, (88.0, 79.4)), + (104, BoilerEfficiencyBand.B, (88.0, 79.4)), + ], +) +def test_gas_a_and_b_bands_use_the_elmhurst_validated_slot( + base_code: int, band: BoilerEfficiencyBand, expected: tuple[float, float] +) -> None: + # These exceed Table 4b's 84% ceiling (the A under-credit the fix removes). + assert band_seasonal_efficiency_pct(base_code, band) == expected + + @pytest.mark.parametrize("band", [BoilerEfficiencyBand.A, BoilerEfficiencyBand.B]) -@pytest.mark.parametrize("base_code", [102, 104, 127, 130]) -def test_a_and_b_bands_are_pending_elmhurst_validation( +@pytest.mark.parametrize("base_code", [127, 130]) +def test_oil_a_and_b_bands_are_pending_validation( base_code: int, band: BoilerEfficiencyBand ) -> None: - # A/B exceed Table 4b's 84% ceiling — they are judgement values that MUST be - # pinned against an accredited Elmhurst build before they are enabled - # (ADR-0068 gated pre-req). Until then: no slot, so an A/B boiler keeps its - # condensing code default (no invented numbers ship). + # Oil A/B not yet validated — no slot, so an oil A/B boiler keeps its + # condensing code default (127/130). No invented numbers ship (ADR-0068). assert band_seasonal_efficiency_pct(base_code, band) is None @@ -77,6 +95,10 @@ def test_non_banded_boilers_never_take_a_slot( ("Gas boiler, combi", BoilerEfficiencyBand.G, (66.0, 57.0)), ("Oil boiler, regular", BoilerEfficiencyBand.E, (71.0, 59.0)), ("Oil boiler, combi", BoilerEfficiencyBand.D, (77.0, 68.0)), + # Elmhurst-validated A/B upside on gas (the A under-credit fix). + ("Gas boiler, regular", BoilerEfficiencyBand.A, (90.6, 81.6)), + ("Gas boiler, combi", BoilerEfficiencyBand.A, (90.6, 81.6)), + ("Gas boiler, combi", BoilerEfficiencyBand.B, (88.0, 79.4)), ], ) def test_overlay_sets_the_band_efficiency_slot_on_gas_and_oil_boilers( @@ -96,9 +118,8 @@ def test_overlay_sets_the_band_efficiency_slot_on_gas_and_oil_boilers( [ # No band → no slot (today's behaviour: keep the condensing code default). ("Gas boiler, combi", None), - # A/B pending Elmhurst → no slot yet. - ("Gas boiler, combi", BoilerEfficiencyBand.A), - # C on gas regular equals the code default — still set, but a no-op value. + # Oil A/B still pending Elmhurst → no slot yet. + ("Oil boiler, combi", BoilerEfficiencyBand.A), # Non-banded boilers ignore the band letter entirely. ("Electric boiler", BoilerEfficiencyBand.D), ("Solid fuel boiler", BoilerEfficiencyBand.G), From 9760b38238469710497512b1edaa8b3b734af1eb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 19:07:57 +0000 Subject: [PATCH 15/22] feat(overlay): pin oil A/B bands from accredited PCDB (completes A/B matrix) Oil A = (90.6, 82.8) [PCDB 17292 Firebird Enviromax Blue Supreme], B = (88.0, 80.2) [PCDB 10498 Worcester Greenstar Danesmoor], applied to oil regular (127) + combi (130). No separate oil Elmhurst build: the gas builds proved Elmhurst's worksheet (206)/(217) for a database boiler == its PCDB winter/summer read verbatim (SAP 10.2 App D2.1), so the PCDB record is the accredited value. Corpus unmoved; 68 overlay tests green (ADR-0068). Full A/B matrix (gas/LPG/biogas + oil) now live. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 15 ++++++++--- .../test_boiler_efficiency_band_overlay.py | 25 ++++++++----------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 6e51b4b00..6ff3f1719 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -297,14 +297,23 @@ _BAND_TO_TABLE_4B_CODE: dict[tuple[int, BoilerEfficiencyBand], int] = { # to both gas regular (102) and combi (104). (Enabling A moves ~14.6k A-rated homes # up ~+1–3 SAP — flagged to stakeholders; some cross a band → Recs/eligibility.) # -# OIL (124–132): still pending — no oil slot yet, so an oil A/B boiler keeps its -# condensing code default (127/130). Oil A/B is a tiny population; a follow-up can -# pin it from an oil PCDB product or an oil Elmhurst build. No invented numbers ship. +# OIL (124–132): pinned from the accredited PCDB records we hold, applied to oil +# regular (127) and combi (130): +# A → PCDB 17292 (Firebird Enviromax Blue Supreme): (90.6, 82.8) +# B → PCDB 10498 (Worcester Greenstar Danesmoor): (88.0, 80.2) +# No separate Elmhurst build was needed: the gas builds above proved Elmhurst's +# worksheet (206)/(217) for a database boiler are its PCDB winter/summer read back +# verbatim (18790 → 90.6/81.6, 15029 → 88.0/79.4, both exact), per SAP 10.2 App +# D2.1 (PCDB overrides Table 4b). So the PCDB record IS the accredited value. _BAND_SLOT_EFFICIENCY_PCT: dict[tuple[int, BoilerEfficiencyBand], tuple[float, float]] = { (102, BoilerEfficiencyBand.A): (90.6, 81.6), (104, BoilerEfficiencyBand.A): (90.6, 81.6), (102, BoilerEfficiencyBand.B): (88.0, 79.4), (104, BoilerEfficiencyBand.B): (88.0, 79.4), + (127, BoilerEfficiencyBand.A): (90.6, 82.8), + (130, BoilerEfficiencyBand.A): (90.6, 82.8), + (127, BoilerEfficiencyBand.B): (88.0, 80.2), + (130, BoilerEfficiencyBand.B): (88.0, 80.2), } diff --git a/tests/domain/epc/test_boiler_efficiency_band_overlay.py b/tests/domain/epc/test_boiler_efficiency_band_overlay.py index 46efbc2e7..d710fea80 100644 --- a/tests/domain/epc/test_boiler_efficiency_band_overlay.py +++ b/tests/domain/epc/test_boiler_efficiency_band_overlay.py @@ -49,29 +49,27 @@ def test_c_and_below_bands_resolve_to_the_table_4b_pair( # (Worcester Greenstar 8000 Life) 90.6/81.6; B → PCDB 15029 (Baxi Duo-tec # Combi 24 HE) 88.0/79.4. Gas/LPG/biogas share codes — one pair per band on # both regular (102) and combi (104). + # Gas A/B — validated on accredited Elmhurst (PCDB 18790 / 15029). (102, BoilerEfficiencyBand.A, (90.6, 81.6)), (104, BoilerEfficiencyBand.A, (90.6, 81.6)), (102, BoilerEfficiencyBand.B, (88.0, 79.4)), (104, BoilerEfficiencyBand.B, (88.0, 79.4)), + # Oil A/B — pinned from the accredited PCDB records (17292 / 10498). The + # gas builds proved Elmhurst's (206)/(217) == the PCDB winter/summer, so + # the PCDB record is the accredited value (no separate oil build needed). + (127, BoilerEfficiencyBand.A, (90.6, 82.8)), + (130, BoilerEfficiencyBand.A, (90.6, 82.8)), + (127, BoilerEfficiencyBand.B, (88.0, 80.2)), + (130, BoilerEfficiencyBand.B, (88.0, 80.2)), ], ) -def test_gas_a_and_b_bands_use_the_elmhurst_validated_slot( +def test_a_and_b_bands_use_the_accredited_slot( base_code: int, band: BoilerEfficiencyBand, expected: tuple[float, float] ) -> None: - # These exceed Table 4b's 84% ceiling (the A under-credit the fix removes). + # A/B exceed Table 4b's 84% ceiling (the under-credit the fix removes). assert band_seasonal_efficiency_pct(base_code, band) == expected -@pytest.mark.parametrize("band", [BoilerEfficiencyBand.A, BoilerEfficiencyBand.B]) -@pytest.mark.parametrize("base_code", [127, 130]) -def test_oil_a_and_b_bands_are_pending_validation( - base_code: int, band: BoilerEfficiencyBand -) -> None: - # Oil A/B not yet validated — no slot, so an oil A/B boiler keeps its - # condensing code default (127/130). No invented numbers ship (ADR-0068). - assert band_seasonal_efficiency_pct(base_code, band) is None - - @pytest.mark.parametrize( "base_code", [ @@ -118,10 +116,9 @@ def test_overlay_sets_the_band_efficiency_slot_on_gas_and_oil_boilers( [ # No band → no slot (today's behaviour: keep the condensing code default). ("Gas boiler, combi", None), - # Oil A/B still pending Elmhurst → no slot yet. - ("Oil boiler, combi", BoilerEfficiencyBand.A), # Non-banded boilers ignore the band letter entirely. ("Electric boiler", BoilerEfficiencyBand.D), + ("Electric boiler", BoilerEfficiencyBand.A), ("Solid fuel boiler", BoilerEfficiencyBand.G), ("Gas CPSU", BoilerEfficiencyBand.D), ], From df9073d80a950c61ed97ec8794c82789d2d66fb9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 19:08:43 +0000 Subject: [PATCH 16/22] =?UTF-8?q?docs(adr):=20ADR-0068=20status=20?= =?UTF-8?q?=E2=80=94=20A/B=20validated=20and=20live=20for=20gas=20+=20oil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-efficiency-band-is-the-fifth-heating-companion.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md b/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md index e71e92e7c..f6c070299 100644 --- a/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md +++ b/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md @@ -2,9 +2,14 @@ ## Status -accepted — implemented (band parser + overlay `(winter, summer)` slot + -calculator precedence + classifier/backfill; C-and-below live, A/B pending the -Elmhurst validation below) +accepted — implemented & live (band parser + overlay `(winter, summer)` slot + +calculator precedence + classifier/backfill). C-and-below from accredited Table +4b; **A/B validated and live for gas/LPG/biogas and oil** — gas from accredited +Elmhurst RdSAP10 builds (PCDB 18790 → 90.6/81.6, 15029 → 88.0/79.4), oil pinned +from the accredited PCDB records (17292 → 90.6/82.8, 10498 → 88.0/80.2) after the +gas builds proved Elmhurst's worksheet (206)/(217) == the PCDB winter/summer +verbatim (SAP 10.2 App D2.1). Whole-dwelling check: A-rated cert 15017550 +1.78 +SAP; gov-API RdSAP corpus unmoved. ## Context From 307fdfb2706ec202b019a23803c9a6f9820a4d7d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Jul 2026 13:06:18 +0000 Subject: [PATCH 17/22] refactor(overlay): band uses cert-native anchors, not a synthesis field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback: seasonal_efficiency_override_pct had no lodged-cert analogue and polluted the EpcPropertyData/MainHeatingDetail cert datatype. Replace it with the cert-native efficiency anchors a real cert already uses: - C–G -> sap_main_heating_code = the band's Table 4b code (combi/regular preserved by the code itself); resolved by the existing Table 4b path. - A/B -> main_heating_index_number = a type-matched representative PCDB product; resolved by the existing PCDB Appendix D2.1 path (winter+summer+combi-loss). _fold_heating clears the base code, so the effective cert reads as a real PCDB cert. Ready for future user-supplied product -> PCDB id. Deletes seasonal_efficiency_override_pct from MainHeatingDetail, HeatingOverlay and _MAIN_HEATING_FIELDS, and REVERTS both cert_to_inputs branches — those four files are now byte-identical to main (no synthesis on the cert type, no new calculator branch). Each A/B PCDB id is CI-guarded to its accredited (winter, summer) so a PCDB refresh fails loudly. Oil A-combi (no >90% product, ≈0 homes) keeps its code-130 default. Real certs: A-combi +2.06 SAP (idx 18964), D-regular -0.98 (code 106); corpus unmoved; 810 tests green (ADR-0068). Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/epc_property_data.py | 6 - .../main_heating_system_overlay.py | 146 +++++++------- .../modelling/scoring/overlay_applicator.py | 1 - domain/modelling/simulation.py | 6 - .../sap10_calculator/rdsap/cert_to_inputs.py | 26 +-- .../test_boiler_efficiency_band_overlay.py | 189 +++++++++++------- .../rdsap/test_cert_to_inputs.py | 59 ------ .../test_landlord_override_overlays.py | 45 ++++- 8 files changed, 222 insertions(+), 256 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index e2d2906b0..a51a843cf 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -125,12 +125,6 @@ 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 6ff3f1719..5626cfc02 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -40,9 +40,6 @@ 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 @@ -247,17 +244,20 @@ _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. +# Boiler Efficiency Band → the boiler's **cert-native efficiency anchor** the +# overlay emits — 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. There is NO synthesised efficiency field: C-and-below emit the band's +# Table 4b `sap_main_heating_code`; A/B emit a representative PCDB +# `main_heating_index_number`. Both are real cert fields the calculator already +# resolves (Table 4b / PCDB Appendix D2.1), and the base code still drives the +# companion set (combi vs regular hot water). # -# 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 +# C-and-below → the band's Table 4b code. The code is itself combi/regular- +# specific (regular 102→106/101/105/115; combi 104→108/103/107/118), so system- +# type behaviour is preserved by the code alone. Oil's Table 4b codes are sparser +# and don't line up cleanly with SEDBUK bands, so oil E/F/G are 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) @@ -286,52 +286,54 @@ _BAND_TO_TABLE_4B_CODE: dict[tuple[int, BoilerEfficiencyBand], int] = { (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 -# validated against an **accredited Elmhurst build** (ADR-0068 gated pre-req). +# A/B exceed Table 4b's 84 % gas / 82–84 % oil ceiling, so there is no Table 4b +# code for them — the cert-native anchor is a representative PCDB product +# `main_heating_index_number`, the same mechanism a real cert (or a future +# user-supplied product) uses. The calculator's existing PCDB path resolves +# winter + summer + combi-loss from the record; `_fold_heating` clears the base +# code when an index is present, so the effective cert reads exactly like a real +# PCDB-lodged cert. Products are **type-matched** to the archetype (combi vs +# regular) because the combi-loss cascade reads the record's keep-hot/DHW profile. # -# GAS (validated 2026-07-29 on Elmhurst RdSAP10, dwelling ref 000567): -# A → PCDB 18790 (Worcester Greenstar 8000 Life): worksheet (206)=90.6 / (217)=81.6 -# B → PCDB 15029 (Baxi Duo-tec Combi 24 HE class): worksheet (206)=88.0 / (217)=79.4 -# Gas/LPG/biogas share codes 101–119, so one pair per band covers all three; applied -# to both gas regular (102) and combi (104). (Enabling A moves ~14.6k A-rated homes -# up ~+1–3 SAP — flagged to stakeholders; some cross a band → Recs/eligibility.) -# -# OIL (124–132): pinned from the accredited PCDB records we hold, applied to oil -# regular (127) and combi (130): -# A → PCDB 17292 (Firebird Enviromax Blue Supreme): (90.6, 82.8) -# B → PCDB 10498 (Worcester Greenstar Danesmoor): (88.0, 80.2) -# No separate Elmhurst build was needed: the gas builds above proved Elmhurst's -# worksheet (206)/(217) for a database boiler are its PCDB winter/summer read back -# verbatim (18790 → 90.6/81.6, 15029 → 88.0/79.4, both exact), per SAP 10.2 App -# D2.1 (PCDB overrides Table 4b). So the PCDB record IS the accredited value. -_BAND_SLOT_EFFICIENCY_PCT: dict[tuple[int, BoilerEfficiencyBand], tuple[float, float]] = { - (102, BoilerEfficiencyBand.A): (90.6, 81.6), - (104, BoilerEfficiencyBand.A): (90.6, 81.6), - (102, BoilerEfficiencyBand.B): (88.0, 79.4), - (104, BoilerEfficiencyBand.B): (88.0, 79.4), - (127, BoilerEfficiencyBand.A): (90.6, 82.8), - (130, BoilerEfficiencyBand.A): (90.6, 82.8), - (127, BoilerEfficiencyBand.B): (88.0, 80.2), - (130, BoilerEfficiencyBand.B): (88.0, 80.2), +# Provenance — gas A/B were built in accredited Elmhurst RdSAP10 (worksheet +# (206)/(217) == the PCDB record verbatim, SAP 10.2 App D2.1); that proved +# Elmhurst reads the PCDB winter/summer, which licensed the oil picks straight +# from the PCDB. Each id is CI-guarded (see test_boiler_efficiency_band_overlay) +# so a PCDB refresh that renumbers a product fails loudly, never silently +# mis-scores. NOTE: oil A combi (130, A) has no PCDB product above 90 % (only an +# "Illustrative" placeholder), so it is intentionally absent — an oil A-combi +# (≈0 dwellings) keeps its code-130 condensing default (82 %). +_BAND_TO_PCDB_INDEX: dict[tuple[int, BoilerEfficiencyBand], int] = { + (102, BoilerEfficiencyBand.A): 18790, # Worcester GS 8000 Life (gas/LPG reg) 90.6/81.6 + (104, BoilerEfficiencyBand.A): 18964, # SIME EDEA (gas combi) 90.1/86.6 + (102, BoilerEfficiencyBand.B): 8106, # Vaillant Ecomax (gas reg) 88.0/79.0 + (104, BoilerEfficiencyBand.B): 15029, # Baxi Duo-tec Combi (gas combi) 88.0/79.4 + (127, BoilerEfficiencyBand.A): 17292, # Firebird Enviromax (oil reg) 90.6/82.8 + (127, BoilerEfficiencyBand.B): 10498, # Worcester Danesmoor (oil reg) 88.0/80.2 + (130, BoilerEfficiencyBand.B): 15959, # Firebird Enviromax Combi (oil combi) 89.0/82.9 } -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) +def band_efficiency_anchor( + base_code: int, band: Optional[BoilerEfficiencyBand] +) -> tuple[int, Optional[int]]: + """The `(sap_main_heating_code, main_heating_index_number)` a boiler `base_code` + emits under a SEDBUK `band` (ADR-0068): + + - **A/B** → the base code kept + a representative PCDB index (the calculator's + PCDB path wins; `_fold_heating` then clears the code, giving a PCDB cert); + - **C–G** → the band's Table 4b code, no index (Table 4b path); + - no band / no applicable mapping (non-banded boiler, `UNKNOWN`, or the oil + A-combi gap) → the base code unchanged, no index. + """ + if band is not None: + index = _BAND_TO_PCDB_INDEX.get((base_code, band)) + if index is not None: + return base_code, index + table_4b_code = _BAND_TO_TABLE_4B_CODE.get((base_code, band)) + if table_4b_code is not None: + return table_4b_code, None + return base_code, None # Canonical system archetype → representative SAP `sap_main_heating_code`. Codes # map to the modern/condensing variant (A-G efficiency deferred): 102 regular @@ -503,16 +505,6 @@ def _natural_fuel_for(code: int) -> Optional[int]: return None -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: @@ -521,11 +513,15 @@ def _gas_boiler_overlay( 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). - 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).""" + The SEDBUK band (when supplied and applicable) picks the boiler's cert-native + efficiency anchor — the band's Table 4b `sap_main_heating_code` (C–G) or a + representative PCDB `main_heating_index_number` (A/B) — the fifth Heating + Companion (ADR-0068). The base `code` still drives the combi/regular hot-water + companion; `_fold_heating` clears the code when an index is emitted.""" + emitted_code, index = band_efficiency_anchor(code, band) return HeatingOverlay( - sap_main_heating_code=code, + sap_main_heating_code=emitted_code, + main_heating_index_number=index, main_heating_category=_GAS_BOILER_CATEGORY, main_fuel_type=_MAINS_GAS_FUEL, gas_connection_available=True, @@ -535,7 +531,6 @@ def _gas_boiler_overlay( 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), ) @@ -554,12 +549,14 @@ def _fuel_boiler_overlay( forces it True; and a solid-fuel boiler vents through a conventional (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).""" + The SEDBUK band applies only to the oil boilers here — `band_efficiency_anchor` + is a no-op for the solid-fuel code 151 (intrinsic-efficiency), returning the + code unchanged with no index — the fifth Heating Companion (ADR-0068).""" fuel = _natural_fuel_for(code) + emitted_code, index = band_efficiency_anchor(code, band) return HeatingOverlay( - sap_main_heating_code=code, + sap_main_heating_code=emitted_code, + main_heating_index_number=index, main_heating_category=_FUEL_BOILER_CATEGORY, main_fuel_type=fuel, main_heating_control=_FULL_BOILER_CONTROL, @@ -568,7 +565,6 @@ def _fuel_boiler_overlay( 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), ) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 5885d0eac..1a9357033 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -137,7 +137,6 @@ _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 cf0d33aa7..55c52855e 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -168,12 +168,6 @@ 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). diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 03919fc8d..3ec43e86b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2112,18 +2112,7 @@ def _main_heating_detail_efficiency( if main is not None and main.main_heating_index_number is not None else None ) - override_pct = ( - main.seasonal_efficiency_override_pct if main is not None else None - ) - if override_pct is not None: - # Landlord-Override SEDBUK-band efficiency — the fifth Heating Companion - # (ADR-0068). "Override wins": a system-replacing override makes the cert - # efficiency stale, so its `(winter, summer)` slot takes precedence over - # the Table 4b code default, occupying the same slot the PCDB - # `winter_efficiency_pct` does. Override-supplied only (lodged certs never - # set it), so the corpus is unmoved. - eff = override_pct[0] / 100.0 - elif pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None: + if pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None: eff = pcdb_main.winter_efficiency_pct / 100.0 else: eff = seasonal_efficiency(main_code, main_category, main_fuel) @@ -8165,21 +8154,8 @@ def cert_to_inputs( # primary loss but ARE still eligible for Eq D1 — the spec's # §D2.1 (2) test is "summer < winter" + "boiler provides both", # not the primary-loss test. - override_pct = main.seasonal_efficiency_override_pct if main is not None else None eq_d1_winter_summer_pct: Optional[tuple[float, float]] = None if ( - override_pct is not None - and epc.sap_heating.water_heating_code == _WHC_FROM_MAIN_HEATING - # Not when DHW is a separate electric immersion (WHC 903): the boiler - # does not provide water heating, so its efficiency must not reach Eq D1. - and not dhw_is_electric_immersion - ): - # Landlord-Override SEDBUK-band `(winter, summer)` — the fifth Heating - # Companion (ADR-0068). It wins the Eq D1 water slot the same way it wins - # the §206 winter efficiency, ahead of the PCDB/Table 4b defaults, so a - # banded boiler's summer efficiency drives its hot-water fuel too. - eq_d1_winter_summer_pct = override_pct - elif ( pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None and pcdb_main.summer_efficiency_pct is not None diff --git a/tests/domain/epc/test_boiler_efficiency_band_overlay.py b/tests/domain/epc/test_boiler_efficiency_band_overlay.py index d710fea80..0e644bbd3 100644 --- a/tests/domain/epc/test_boiler_efficiency_band_overlay.py +++ b/tests/domain/epc/test_boiler_efficiency_band_overlay.py @@ -4,133 +4,147 @@ from typing import Optional import pytest +from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand from domain.epc.property_overlays.main_heating_system_overlay import ( - band_seasonal_efficiency_pct, + band_efficiency_anchor, main_heating_overlay_for, ) +from domain.modelling.scoring.overlay_applicator import apply_simulations +from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +# --- band → cert-native efficiency anchor (code for C–G, PCDB index for A/B) --- @pytest.mark.parametrize( ("base_code", "band", "expected"), [ - # Gas regular (base 102) — C-and-below reuse the accredited Table 4b - # (winter, summer) of the code the band maps to (ADR-0068). - (102, BoilerEfficiencyBand.C, (84.0, 74.0)), # code 102 - (102, BoilerEfficiencyBand.D, (80.0, 70.0)), # code 106 - (102, BoilerEfficiencyBand.E, (74.0, 64.0)), # code 101 - (102, BoilerEfficiencyBand.F, (70.0, 60.0)), # code 105 - (102, BoilerEfficiencyBand.G, (66.0, 56.0)), # code 115 - # Gas combi (base 104). - (104, BoilerEfficiencyBand.C, (84.0, 75.0)), # code 104 - (104, BoilerEfficiencyBand.D, (80.0, 71.0)), # code 108 - (104, BoilerEfficiencyBand.G, (66.0, 57.0)), # code 118 - # Oil regular (base 127) — E/F nearest-fit to code 125 (⚠ ADR-0068). - (127, BoilerEfficiencyBand.C, (84.0, 72.0)), # code 127 - (127, BoilerEfficiencyBand.D, (80.0, 68.0)), # code 126 - (127, BoilerEfficiencyBand.E, (71.0, 59.0)), # code 125 - (127, BoilerEfficiencyBand.G, (66.0, 54.0)), # code 124 - # Oil combi (base 130) — D/E to code 129, F/G to code 128 (⚠ ADR-0068). - (130, BoilerEfficiencyBand.C, (82.0, 73.0)), # code 130 - (130, BoilerEfficiencyBand.D, (77.0, 68.0)), # code 129 - (130, BoilerEfficiencyBand.F, (71.0, 62.0)), # code 128 + # C-and-below → the band's Table 4b sap_main_heating_code, no index. The + # code is itself regular/combi-specific, so system type is preserved. + (102, BoilerEfficiencyBand.C, (102, None)), + (102, BoilerEfficiencyBand.D, (106, None)), + (102, BoilerEfficiencyBand.G, (115, None)), + (104, BoilerEfficiencyBand.D, (108, None)), + (104, BoilerEfficiencyBand.G, (118, None)), + (127, BoilerEfficiencyBand.E, (125, None)), + (130, BoilerEfficiencyBand.D, (129, None)), + # A/B → base code kept + a representative PCDB index (>84%, no Table 4b code). + (102, BoilerEfficiencyBand.A, (102, 18790)), + (104, BoilerEfficiencyBand.A, (104, 18964)), + (102, BoilerEfficiencyBand.B, (102, 8106)), + (104, BoilerEfficiencyBand.B, (104, 15029)), + (127, BoilerEfficiencyBand.A, (127, 17292)), + (127, BoilerEfficiencyBand.B, (127, 10498)), + (130, BoilerEfficiencyBand.B, (130, 15959)), ], ) -def test_c_and_below_bands_resolve_to_the_table_4b_pair( - base_code: int, band: BoilerEfficiencyBand, expected: tuple[float, float] +def test_band_resolves_to_the_cert_native_anchor( + base_code: int, band: BoilerEfficiencyBand, expected: tuple[int, Optional[int]] ) -> None: - assert band_seasonal_efficiency_pct(base_code, band) == expected + assert band_efficiency_anchor(base_code, band) == expected @pytest.mark.parametrize( - ("base_code", "band", "expected"), + ("base_code", "band"), [ - # Gas A/B validated against accredited Elmhurst (ADR-0068): A → PCDB 18790 - # (Worcester Greenstar 8000 Life) 90.6/81.6; B → PCDB 15029 (Baxi Duo-tec - # Combi 24 HE) 88.0/79.4. Gas/LPG/biogas share codes — one pair per band on - # both regular (102) and combi (104). - # Gas A/B — validated on accredited Elmhurst (PCDB 18790 / 15029). - (102, BoilerEfficiencyBand.A, (90.6, 81.6)), - (104, BoilerEfficiencyBand.A, (90.6, 81.6)), - (102, BoilerEfficiencyBand.B, (88.0, 79.4)), - (104, BoilerEfficiencyBand.B, (88.0, 79.4)), - # Oil A/B — pinned from the accredited PCDB records (17292 / 10498). The - # gas builds proved Elmhurst's (206)/(217) == the PCDB winter/summer, so - # the PCDB record is the accredited value (no separate oil build needed). - (127, BoilerEfficiencyBand.A, (90.6, 82.8)), - (130, BoilerEfficiencyBand.A, (90.6, 82.8)), - (127, BoilerEfficiencyBand.B, (88.0, 80.2)), - (130, BoilerEfficiencyBand.B, (88.0, 80.2)), + # No band → base code unchanged, no index. + (102, None), + (104, None), + # Oil A-combi gap: no PCDB product above 90% exists → keep the code-130 + # condensing default (82%). ≈0 dwellings (ADR-0068). + (130, BoilerEfficiencyBand.A), + # Non-banded boilers ignore the band letter entirely. + (151, BoilerEfficiencyBand.G), # solid fuel + (191, BoilerEfficiencyBand.A), # electric boiler + (192, BoilerEfficiencyBand.D), # electric CPSU + (120, BoilerEfficiencyBand.D), # gas CPSU ], ) -def test_a_and_b_bands_use_the_accredited_slot( - base_code: int, band: BoilerEfficiencyBand, expected: tuple[float, float] +def test_no_applicable_band_keeps_the_base_code_with_no_index( + base_code: int, band: Optional[BoilerEfficiencyBand] ) -> None: - # A/B exceed Table 4b's 84% ceiling (the under-credit the fix removes). - assert band_seasonal_efficiency_pct(base_code, band) == expected + assert band_efficiency_anchor(base_code, band) == (base_code, None) @pytest.mark.parametrize( - "base_code", + ("pcdb_id", "expected_winter_summer"), [ - 151, # Solid fuel boiler — Table 4a 75%, intrinsic (SEDBUK doesn't rate it) - 191, # Electric boiler — Table 4a 100%, intrinsic - 192, # Electric CPSU — intrinsic electric - 120, # Gas CPSU — non-condensing, not SEDBUK-banded + (18790, (90.6, 81.6)), # gas A regular — Worcester GS 8000 Life + (18964, (90.1, 86.6)), # gas A combi — SIME EDEA + (8106, (88.0, 79.0)), # gas B regular — Vaillant Ecomax + (15029, (88.0, 79.4)), # gas B combi — Baxi Duo-tec Combi + (17292, (90.6, 82.8)), # oil A regular — Firebird Enviromax + (10498, (88.0, 80.2)), # oil B regular — Worcester Danesmoor + (15959, (89.0, 82.9)), # oil B combi — Firebird Enviromax Combi ], ) -@pytest.mark.parametrize("band", list(BoilerEfficiencyBand)) -def test_non_banded_boilers_never_take_a_slot( - base_code: int, band: BoilerEfficiencyBand +def test_each_ab_pcdb_index_still_resolves_to_its_accredited_efficiency( + pcdb_id: int, expected_winter_summer: tuple[float, float] ) -> None: - assert band_seasonal_efficiency_pct(base_code, band) is None + # CI guard: a PCDB refresh that renumbers/removes a product must fail here + # loudly, never silently mis-score an A/B boiler (ADR-0068). + record = gas_oil_boiler_record(pcdb_id) + assert record is not None + assert (record.winter_efficiency_pct, record.summer_efficiency_pct) == ( + expected_winter_summer + ) + + +# --- overlay wiring (the HeatingOverlay a band produces) --- @pytest.mark.parametrize( - ("archetype", "band", "expected"), + ("archetype", "band", "code", "index"), [ - ("Gas boiler, regular", BoilerEfficiencyBand.D, (80.0, 70.0)), - ("Gas boiler, combi", BoilerEfficiencyBand.G, (66.0, 57.0)), - ("Oil boiler, regular", BoilerEfficiencyBand.E, (71.0, 59.0)), - ("Oil boiler, combi", BoilerEfficiencyBand.D, (77.0, 68.0)), - # Elmhurst-validated A/B upside on gas (the A under-credit fix). - ("Gas boiler, regular", BoilerEfficiencyBand.A, (90.6, 81.6)), - ("Gas boiler, combi", BoilerEfficiencyBand.A, (90.6, 81.6)), - ("Gas boiler, combi", BoilerEfficiencyBand.B, (88.0, 79.4)), + # C–G → emit the band Table 4b code, no index. + ("Gas boiler, regular", BoilerEfficiencyBand.D, 106, None), + ("Gas boiler, combi", BoilerEfficiencyBand.G, 118, None), + ("Oil boiler, regular", BoilerEfficiencyBand.E, 125, None), + ("Oil boiler, combi", BoilerEfficiencyBand.D, 129, None), + # A/B → keep the base code + set the representative PCDB index. + ("Gas boiler, regular", BoilerEfficiencyBand.A, 102, 18790), + ("Gas boiler, combi", BoilerEfficiencyBand.A, 104, 18964), + ("Gas boiler, combi", BoilerEfficiencyBand.B, 104, 15029), + ("Oil boiler, regular", BoilerEfficiencyBand.A, 127, 17292), + ("Oil boiler, combi", BoilerEfficiencyBand.B, 130, 15959), ], ) -def test_overlay_sets_the_band_efficiency_slot_on_gas_and_oil_boilers( - archetype: str, band: BoilerEfficiencyBand, expected: tuple[float, float] +def test_overlay_emits_the_band_anchor_on_gas_and_oil_boilers( + archetype: str, band: BoilerEfficiencyBand, code: int, index: Optional[int] ) -> None: simulation = main_heating_overlay_for(archetype, 0, band) assert simulation is not None assert simulation.heating is not None - # The base code is unchanged (it still drives combi/regular HW behaviour); - # only the efficiency slot is set from the band. - assert simulation.heating.seasonal_efficiency_override_pct == expected + assert simulation.heating.sap_main_heating_code == code + assert simulation.heating.main_heating_index_number == index @pytest.mark.parametrize( ("archetype", "band"), [ - # No band → no slot (today's behaviour: keep the condensing code default). + # No band → base code, no index (today's behaviour). ("Gas boiler, combi", None), - # Non-banded boilers ignore the band letter entirely. + # Oil A-combi gap → base code 130, no index. + ("Oil boiler, combi", BoilerEfficiencyBand.A), + # Non-banded boilers ignore the band entirely. ("Electric boiler", BoilerEfficiencyBand.D), - ("Electric boiler", BoilerEfficiencyBand.A), ("Solid fuel boiler", BoilerEfficiencyBand.G), ("Gas CPSU", BoilerEfficiencyBand.D), ], ) -def test_overlay_sets_no_slot_when_band_absent_or_not_applicable( +def test_overlay_sets_no_index_when_band_absent_or_not_applicable( archetype: str, band: Optional[BoilerEfficiencyBand] ) -> None: simulation = main_heating_overlay_for(archetype, 0, band) assert simulation is not None assert simulation.heating is not None - assert simulation.heating.seasonal_efficiency_override_pct is None + assert simulation.heating.main_heating_index_number is None def test_overlay_defaults_to_no_band_when_not_supplied() -> None: @@ -139,4 +153,31 @@ def test_overlay_defaults_to_no_band_when_not_supplied() -> None: assert simulation is not None assert simulation.heating is not None - assert simulation.heating.seasonal_efficiency_override_pct is None + assert simulation.heating.sap_main_heating_code == 104 + assert simulation.heating.main_heating_index_number is None + + +# --- effective cert (post-fold): A/B become a PCDB cert; C–G keep the code --- + + +def _apply_gas_combi(band: Optional[BoilerEfficiencyBand]) -> EpcPropertyData: + overlay = main_heating_overlay_for("Gas boiler, combi", 0, band) + assert overlay is not None + return apply_simulations(build_epc(), [overlay]) + + +def test_ab_effective_cert_is_a_pcdb_cert_with_the_code_cleared() -> None: + # `_fold_heating`'s index/code mutual-exclusion: an A/B override yields exactly + # a PCDB-lodged cert — index set, code cleared — which the calculator's PCDB + # Appendix D2.1 path resolves (winter+summer+combi-loss). + eff = _apply_gas_combi(BoilerEfficiencyBand.A) + main = eff.sap_heating.main_heating_details[0] + assert main.main_heating_index_number == 18964 + assert main.sap_main_heating_code is None + + +def test_c_to_g_effective_cert_keeps_the_band_table_4b_code() -> None: + eff = _apply_gas_combi(BoilerEfficiencyBand.G) + main = eff.sap_heating.main_heating_details[0] + assert main.sap_main_heating_code == 118 # gas combi G + assert main.main_heating_index_number is None diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index adf75734d..d20230c18 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -2885,65 +2885,6 @@ def test_living_area_rounds_half_up_at_2_dp_decimal_boundary_per_rdsap_15() -> N assert abs(fla_off_boundary - (14.06 / 46.87)) <= 1e-12 -def _epc_with_override_boiler( - base: object, seasonal_efficiency_override_pct: Optional[tuple[float, float]] -): - """A copy of the typical semi-detached cert whose primary gas boiler (Table 4b - code 102) carries a Landlord-Override `(winter, summer)` efficiency slot — the - fifth Heating Companion (ADR-0068).""" - import dataclasses - - typical = _typical_semi_detached_epc() - detail = dataclasses.replace( - _gas_boiler_detail(sap_main_heating_code=102), - seasonal_efficiency_override_pct=seasonal_efficiency_override_pct, - ) - return make_minimal_sap10_epc( - total_floor_area_m2=_TYPICAL_TFA_M2, - habitable_rooms_count=4, - region_code="1", - sap_building_parts=typical.sap_building_parts, - sap_windows=typical.sap_windows, - sap_heating=make_sap_heating(main_heating_details=[detail]), - ) - - -def test_seasonal_efficiency_override_wins_over_the_table_4b_code_default() -> None: - # A gas boiler override forces the SEDBUK-band efficiency ahead of the Table - # 4b code default: a G-rated boiler on code 102 (84%) is modelled at 66%, not - # the condensing 84% — the fix at the heart of #1704 / ADR-0068. "Override - # wins": the band-derived efficiency replaces the code's, symmetric up or down. - epc = _epc_with_override_boiler(None, (66.0, 56.0)) # G band (winter, summer) - - inputs = cert_to_inputs(epc) - - assert inputs.main_heating_efficiency == 0.66 - - -def test_no_override_keeps_the_table_4b_code_efficiency() -> None: - # Absent the slot, the code default stands (today's behaviour) — the override - # is the only thing that moves it. - epc = _epc_with_override_boiler(None, None) - - inputs = cert_to_inputs(epc) - - assert inputs.main_heating_efficiency == 0.84 - - -def test_seasonal_efficiency_override_feeds_the_eq_d1_water_summer_efficiency() -> None: - # The slot is a `(winter, summer)` pair; the summer leg drives SAP Appendix D - # §D2.1 Eq D1 water-heating efficiency (WHC 901, from main). A lower-band - # boiler burns less efficiently in summer, so it needs MORE hot-water fuel — - # a direction check that the override reaches the Eq D1 branch, not just §206. - default_code = _epc_with_override_boiler(None, None) # code 102 → summer 74% - g_band = _epc_with_override_boiler(None, (66.0, 56.0)) # summer 56% - - hw_default = cert_to_inputs(default_code).hot_water_kwh_per_yr - hw_g_band = cert_to_inputs(g_band).hot_water_kwh_per_yr - - assert hw_g_band > hw_default - - def test_main_heating_efficiency_reads_sap_main_heating_code() -> None: # Arrange — Direction check: a gas combi (Table 4b code 102, 84% eff) # vs a non-condensing gas boiler (code 105, 70% eff) must show through diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py index 37bf4bc19..30ea272f1 100644 --- a/tests/repositories/property/test_landlord_override_overlays.py +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -227,7 +227,7 @@ def test_main_heating_system_row_produces_a_heating_overlay() -> None: assert overlays[0].heating.sap_main_heating_code == 104 -def test_band_parsed_from_the_heating_description_sets_the_efficiency_slot() -> None: +def test_band_parsed_from_the_heating_description_sets_the_code() -> None: # Ship-before-FE (parse-on-read): the SEDBUK band is already carried in the # main_heating_system row's original_spreadsheet_description, so the modelling # honours it without waiting for the new boiler_efficiency_band override row. @@ -246,8 +246,10 @@ def test_band_parsed_from_the_heating_description_sets_the_efficiency_slot() -> assert len(overlays) == 1 assert overlays[0].heating is not None - # G-rated gas regular → Table 4b (66, 56), not the condensing 84% default. - assert overlays[0].heating.seasonal_efficiency_override_pct == (66.0, 56.0) + # G-rated gas regular → the band's Table 4b code 115 (66/56), not the + # condensing 84% default (102). + assert overlays[0].heating.sap_main_heating_code == 115 + assert overlays[0].heating.main_heating_index_number is None def test_explicit_band_override_row_wins_over_the_parsed_description() -> None: @@ -268,8 +270,8 @@ def test_explicit_band_override_row_wins_over_the_parsed_description() -> None: overlays = overlays_from(overrides) heating = next(o.heating for o in overlays if o.heating is not None) - # D wins over the description's G → Table 4b (80, 70). - assert heating.seasonal_efficiency_override_pct == (80.0, 70.0) + # D wins over the description's G → Table 4b code 106 (80/70), not 115. + assert heating.sap_main_heating_code == 106 def test_band_repoints_with_the_resolved_fuel_for_an_oil_boiler() -> None: @@ -290,12 +292,34 @@ def test_band_repoints_with_the_resolved_fuel_for_an_oil_boiler() -> None: overlays = overlays_from(overrides) heating = next(o.heating for o in overlays if o.heating is not None) - assert heating.sap_main_heating_code == 127 # oil regular - assert heating.seasonal_efficiency_override_pct == (80.0, 68.0) # oil D → code 126 + # Oil regular D → the oil Table 4b code 126 (80/68), off the base oil code 127. + assert heating.sap_main_heating_code == 126 + assert heating.main_heating_index_number is None -def test_no_band_information_leaves_the_efficiency_slot_unset() -> None: - # A plain boiler description carries no band → no slot → condensing default. +def test_a_band_sets_a_pcdb_index_not_a_table_4b_code() -> None: + # A/B exceed Table 4b's ceiling → the overlay emits a representative PCDB + # index (base code kept; _fold clears it into a PCDB cert). + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", + 0, + "Gas boiler, combi", + original_spreadsheet_description="Boiler: A rated Combi", + ), + ) + ) + + overlays = overlays_from(overrides) + + heating = next(o.heating for o in overlays if o.heating is not None) + assert heating.main_heating_index_number == 18964 # gas A combi (SIME EDEA) + assert heating.sap_main_heating_code == 104 # base kept; _fold clears it + + +def test_no_band_information_leaves_the_base_code_unchanged() -> None: + # A plain boiler description carries no band → base condensing code, no index. overrides = ResolvedPropertyOverrides( rows=( ResolvedPropertyOverride( @@ -310,7 +334,8 @@ def test_no_band_information_leaves_the_efficiency_slot_unset() -> None: overlays = overlays_from(overrides) heating = next(o.heating for o in overlays if o.heating is not None) - assert heating.seasonal_efficiency_override_pct is None + assert heating.sap_main_heating_code == 104 + assert heating.main_heating_index_number is None def test_unresolvable_rows_are_skipped() -> None: From 10acaa48cca9bbfa3119019607877cbc8eedcadc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Jul 2026 13:08:08 +0000 Subject: [PATCH 18/22] =?UTF-8?q?docs(adr):=20ADR-0068=20mechanism=20?= =?UTF-8?q?=E2=80=94=20cert-native=20anchors,=20not=20a=20synthesis=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 4 + Status rewritten: band → Table 4b code (C-G) / PCDB index (A/B), carried by cert-native fields only. Records the review rejection of the (winter,summer) slot on MainHeatingDetail. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ncy-band-is-the-fifth-heating-companion.md | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md b/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md index f6c070299..3df68eac5 100644 --- a/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md +++ b/docs/adr/0068-boiler-efficiency-band-is-the-fifth-heating-companion.md @@ -2,14 +2,16 @@ ## Status -accepted — implemented & live (band parser + overlay `(winter, summer)` slot + -calculator precedence + classifier/backfill). C-and-below from accredited Table -4b; **A/B validated and live for gas/LPG/biogas and oil** — gas from accredited -Elmhurst RdSAP10 builds (PCDB 18790 → 90.6/81.6, 15029 → 88.0/79.4), oil pinned -from the accredited PCDB records (17292 → 90.6/82.8, 10498 → 88.0/80.2) after the -gas builds proved Elmhurst's worksheet (206)/(217) == the PCDB winter/summer -verbatim (SAP 10.2 App D2.1). Whole-dwelling check: A-rated cert 15017550 +1.78 -SAP; gov-API RdSAP corpus unmoved. +accepted — implemented & live. Band → **cert-native efficiency anchor** (C-and- +below: the band's Table 4b `sap_main_heating_code`; A/B: a representative PCDB +`main_heating_index_number`) — no synthesised field on the cert datatype (see +Decision 4). **A/B live for gas/LPG/biogas and oil**: gas from accredited Elmhurst +RdSAP10 builds (PCDB 18790 reg / 15029 combi; 18964 gas-A-combi, 8106 gas-B-reg), +oil from accredited PCDB (17292, 10498, 15959) after the gas builds proved +Elmhurst's worksheet (206)/(217) == the PCDB winter/summer verbatim (SAP 10.2 App +D2.1). Oil A-combi has no >90 % product (≈0 homes) → keeps its code-130 default. +Each A/B id CI-guarded. Whole-dwelling: A-combi cert 15017550 +2.06 SAP (via PCDB +idx), D-regular −0.98 (via code); gov-API RdSAP corpus unmoved. ## Context @@ -68,17 +70,30 @@ gave us), distinct from the sibling **fuel-override data-quality** question policy ("we only correct downward") that a later reader would silently "fix" into (a) anyway. -4. **Mechanism: `(archetype + fuel) → base code`, then `band → (winter, summer) - efficiency slot` that overrides the code's default.** The **archetype and its - resolved fuel** pick the *base* `sap_main_heating_code` — this is already - ADR-0067's "resolve fuel at resolution" (`Gas boiler, regular` + an oil - `main_fuel` → `Oil boiler, regular` → an oil code), and the code still drives - system-type behaviour (combi vs regular hot water, etc.). The **band** then - sets a `(winter, summer)` seasonal-efficiency pair on the overlay / - `MainHeatingDetail` that the calculator consumes **ahead of** the code's - Table 4b default — mirroring the existing PCDB precedence - (`pcdb_main.winter_efficiency_pct`, `cert_to_inputs.py:2103`). So efficiency is - looked up by **(fuel family, boiler type, band)**, never a single annual number. +4. **Mechanism: `(archetype + fuel) → base code`, then `band → a cert-native + efficiency anchor`.** The **archetype and its resolved fuel** pick the *base* + `sap_main_heating_code` — ADR-0067's "resolve fuel at resolution" (`Gas boiler, + regular` + an oil `main_fuel` → `Oil boiler, regular` → an oil code). The + **band** then picks the boiler's efficiency using the **same fields a real + cert carries** — deliberately *not* a synthesised efficiency field on the cert + datatype (an earlier draft added a `(winter, summer)` slot on + `MainHeatingDetail`; rejected in review as it had no lodged-cert analogue and + polluted the API-cert type): + - **C-and-below** → the band's **Table 4b `sap_main_heating_code`** (regular + 102→106/101/105/115; combi 104→108/103/107/118). The code is itself + combi/regular-specific, so system-type behaviour is preserved by the code + alone; the existing Table 4b path resolves the efficiency. + - **A/B** (exceed Table 4b's 84 % ceiling, so no code exists) → a + **representative PCDB `main_heating_index_number`**, type-matched to the + archetype (combi vs regular, because the combi-loss cascade reads the + record). `_fold_heating` clears the base code when an index is present, so + the **Effective EPC reads as a real PCDB-lodged cert**, resolved by the + existing PCDB Appendix D2.1 path (winter + summer + combi-loss). This is also + the natural on-ramp to a **future user-supplied product → PCDB id**. + + So efficiency is looked up by **(fuel family, boiler type, band)** and carried + by cert-native fields only — never a synthesised number on the cert type. Each + A/B product id is CI-guarded to its accredited `(winter, summer)`. 5. **The band mechanism covers exactly the SEDBUK-rated wet boilers — gas and oil — and both are currently inflated.** Mains gas, LPG and biogas **share** the From eb07b916141202966223e3866e2c031d7afcc000 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Jul 2026 14:57:11 +0000 Subject: [PATCH 19/22] review: guard-only band classifier, backfill count, docstring (PR #1713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses reviewer feedback: - #3 (Khalim + kimjunte): the boiler_efficiency_band classifier column is now GUARD-ONLY — LLM fallback removed. The band format is deterministic, so an LLM guess could only fabricate a band the landlord never stated (moves SAP + eligibility). New LoggingUnknownColumnClassifier is the non-fabricating fallback: maps guard-misses to UNKNOWN (never stored) and logs them for review — kimjunte's 'warn on None', placed in the classifier path not the shared guard (which sees legitimate None constantly on the modelling/backfill paths). - #2 (Khalim): backfill --apply now reports rows ACTUALLY written (upsert rowcount), not the candidate count, so an idempotent re-run reports 0. - #1 (Khalim): overlay module docstring scrubbed of stale 'slot/pending' wording to match the cert-native anchor mechanism. kimjunte's finaliser optional-skip question (declared-vs-shared-column flow) left for reviewer alignment, not changed. 529 tests green; pyright clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../landlord_description_overrides/handler.py | 18 +++-- .../logging_unknown_column_classifier.py | 50 ++++++++++++ .../main_heating_system_overlay.py | 20 +++-- scripts/backfill_boiler_efficiency_band.py | 52 ++++++++----- .../test_logging_unknown_column_classifier.py | 78 +++++++++++++++++++ 5 files changed, 184 insertions(+), 34 deletions(-) create mode 100644 domain/data_transformation/logging_unknown_column_classifier.py create mode 100644 tests/domain/data_transformation/test_logging_unknown_column_classifier.py diff --git a/applications/landlord_description_overrides/handler.py b/applications/landlord_description_overrides/handler.py index 12d49c78c..3b53fec30 100644 --- a/applications/landlord_description_overrides/handler.py +++ b/applications/landlord_description_overrides/handler.py @@ -26,6 +26,9 @@ from domain.epc.property_overrides.roof_guard import roof_guard from domain.data_transformation.guarded_column_classifier import ( GuardedColumnClassifier, ) +from domain.data_transformation.logging_unknown_column_classifier import ( + LoggingUnknownColumnClassifier, +) from domain.epc.property_overrides.water_heating_type import WaterHeatingType from domain.epc.property_overrides.water_heating_guard import water_heating_guard from domain.epc.property_overrides.wall_type import WallType @@ -234,14 +237,17 @@ def _build_columns( source_column=src, # The SEDBUK band rides the SAME "Heating" source column as # main_heating_system (like Property Type feeds property_type + - # built_form_type). The deterministic guard extracts the structured - # `Boiler: rated ...` band — authoritative for the format — and - # the LLM is the fallback for oddities, returning UNKNOWN (never - # stored) for a plain boiler or a non-boiler heating system (ADR-0068). + # built_form_type). The band's `Boiler: rated ...` format is + # fully deterministic, so the guard is authoritative and the column is + # **guard-only** — NO LLM fallback. An LLM guess could fabricate a band + # the landlord never stated, which here moves SAP + funding eligibility + # (reviewer feedback). Unrecognised descriptions (a plain boiler, a + # non-boiler heating system) become UNKNOWN — never stored — and are + # logged for review (ADR-0068). classifier=GuardedColumnClassifier( guard=boiler_efficiency_band_guard, - fallback=ChatGptColumnClassifier( - chat_gpt, BoilerEfficiencyBand, BoilerEfficiencyBand.UNKNOWN + fallback=LoggingUnknownColumnClassifier( + BoilerEfficiencyBand.UNKNOWN, "boiler_efficiency_band" ), ), repo=LandlordOverridesRepository[BoilerEfficiencyBand]( diff --git a/domain/data_transformation/logging_unknown_column_classifier.py b/domain/data_transformation/logging_unknown_column_classifier.py new file mode 100644 index 000000000..91a7c3c85 --- /dev/null +++ b/domain/data_transformation/logging_unknown_column_classifier.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging +from enum import Enum +from typing import TypeVar + +from domain.data_transformation.column_classifier import ColumnClassifier + +logger = logging.getLogger(__name__) + +E = TypeVar("E", bound=Enum) + +# Cap the sample of unresolved descriptions logged, so a large batch surfaces the +# surprise without flooding the log. +_SAMPLE_SIZE = 10 + + +class LoggingUnknownColumnClassifier(ColumnClassifier[E]): + """A ``ColumnClassifier`` that resolves nothing — it maps every description to + the enum's ``unknown`` member and logs a warning naming the column and a sample + of what it saw. + + Its purpose is to be a **deterministic, non-fabricating fallback** for a + ``GuardedColumnClassifier`` whose guard is authoritative for the column's + format. Where the LLM fallback would *guess* a category (and could invent a + value the landlord never stated — high-stakes for the Boiler Efficiency Band, + which moves SAP + funding eligibility), this fallback records ``unknown`` and + surfaces the misses for review instead. Guard hits never reach it, so only the + genuinely unrecognised descriptions are logged. + """ + + def __init__(self, unknown: E, column_name: str) -> None: + self._unknown = unknown + self._column_name = column_name + + def classify(self, descriptions: set[str]) -> dict[str, E]: + if not descriptions: + return {} + sample = sorted(descriptions)[:_SAMPLE_SIZE] + more = "" if len(descriptions) <= _SAMPLE_SIZE else f" (+{len(descriptions) - _SAMPLE_SIZE} more)" + logger.warning( + "%s: %d description(s) not recognised by the guard -> recorded %r " + "(never fabricated, not persisted); sample: %s%s", + self._column_name, + len(descriptions), + self._unknown.value, + sample, + more, + ) + return {description: self._unknown for description in descriptions} diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 5626cfc02..a958fac0a 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -19,14 +19,18 @@ adding its code — coherent companions fall out. Synthesis owns coherence; the calculator never normalises a lodged cert. 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 +fifth Heating Companion (ADR-0068), via the boiler's **cert-native efficiency +anchor** — no synthesised efficiency field. The `band` argument (see +`band_efficiency_anchor`) picks: + - **C-and-below** → the band's Table 4b `sap_main_heating_code` (the code is + itself combi/regular-specific, so hot-water behaviour is preserved), so an + old low-rated boiler is modelled at its band efficiency, not the condensing + 84 %; + - **A/B** (exceed Table 4b's 84 % ceiling) → a representative PCDB + `main_heating_index_number`; `_fold_heating` clears the base code, so the + effective cert reads as a real PCDB-lodged cert. +Solid-fuel / electric / CPSU boilers are intrinsic-efficiency and take no band +(the anchor is a no-op). 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. """ diff --git a/scripts/backfill_boiler_efficiency_band.py b/scripts/backfill_boiler_efficiency_band.py index 2d95309dd..ff083e626 100644 --- a/scripts/backfill_boiler_efficiency_band.py +++ b/scripts/backfill_boiler_efficiency_band.py @@ -117,8 +117,13 @@ _UPSERT_BAND = text( def backfill(conn: Connection, *, apply: bool) -> int: """Backfill the ``boiler_efficiency_band`` rows off the existing - ``main_heating_system`` descriptions. Returns the number of band rows the run - writes (or would write, in dry-run).""" + ``main_heating_system`` descriptions. + + In ``--apply`` returns the number of rows **actually written** — the upsert's + ``WHERE override_value <> EXCLUDED.override_value`` no-ops rows already at the + target band, so an idempotent re-run reports 0, not the candidate count. In + dry-run returns the number of **candidate** rows (it cannot know how many + differ without writing).""" rows = [ MainHeatingRow( property_id=r.property_id, @@ -129,21 +134,24 @@ def backfill(conn: Connection, *, apply: bool) -> int: for r in conn.execute(_SELECT_MAIN_HEATING) ] to_write = band_backfill_rows(rows) - if apply: - for band_row in to_write: - conn.execute( - _UPSERT_BAND, - { - "property_id": band_row.property_id, - "portfolio_id": band_row.portfolio_id, - "building_part": band_row.building_part, - "override_value": band_row.override_value, - "original_spreadsheet_description": ( - band_row.original_spreadsheet_description - ), - }, - ) - return len(to_write) + if not apply: + return len(to_write) + written = 0 + for band_row in to_write: + result = conn.execute( + _UPSERT_BAND, + { + "property_id": band_row.property_id, + "portfolio_id": band_row.portfolio_id, + "building_part": band_row.building_part, + "override_value": band_row.override_value, + "original_spreadsheet_description": ( + band_row.original_spreadsheet_description + ), + }, + ) + written += result.rowcount or 0 + return written def main() -> None: @@ -161,13 +169,17 @@ def main() -> None: conn.execute(text("SET statement_timeout = 120000")) total = backfill(conn, apply=args.apply) - verb = "backfilled" if args.apply else "would backfill" + noun = "row(s) written" if args.apply else "candidate row(s)" print( - f"{verb} {total} boiler_efficiency_band override row(s) from the existing " + f"{total} boiler_efficiency_band {noun} from the existing " "main_heating_system descriptions (Boiler: rated ...). The modelling " "gate (gas/oil boilers only) is applied at overlay time (ADR-0068)." ) - if not args.apply: + if args.apply: + print( + "(Rows already at their target band are no-ops, so a re-run reports 0.)" + ) + else: print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") diff --git a/tests/domain/data_transformation/test_logging_unknown_column_classifier.py b/tests/domain/data_transformation/test_logging_unknown_column_classifier.py new file mode 100644 index 000000000..d92766bbd --- /dev/null +++ b/tests/domain/data_transformation/test_logging_unknown_column_classifier.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import logging +from enum import Enum +from typing import Optional + +import pytest + +from domain.data_transformation.guarded_column_classifier import ( + GuardedColumnClassifier, +) +from domain.data_transformation.logging_unknown_column_classifier import ( + LoggingUnknownColumnClassifier, +) + + +class _Band(Enum): + A = "A" + D = "D" + UNKNOWN = "Unknown" + + +def test_maps_every_description_to_unknown() -> None: + classifier = LoggingUnknownColumnClassifier(_Band.UNKNOWN, "boiler_efficiency_band") + + assert classifier.classify({"x", "y"}) == {"x": _Band.UNKNOWN, "y": _Band.UNKNOWN} + + +def test_logs_a_warning_naming_the_column_and_a_sample( + caplog: pytest.LogCaptureFixture, +) -> None: + classifier = LoggingUnknownColumnClassifier(_Band.UNKNOWN, "boiler_efficiency_band") + + with caplog.at_level(logging.WARNING): + classifier.classify({"Community heating"}) + + assert "boiler_efficiency_band" in caplog.text + assert "Community heating" in caplog.text + + +def test_empty_input_neither_logs_nor_returns_rows( + caplog: pytest.LogCaptureFixture, +) -> None: + classifier = LoggingUnknownColumnClassifier(_Band.UNKNOWN, "boiler_efficiency_band") + + with caplog.at_level(logging.WARNING): + result = classifier.classify(set()) + + assert result == {} + assert caplog.records == [] + + +def _band_guard(description: str) -> Optional[_Band]: + # A tiny stand-in guard: recognises "A rated" / "D rated", else None. + for member in (_Band.A, _Band.D): + if f"{member.value} rated" in description: + return member + return None + + +def test_guard_only_composition_never_reaches_an_llm( + caplog: pytest.LogCaptureFixture, +) -> None: + # The band column is guard-only: recognised bands resolve deterministically, + # and unrecognised descriptions become UNKNOWN (logged) — never an LLM guess + # that could fabricate a band (reviewer feedback). + classifier = GuardedColumnClassifier( + guard=_band_guard, + fallback=LoggingUnknownColumnClassifier(_Band.UNKNOWN, "boiler_efficiency_band"), + ) + + with caplog.at_level(logging.WARNING): + result = classifier.classify({"Boiler: D rated Combi", "Gas boiler"}) + + assert result == {"Boiler: D rated Combi": _Band.D, "Gas boiler": _Band.UNKNOWN} + # Only the miss is logged; the guarded hit is not. + assert "Gas boiler" in caplog.text + assert "D rated" not in caplog.text From 1af3860528ff742372447d387e6cbea1ad080e42 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Jul 2026 15:05:27 +0000 Subject: [PATCH 20/22] review: finaliser records an explicit Unknown band instead of skipping kimjunte + Khalim alignment: when the band column is mapped and a description can't be resolved to A-G, write the row with an explicit Unknown value rather than silently skipping. The landlord declared the column, so every row gets a value; 'assessed, no band' is now distinct from 'never assessed'. Still never fails the finalise (unlike the mandatory components). Modelling reads Unknown as no band (no efficiency anchor), so it stays inert to SAP. Requires the FE boiler_efficiency_band pgEnum to include 'Unknown' (assessment- model#486 updated). 7 finaliser tests green; pyright clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bulk_upload_finaliser_orchestrator.py | 50 +++++++++++-------- ...test_bulk_upload_finaliser_orchestrator.py | 19 ++++--- 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/orchestration/bulk_upload_finaliser_orchestrator.py b/orchestration/bulk_upload_finaliser_orchestrator.py index 5d0bd9568..77a545856 100644 --- a/orchestration/bulk_upload_finaliser_orchestrator.py +++ b/orchestration/bulk_upload_finaliser_orchestrator.py @@ -14,6 +14,7 @@ from typing import Any, Optional from uuid import UUID +from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand from domain.epc.property_overrides.built_form_type import BuiltFormType from domain.epc.property_overrides.property_type import PropertyType from domain.epc.property_overrides.roof_type import RoofType @@ -61,12 +62,16 @@ UNKNOWN_VALUES = frozenset( } ) -# Override components that are OPTIONAL per cell: present on some, legitimately -# absent on others. An unresolved/UNKNOWN value is skipped (no row) rather than -# failing the finalise the way a mandatory component does. The Boiler Efficiency -# Band (ADR-0068) exists only for a SEDBUK-rated boiler, so a plain boiler or a -# non-boiler heating cell has none. -_OPTIONAL_COMPONENTS = frozenset({"boiler_efficiency_band"}) +# Override components whose unresolved/UNKNOWN value is recorded EXPLICITLY as +# Unknown rather than failing the finalise the way a mandatory component does. +# The landlord declared the column, so we always write a row — an undeterminable +# Boiler Efficiency Band (ADR-0068; a non-boiler heating cell, or a plain boiler +# with no SEDBUK letter) is stored as Unknown, keeping "assessed, no band" +# distinct from "never assessed". The modelling reads Unknown as no band (no +# efficiency anchor), so it is inert to SAP; it is not persisted downstream of the +# override read. +_UNKNOWN_MAPPED_COMPONENTS = frozenset({"boiler_efficiency_band"}) +_BOILER_EFFICIENCY_BAND_UNKNOWN = BoilerEfficiencyBand.UNKNOWN.value def _split_entries(cell: Any) -> list[str]: @@ -316,23 +321,24 @@ class BulkUploadFinaliserOrchestrator: for building_part, file_pos in enumerate(permutation): raw = entries[file_pos] value = component_vocab.get(raw.lower()) - if component in _OPTIONAL_COMPONENTS and ( - value is None or value in UNKNOWN_VALUES - ): - # An OPTIONAL override is present on some cells and absent - # on others by design — a Boiler Efficiency Band only - # exists for a SEDBUK-rated boiler (ADR-0068), so a plain - # boiler or a non-boiler heating cell resolves to UNKNOWN. - # That is a legitimate "no value": skip it (no row), don't - # fail the finalise the way a mandatory component does. - continue if value is None or value in UNKNOWN_VALUES: - raise ValueError( - f"Unresolved {component} description {raw!r} " - f"(row {row_id}, portfolio {portfolio_id}): no resolved " - f"value{' (UNKNOWN)' if value else ''}. The verify gate " - f"should have mapped it; failing the finalise (ADR-0006)." - ) + if component in _UNKNOWN_MAPPED_COMPONENTS: + # Record an EXPLICIT Unknown (ADR-0068; kimjunte): the + # landlord declared the column, so we always write a + # row — an undeterminable band (non-boiler cell, or a + # plain boiler with no SEDBUK letter) is Unknown, not + # skipped and not a hard failure. Modelling reads it as + # no band. Distinguishes "assessed, no band" from + # "never assessed". + value = _BOILER_EFFICIENCY_BAND_UNKNOWN + else: + raise ValueError( + f"Unresolved {component} description {raw!r} " + f"(row {row_id}, portfolio {portfolio_id}): no " + f"resolved value{' (UNKNOWN)' if value else ''}. The " + f"verify gate should have mapped it; failing the " + f"finalise (ADR-0006)." + ) inserts.append( PropertyOverrideInsert( property_id=property_id, diff --git a/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py b/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py index 3ce8b8dcc..38dba15d2 100644 --- a/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py +++ b/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py @@ -262,10 +262,12 @@ def test_finalise_writes_the_boiler_efficiency_band_alongside_the_heating_system assert (band.building_part, band.override_value) == (0, "D") -def test_finalise_skips_an_unknown_boiler_efficiency_band_without_failing() -> None: - # A non-boiler heating system (or a plain boiler) carries no band, so the band - # classifier returns UNKNOWN. Unlike the mandatory components, an UNKNOWN band - # is a legitimate "no value" — skip it, don't fail the finalise (ADR-0068). +def test_finalise_records_unknown_when_the_band_cannot_be_mapped() -> None: + # A description with no determinable band (a non-boiler heating system, or a + # plain boiler) is recorded as an EXPLICIT Unknown band, not skipped: if the + # landlord declared the band column we always write a row, so "assessed, no + # band" is distinguishable from "never assessed" (ADR-0068; kimjunte). Unlike + # the mandatory components it does NOT fail the finalise. combiner = [{"address2uprn_uprn": "100023", "source_row_id": "row-a"}] classifier = [ {"Heating": "Community Heating Systems: Community boilers only (RdSAP)", @@ -294,11 +296,12 @@ def test_finalise_skips_an_unknown_boiler_efficiency_band_without_failing() -> N }, ) - # The heating system row is written; the UNKNOWN band produced no row. + # The heating row is written, AND an explicit Unknown band row alongside it. assert any(o.override_component == "main_heating_system" for o in overrides.upserted) - assert not any( - o.override_component == "boiler_efficiency_band" for o in overrides.upserted - ) + (band,) = [ + o for o in overrides.upserted if o.override_component == "boiler_efficiency_band" + ] + assert band.override_value == "Unknown" def test_finalise_fails_loudly_on_unresolved_description() -> None: From 50554ff57e85ae14faee4d986f8be7b9f93783cc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Jul 2026 15:58:38 +0000 Subject: [PATCH 21/22] review: backfill writes an explicit Unknown band too (matches finaliser) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the historical backfill with the go-forward finaliser: it now writes a boiler_efficiency_band row for EVERY main_heating_system row — the parseable band (A-G) where present, else an explicit Unknown — so historical and go-forward data match. Unknown is fine on non-boilers (inert to modelling). Confirmed with Khalim. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/backfill_boiler_efficiency_band.py | 34 ++++++++++++------- .../test_backfill_boiler_efficiency_band.py | 18 +++++++--- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/scripts/backfill_boiler_efficiency_band.py b/scripts/backfill_boiler_efficiency_band.py index ff083e626..01271d694 100644 --- a/scripts/backfill_boiler_efficiency_band.py +++ b/scripts/backfill_boiler_efficiency_band.py @@ -7,12 +7,14 @@ override rows for the existing stock by re-parsing that text with the SAME guard live classifier uses (``boiler_efficiency_band_guard``), so the backfill and the forward path cannot drift. -One ``boiler_efficiency_band`` row per ``main_heating_system`` row whose description -carries a band (``A``–``G``), keyed to the same ``(property_id, building_part)``. -Descriptions with no band (a plain boiler, a non-boiler heating system) get no row. -The modelling gate (gas/oil boilers only, ADR-0068) is applied at overlay time, not -here — so a band parsed off an electric ``Boiler: A rated NA`` is stored for -fidelity but ignored by the calculator, exactly as the live classifier stores it. +One ``boiler_efficiency_band`` row per ``main_heating_system`` row, keyed to the +same ``(property_id, building_part)`` — the parseable band (``A``–``G``) where the +description carries one, else an explicit ``Unknown``. This mirrors the finaliser +(which records Unknown rather than skipping), so backfilled historical data matches +go-forward data. The modelling gate (gas/oil boilers only, ADR-0068) is applied at +overlay time, not here — so a band parsed off an electric ``Boiler: A rated NA`` is +stored for fidelity but ignored by the calculator, and ``Unknown`` (incl. on a +non-boiler system) is inert to the calculator, exactly as the live path stores them. GATED: the ``boiler_efficiency_band`` value of the FE-owned ``override_component`` pgEnum must exist first (the Drizzle migration in the assessment-model repo). Until @@ -63,24 +65,30 @@ class BandBackfillRow: def band_backfill_rows(rows: Iterable[MainHeatingRow]) -> list[BandBackfillRow]: - """The ``boiler_efficiency_band`` rows to write for a set of - ``main_heating_system`` rows — one per row whose description carries a - parseable SEDBUK band, keyed to the same property + building part. Rows with - no band (or the ``UNKNOWN`` sentinel) produce nothing. + """The ``boiler_efficiency_band`` row to write for **every** + ``main_heating_system`` row, keyed to the same property + building part — the + parseable SEDBUK band (``A``–``G``) where the description carries one, else an + explicit ``Unknown``. This mirrors the finaliser (which records Unknown rather + than skipping), so backfilled historical data matches go-forward data; + ``Unknown`` is inert to modelling (no efficiency anchor) and fine on a + non-boiler system. Pure and DB-free, so the parse/decide logic is unit-tested without a database; the connection wrapper below just feeds it rows and upserts.""" backfill: list[BandBackfillRow] = [] for row in rows: band = boiler_efficiency_band_guard(row.original_spreadsheet_description) - if band is None or band is BoilerEfficiencyBand.UNKNOWN: - continue + value = ( + band.value + if band is not None and band is not BoilerEfficiencyBand.UNKNOWN + else BoilerEfficiencyBand.UNKNOWN.value + ) backfill.append( BandBackfillRow( property_id=row.property_id, portfolio_id=row.portfolio_id, building_part=row.building_part, - override_value=band.value, + override_value=value, original_spreadsheet_description=row.original_spreadsheet_description, ) ) diff --git a/tests/scripts/test_backfill_boiler_efficiency_band.py b/tests/scripts/test_backfill_boiler_efficiency_band.py index c94220201..9319564af 100644 --- a/tests/scripts/test_backfill_boiler_efficiency_band.py +++ b/tests/scripts/test_backfill_boiler_efficiency_band.py @@ -30,14 +30,22 @@ def test_backfills_a_band_row_per_boiler_with_a_parseable_band() -> None: ] -def test_leaves_descriptions_without_a_band_untouched() -> None: +def test_records_unknown_for_descriptions_without_a_band() -> None: + # Aligned with the finaliser: every main_heating_system row gets a band row — + # a description with no determinable band (plain boiler, non-boiler system) is + # recorded as an explicit Unknown, so historical + go-forward data match. + # Unknown is fine for non-boilers (inert to modelling). rows = [ - _row("Gas boiler"), - _row("Community Heating Systems: Community boilers only (RdSAP)"), - _row(""), + _row("Gas boiler", property_id=1), + _row("Community Heating Systems: Community boilers only (RdSAP)", property_id=2), ] - assert band_backfill_rows(rows) == [] + result = band_backfill_rows(rows) + + assert [(r.property_id, r.override_value) for r in result] == [ + (1, "Unknown"), + (2, "Unknown"), + ] def test_uses_the_same_guard_as_the_live_path_for_multi_system_and_electric() -> None: From 3e4b8bd781d11849d571df97529812d9f6657be7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Jul 2026 16:40:46 +0000 Subject: [PATCH 22/22] fix: wire boiler_efficiency_band into the reader registry (consistency guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_override_component_consistency caught two real gaps: the band was in the override_component pgEnum but (a) missing from _ROW_TYPES — so the finaliser couldn't load its classified vocab (a latent bug the injected-vocab finaliser tests missed), and (b) not in _COMPONENT_OVERLAYS (correct — it's an attribute consumed by the main_heating_system overlay, not a standalone one). Add the band to _ROW_TYPES (reader), and introduce _ATTRIBUTE_COMPONENTS to name the classified+stored-but-no-standalone-overlay case. The guard now asserts: pgEnum == _ROW_TYPES; and _ROW_TYPES minus attribute components == _COMPONENT_OVERLAYS. Full repositories suite (247) + finaliser green; pyright clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ord_override_reader_postgres_repository.py | 7 ++++ .../property/landlord_override_overlays.py | 9 ++++ .../test_override_component_consistency.py | 41 +++++++++++++------ 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/infrastructure/landlord_overrides/landlord_override_reader_postgres_repository.py b/infrastructure/landlord_overrides/landlord_override_reader_postgres_repository.py index 4d02b0d59..cf1379f7b 100644 --- a/infrastructure/landlord_overrides/landlord_override_reader_postgres_repository.py +++ b/infrastructure/landlord_overrides/landlord_override_reader_postgres_repository.py @@ -37,6 +37,9 @@ from infrastructure.postgres.landlord_main_fuel_override_table import ( from infrastructure.postgres.landlord_main_heating_system_override_table import ( LandlordMainHeatingSystemOverrideRow, ) +from infrastructure.postgres.landlord_boiler_efficiency_band_override_table import ( + LandlordBoilerEfficiencyBandOverrideRow, +) from infrastructure.postgres.landlord_wall_type_override_table import ( LandlordWallTypeOverrideRow, ) @@ -58,6 +61,10 @@ _ROW_TYPES: dict[str, type] = { "construction_age_band": LandlordConstructionAgeBandOverrideRow, "water_heating": LandlordWaterHeatingOverrideRow, "main_heating_system": LandlordMainHeatingSystemOverrideRow, + # An attribute of the main_heating_system override (consumed by its overlay, + # not a standalone one) — but still classified + cached + stored, so it needs + # a reader entry for the finaliser to load its vocab (ADR-0068). + "boiler_efficiency_band": LandlordBoilerEfficiencyBandOverrideRow, } diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index 550d39f7b..418047a2f 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -78,6 +78,15 @@ _COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] = } +# Components that are classified + stored but have **no standalone overlay** — they +# are *attributes* consumed by another component's overlay, so they are absent from +# `_COMPONENT_OVERLAYS` on purpose. The Boiler Efficiency Band rides the +# main_heating_system overlay (ADR-0068), read at its call site (see +# `_boiler_efficiency_band_for`). The override-component consistency guard treats +# these as the reader/pgEnum-minus-overlays difference. +_ATTRIBUTE_COMPONENTS: frozenset[str] = frozenset({"boiler_efficiency_band"}) + + # Components whose overlay must be applied LAST so an explicit value wins a # default another overlay dragged. `apply_simulations` is last-wins and override # rows arrive in arbitrary order, so a `main_fuel` override must be applied after diff --git a/tests/repositories/property/test_override_component_consistency.py b/tests/repositories/property/test_override_component_consistency.py index d3801b0da..f9ccb4ed2 100644 --- a/tests/repositories/property/test_override_component_consistency.py +++ b/tests/repositories/property/test_override_component_consistency.py @@ -1,12 +1,20 @@ """Every override component must be wired through the WHOLE chain. -The finaliser reader (`_ROW_TYPES`, component -> landlord table) and the overlay -registry (`_COMPONENT_OVERLAYS`, component -> overlay mapper) must cover exactly -the same set of components. If a component is classified + stored but has no -reader entry, the finaliser silently never writes its `property_overrides` rows; -if it has no overlay entry, the row never reaches the calculator. This guard -keeps the two registries in lock-step (it would have caught the missing -main_fuel / glazing / construction_age_band reader entries). +The finaliser reader (`_ROW_TYPES`, component -> landlord table), the overlay +registry (`_COMPONENT_OVERLAYS`, component -> overlay mapper), and the +`override_component` pgEnum mirror must stay in lock-step. If a component is +classified + stored but has no reader entry, the finaliser silently never writes +its `property_overrides` rows; if it has no overlay entry, the row never reaches +the calculator; if it is missing from the pgEnum, writing/reading it throws a +LookupError against Postgres (caught live on the Hyde portfolio-796 run). This +guard would have caught the missing main_fuel / glazing / construction_age_band +reader entries. + +The one exception is an **attribute component** (`_ATTRIBUTE_COMPONENTS`): it is +classified + stored + read (so it *is* in `_ROW_TYPES` and the pgEnum) but is +consumed by another component's overlay rather than having its own, so it is +deliberately absent from `_COMPONENT_OVERLAYS` — the Boiler Efficiency Band rides +the main_heating_system overlay (ADR-0068). """ from __future__ import annotations @@ -18,18 +26,27 @@ from infrastructure.landlord_overrides.landlord_override_reader_postgres_reposit ) from infrastructure.postgres.property_override_table import override_component_sa_enum from repositories.property.landlord_override_overlays import ( + _ATTRIBUTE_COMPONENTS, # pyright: ignore[reportPrivateUsage] _COMPONENT_OVERLAYS, # pyright: ignore[reportPrivateUsage] ) def test_reader_and_overlay_registries_cover_the_same_components() -> None: - # Assert - assert set(_ROW_TYPES) == set(_COMPONENT_OVERLAYS) + # Every classified/stored component (`_ROW_TYPES`) has a standalone overlay, + # except the attribute components consumed by another overlay. + assert set(_ROW_TYPES) - _ATTRIBUTE_COMPONENTS == set(_COMPONENT_OVERLAYS) + + +def test_attribute_components_are_read_and_stored_but_have_no_overlay() -> None: + # An attribute component must still be classified/read (in `_ROW_TYPES`) and + # stored (in the pgEnum), but must NOT appear in `_COMPONENT_OVERLAYS`. + assert _ATTRIBUTE_COMPONENTS <= set(_ROW_TYPES) + assert _ATTRIBUTE_COMPONENTS.isdisjoint(_COMPONENT_OVERLAYS) def test_override_component_pgenum_covers_every_component() -> None: # The property_overrides.override_component pgEnum mirror must list every - # component, or writing/reading a new-component row through it throws a - # LookupError against Postgres (caught live on the Hyde portfolio-796 run). + # classified/stored component (overlay-backed + attribute), or writing/reading + # a new-component row through it throws a LookupError against Postgres. pgenum_values = cast(list[str], getattr(override_component_sa_enum, "enums")) - assert set(pgenum_values) == set(_COMPONENT_OVERLAYS) + assert set(pgenum_values) == set(_ROW_TYPES)