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