feat(overrides): GREEN — thread Boiler Efficiency Band to the overlay call site

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-29 16:21:14 +00:00
parent e91e714221
commit ce803ecedc
3 changed files with 73 additions and 12 deletions

View file

@ -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

View file

@ -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 {

View file

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