mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-08-02 21:08:24 +00:00
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:
parent
291717ca8f
commit
6a4039c486
4 changed files with 118 additions and 0 deletions
|
|
@ -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]] = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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} "
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue