From ce803ecedc05204e49d61a3fae0e1c495b1ff530 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 29 Jul 2026 16:21:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(overrides):=20GREEN=20=E2=80=94=20thread?= =?UTF-8?q?=20Boiler=20Efficiency=20Band=20to=20the=20overlay=20call=20sit?= =?UTF-8?q?e?= 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)