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