From cc228b3da5b87e5dffaaa676f99a0305f10426f6 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Fri, 19 Jun 2026 14:09:05 +0000 Subject: [PATCH] =?UTF-8?q?Classify=20the=20landlord=20Hot=20Water=20and?= =?UTF-8?q?=20Heating=20columns=20into=20categories=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../landlord_description_overrides/handler.py | 28 ++++++++ domain/epc/main_heating_system_type.py | 24 +++++++ domain/epc/water_heating_type.py | 21 ++++++ ...ord_override_reader_postgres_repository.py | 20 ++++++ ...lord_main_heating_system_override_table.py | 71 +++++++++++++++++++ .../landlord_water_heating_override_table.py | 69 ++++++++++++++++++ 6 files changed, 233 insertions(+) create mode 100644 domain/epc/main_heating_system_type.py create mode 100644 domain/epc/water_heating_type.py create mode 100644 infrastructure/postgres/landlord_main_heating_system_override_table.py create mode 100644 infrastructure/postgres/landlord_water_heating_override_table.py diff --git a/applications/landlord_description_overrides/handler.py b/applications/landlord_description_overrides/handler.py index 2f83b81f..1a99aea6 100644 --- a/applications/landlord_description_overrides/handler.py +++ b/applications/landlord_description_overrides/handler.py @@ -11,8 +11,10 @@ from domain.epc.built_form_type import BuiltFormType from domain.epc.construction_age_band import ConstructionAgeBand from domain.epc.glazing_type import GlazingType from domain.epc.main_fuel_type import MainFuelType +from domain.epc.main_heating_system_type import MainHeatingSystemType from domain.epc.property_type import PropertyType from domain.epc.roof_type import RoofType +from domain.epc.water_heating_type import WaterHeatingType from domain.epc.wall_type import WallType from domain.epc.wall_type_construction_dates import ( wall_type_construction_date_prompt_hint, @@ -36,6 +38,12 @@ from infrastructure.postgres.landlord_glazing_override_table import ( from infrastructure.postgres.landlord_main_fuel_override_table import ( LandlordMainFuelOverrideRow, ) +from infrastructure.postgres.landlord_main_heating_system_override_table import ( + LandlordMainHeatingSystemOverrideRow, +) +from infrastructure.postgres.landlord_water_heating_override_table import ( + LandlordWaterHeatingOverrideRow, +) from infrastructure.postgres.landlord_property_type_override_table import ( LandlordPropertyTypeOverrideRow, ) @@ -144,6 +152,26 @@ def _build_columns( session, LandlordConstructionAgeBandOverrideRow ), ), + "water_heating": lambda src: ClassifiableColumn( + name="water_heating", + source_column=src, + classifier=ChatGptColumnClassifier( + chat_gpt, WaterHeatingType, WaterHeatingType.UNKNOWN + ), + repo=LandlordOverridesRepository[WaterHeatingType]( + session, LandlordWaterHeatingOverrideRow + ), + ), + "main_heating_system": lambda src: ClassifiableColumn( + name="main_heating_system", + source_column=src, + classifier=ChatGptColumnClassifier( + chat_gpt, MainHeatingSystemType, MainHeatingSystemType.UNKNOWN + ), + repo=LandlordOverridesRepository[MainHeatingSystemType]( + session, LandlordMainHeatingSystemOverrideRow + ), + ), } columns: list[ClassifiableColumn[Any]] = [] diff --git a/domain/epc/main_heating_system_type.py b/domain/epc/main_heating_system_type.py new file mode 100644 index 00000000..ff3c4233 --- /dev/null +++ b/domain/epc/main_heating_system_type.py @@ -0,0 +1,24 @@ +from enum import Enum + + +class MainHeatingSystemType(Enum): + """A landlord-supplied main-heating-system description, as resolved by the + landlord-description-overrides context. + + Each member's value is the canonical system archetype that the main-heating + Simulation Overlay + (``domain/epc/property_overlays/main_heating_system_overlay.py``) maps to a + representative SAP ``sap_main_heating_code`` — so the member values MUST stay + in lock-step with that overlay's ``_MAIN_HEATING_CODES`` keys. The SEDBUK A-G + efficiency band the Hyde "Heating" column carries is NOT modelled yet + (deferred), so archetypes map to their modern/condensing code. ``UNKNOWN`` + covers values the classifier cannot resolve and the not-yet-modelled systems + (heat pumps, community heating). + """ + + GAS_COMBI = "Gas boiler, combi" + GAS_REGULAR = "Gas boiler, regular" + GAS_CPSU = "Gas CPSU" + ELECTRIC_STORAGE_FAN = "Electric storage heaters, fan" + DIRECT_ELECTRIC = "Direct-acting electric" + UNKNOWN = "Unknown" diff --git a/domain/epc/water_heating_type.py b/domain/epc/water_heating_type.py new file mode 100644 index 00000000..025c669a --- /dev/null +++ b/domain/epc/water_heating_type.py @@ -0,0 +1,21 @@ +from enum import Enum + + +class WaterHeatingType(Enum): + """A landlord-supplied water-heating description, as resolved by the + landlord-description-overrides context. + + Each member's value is the canonical ", " description that the + water-heating Simulation Overlay + (``domain/epc/property_overlays/water_heating_overlay.py``) decomposes into + the SAP ``water_heating_code`` + ``water_heating_fuel`` int codes the + calculator reads — so the member values MUST stay in lock-step with that + overlay's ``_WATER_HEATING_CODES`` keys. ``UNKNOWN`` covers values the + classifier cannot resolve, and any combination not yet given verified codes + (it leaves the lodged cert's hot-water arrangement untouched). + """ + + FROM_MAIN_MAINS_GAS = "From main system, mains gas" + FROM_MAIN_ELECTRICITY = "From main system, electricity" + ELECTRIC_IMMERSION = "Electric immersion, electricity" + UNKNOWN = "Unknown" diff --git a/infrastructure/landlord_overrides/landlord_override_reader_postgres_repository.py b/infrastructure/landlord_overrides/landlord_override_reader_postgres_repository.py index 79b851f3..4d02b0d5 100644 --- a/infrastructure/landlord_overrides/landlord_override_reader_postgres_repository.py +++ b/infrastructure/landlord_overrides/landlord_override_reader_postgres_repository.py @@ -25,9 +25,24 @@ from infrastructure.postgres.landlord_property_type_override_table import ( from infrastructure.postgres.landlord_roof_type_override_table import ( LandlordRoofTypeOverrideRow, ) +from infrastructure.postgres.landlord_construction_age_band_override_table import ( + LandlordConstructionAgeBandOverrideRow, +) +from infrastructure.postgres.landlord_glazing_override_table import ( + LandlordGlazingOverrideRow, +) +from infrastructure.postgres.landlord_main_fuel_override_table import ( + LandlordMainFuelOverrideRow, +) +from infrastructure.postgres.landlord_main_heating_system_override_table import ( + LandlordMainHeatingSystemOverrideRow, +) from infrastructure.postgres.landlord_wall_type_override_table import ( LandlordWallTypeOverrideRow, ) +from infrastructure.postgres.landlord_water_heating_override_table import ( + LandlordWaterHeatingOverrideRow, +) from repositories.landlord_overrides.landlord_override_reader import ( LandlordOverrideReader, ) @@ -38,6 +53,11 @@ _ROW_TYPES: dict[str, type] = { "built_form_type": LandlordBuiltFormTypeOverrideRow, "wall_type": LandlordWallTypeOverrideRow, "roof_type": LandlordRoofTypeOverrideRow, + "main_fuel": LandlordMainFuelOverrideRow, + "glazing": LandlordGlazingOverrideRow, + "construction_age_band": LandlordConstructionAgeBandOverrideRow, + "water_heating": LandlordWaterHeatingOverrideRow, + "main_heating_system": LandlordMainHeatingSystemOverrideRow, } diff --git a/infrastructure/postgres/landlord_main_heating_system_override_table.py b/infrastructure/postgres/landlord_main_heating_system_override_table.py new file mode 100644 index 00000000..a06c35c4 --- /dev/null +++ b/infrastructure/postgres/landlord_main_heating_system_override_table.py @@ -0,0 +1,71 @@ +"""SQLModel mirror of the ``landlord_main_heating_system_overrides`` Drizzle table. + +The schema source of truth lives in the ``assessment-model`` TS repo +(`src/app/db/schema/landlord_overrides.ts`). The migrations are owned there; +this row class only mirrors the columns so the Python lambda can read/write. +See ADR-0003. Shape mirrors ``LandlordWallTypeOverrideRow`` -- the only +differences are the table name, the ``main_heating_system`` pgEnum on ``value``, +and the unique-constraint name. +""" + +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.main_heating_system_type import MainHeatingSystemType +from infrastructure.postgres.landlord_override_enums import override_source_sa_enum + + +class LandlordMainHeatingSystemOverrideRow(SQLModel, table=True): + __tablename__: ClassVar[str] = "landlord_main_heating_system_overrides" # pyright: ignore[reportIncompatibleVariableOverride] + __table_args__: ClassVar[tuple[UniqueConstraint, ...]] = ( # pyright: ignore[reportIncompatibleVariableOverride] + # Shortened (drop the redundant ``_overrides``) to stay within + # PostgreSQL's 63-char identifier limit; mirrors the Drizzle name. + UniqueConstraint( + "portfolio_id", + "description", + name="landlord_main_heating_system_portfolio_description_unique", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + + # bigint to match the Drizzle ``portfolio_id`` FK; SQLModel's default int + # mapping is 32-bit Integer and would overflow once portfolio IDs exceed + # 2^31. The FK to ``portfolio.id`` is enforced by the Drizzle migration, + # not declared here -- the ``portfolio`` table is not modelled in Python. + portfolio_id: int = Field( + sa_column=Column(BigInteger, nullable=False, index=True), + ) + + description: str = Field(nullable=False) + + value: MainHeatingSystemType = Field( + sa_column=Column( + SAEnum( + MainHeatingSystemType, + name="main_heating_system", + values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType] + ), + nullable=False, + ), + ) + + # Shared SAEnum -- see ``landlord_override_enums`` for why this single + # instance is reused by every ``landlord_*_overrides`` row class. + 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/landlord_water_heating_override_table.py b/infrastructure/postgres/landlord_water_heating_override_table.py new file mode 100644 index 00000000..c69a41d2 --- /dev/null +++ b/infrastructure/postgres/landlord_water_heating_override_table.py @@ -0,0 +1,69 @@ +"""SQLModel mirror of the ``landlord_water_heating_overrides`` Drizzle table. + +The schema source of truth lives in the ``assessment-model`` TS repo +(`src/app/db/schema/landlord_overrides.ts`). The migrations are owned there; +this row class only mirrors the columns so the Python lambda can read/write. +See ADR-0003. Shape mirrors ``LandlordWallTypeOverrideRow`` -- the only +differences are the table name, the ``water_heating`` pgEnum on ``value``, and +the unique-constraint name. +""" + +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.water_heating_type import WaterHeatingType +from infrastructure.postgres.landlord_override_enums import override_source_sa_enum + + +class LandlordWaterHeatingOverrideRow(SQLModel, table=True): + __tablename__: ClassVar[str] = "landlord_water_heating_overrides" # pyright: ignore[reportIncompatibleVariableOverride] + __table_args__: ClassVar[tuple[UniqueConstraint, ...]] = ( # pyright: ignore[reportIncompatibleVariableOverride] + UniqueConstraint( + "portfolio_id", + "description", + name="landlord_water_heating_overrides_portfolio_description_unique", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + + # bigint to match the Drizzle ``portfolio_id`` FK; SQLModel's default int + # mapping is 32-bit Integer and would overflow once portfolio IDs exceed + # 2^31. The FK to ``portfolio.id`` is enforced by the Drizzle migration, + # not declared here -- the ``portfolio`` table is not modelled in Python. + portfolio_id: int = Field( + sa_column=Column(BigInteger, nullable=False, index=True), + ) + + description: str = Field(nullable=False) + + value: WaterHeatingType = Field( + sa_column=Column( + SAEnum( + WaterHeatingType, + name="water_heating", + values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType] + ), + nullable=False, + ), + ) + + # Shared SAEnum -- see ``landlord_override_enums`` for why this single + # instance is reused by every ``landlord_*_overrides`` row class. + 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, + )