feat(classifier): GREEN — classify + finalise the Boiler Efficiency Band

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-29 16:32:42 +00:00
parent 291717ca8f
commit 6a4039c486
4 changed files with 118 additions and 0 deletions

View file

@ -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: <A-G> 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]] = []

View file

@ -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) -> AG`` 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,
)

View file

@ -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 AG. FE-owned pgEnum value
# (deferred / Class-A/B: no deploy until the Drizzle migration adds it).
"boiler_efficiency_band",
name="override_component",
)

View file

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