diff --git a/CONTEXT.md b/CONTEXT.md index 67fb95d9..baedf2f9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -216,7 +216,7 @@ One mutually-exclusive way to satisfy a **Recommendation** — possibly a **bund _Avoid_: option (too generic), variant, SKU **Simulation Overlay** (type `EpcSimulation`): -The change a single **Measure Option** makes to a Property's EpcPropertyData, expressed as an all-optional partial mirror of EpcPropertyData and its nested types — covering only the retrofit-relevant surface (walls/roofs/floors, windows, heating + controls, hot water, ventilation, lighting, PV, draughtproofing), never identity/location fields. Targets a specific building part by `BuildingPartIdentifier` (MAIN, EXTENSION_1..4) so "insulate the cavity wall" addresses the exact `SapBuildingPart`. Carries no scores. It is **not** an EpcPropertyData (composition, not inheritance — an all-`None` overlay is not a valid EPC). A domain operation folds a baseline EpcPropertyData + an ordered set of Overlays into a throwaway EpcPropertyData handed to the calculator; only the score is kept, the EPD is discarded. +The change a single **Measure Option** makes to a Property's EpcPropertyData, expressed as an all-optional partial mirror of EpcPropertyData and its nested types — covering only the retrofit-relevant surface (walls/roofs/floors, windows, heating + controls, hot water, ventilation, lighting, PV, draughtproofing), never identity/location fields. Targets a specific building part by `BuildingPartIdentifier` (MAIN, EXTENSION_1..4) so "insulate the cavity wall" addresses the exact `SapBuildingPart`; targets a specific **window by its index** in `sap_windows` (the PDF's W1/W2/W3) — glazing measures address windows directly by number, regardless of which wall they sit on; the window's building-part association is carried separately via `window_location` (resolved by `_window_bp_index`), not used for targeting; and targets whole-dwelling systems (e.g. `sap_heating`) directly. Carries no scores. It is **not** an EpcPropertyData (composition, not inheritance — an all-`None` overlay is not a valid EPC). A domain operation folds a baseline EpcPropertyData + an ordered set of Overlays into a throwaway EpcPropertyData handed to the calculator; only the score is kept, the EPD is discarded. _Avoid_: simulation config (the legacy EPC-API flag object), patch, delta, diff **Product**: diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py new file mode 100644 index 00000000..f036b786 --- /dev/null +++ b/domain/modelling/contingencies.py @@ -0,0 +1,21 @@ +"""Per-Measure-Type contingency rates. + +The one cost component carried separately from a Product's fully-loaded total +(CONTEXT.md). Mirrors the legacy `recommendations/Costs.py::Costs.CONTINGENCIES`; +extended as each measure type lands. +""" + +_CONTINGENCY_RATES: dict[str, float] = { + "cavity_wall_insulation": 0.10, +} + + +def contingency_rate(measure_type: str) -> float: + """Return the contingency rate for a Measure Type, raising if unknown + (strict — do not silently default, per the repo's strict-raise convention).""" + try: + return _CONTINGENCY_RATES[measure_type] + except KeyError as exc: + raise ValueError( + f"no contingency rate configured for measure type {measure_type!r}" + ) from exc diff --git a/domain/modelling/product.py b/domain/modelling/product.py new file mode 100644 index 00000000..fe5c78f3 --- /dev/null +++ b/domain/modelling/product.py @@ -0,0 +1,16 @@ +"""Product — a catalogue entry a Measure Option installs. + +Carries the data needed to price an Option: a fully-loaded unit cost and the +per-Measure-Type contingency rate carried alongside it (CONTEXT.md). The +catalogue is equipment-dominated (heat pumps, glazing, PV) — hence "Product", +not "material". Read via a `ProductRepository`. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Product: + measure_type: str + unit_cost_per_m2: float + contingency_rate: float diff --git a/infrastructure/postgres/product_table.py b/infrastructure/postgres/product_table.py new file mode 100644 index 00000000..b353b300 --- /dev/null +++ b/infrastructure/postgres/product_table.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import ClassVar, Optional + +from sqlmodel import Field, SQLModel + + +class MaterialRow(SQLModel, table=True): + """Defensive view of the externally-owned ``material`` catalogue table. + + Declares only the columns the modelling backend reads to price a Measure + Option; other columns (r-values, labour breakdowns, etc.) are left off so + schema churn elsewhere doesn't ripple in. `total_cost` is the fully-loaded + cost per the row's `cost_unit` (GBP/m^2 for fabric measures). + """ + + __tablename__: ClassVar[str] = "material" # pyright: ignore[reportIncompatibleVariableOverride] + + id: int = Field(primary_key=True) + type: str + total_cost: Optional[float] = Field(default=None) + cost_unit: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + is_active: bool = Field(default=True) diff --git a/repositories/product/__init__.py b/repositories/product/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/repositories/product/product_postgres_repository.py b/repositories/product/product_postgres_repository.py new file mode 100644 index 00000000..13926885 --- /dev/null +++ b/repositories/product/product_postgres_repository.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from sqlmodel import Session, col, select + +from domain.modelling.contingencies import contingency_rate +from domain.modelling.product import Product +from infrastructure.postgres.product_table import MaterialRow +from repositories.product.product_repository import ProductRepository + + +class ProductPostgresRepository(ProductRepository): + """Reads the ``material`` catalogue table and maps an active row to a + Product: `total_cost` becomes the fully-loaded `unit_cost_per_m2`, and the + per-Measure-Type contingency is joined from config.""" + + def __init__(self, session: Session) -> None: + self._session = session + + def get(self, measure_type: str) -> Product: + row: MaterialRow | None = self._session.exec( + select(MaterialRow).where( + col(MaterialRow.type) == measure_type, + col(MaterialRow.is_active).is_(True), + ) + ).first() + if row is None: + raise ValueError(f"no active product for measure type {measure_type!r}") + if row.total_cost is None: + raise ValueError(f"product {measure_type!r} has no total_cost") + return Product( + measure_type=measure_type, + unit_cost_per_m2=row.total_cost, + contingency_rate=contingency_rate(measure_type), + ) diff --git a/repositories/product/product_repository.py b/repositories/product/product_repository.py new file mode 100644 index 00000000..eab7b202 --- /dev/null +++ b/repositories/product/product_repository.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from domain.modelling.product import Product + + +class ProductRepository(ABC): + """Loads Products from the catalogue, abstracting the data source (a + Postgres-backed materials table today; a JSON file for costs the ETL does + not yet supply, behind the same port later). Maps the raw source row into + the `Product` domain object, joining the per-Measure-Type contingency.""" + + @abstractmethod + def get(self, measure_type: str) -> Product: + """Return the Product for a Measure Type, raising if there is no active + catalogue entry.""" + ... diff --git a/tests/repositories/product/__init__.py b/tests/repositories/product/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/repositories/product/test_product_postgres_repository.py b/tests/repositories/product/test_product_postgres_repository.py new file mode 100644 index 00000000..13293ea6 --- /dev/null +++ b/tests/repositories/product/test_product_postgres_repository.py @@ -0,0 +1,63 @@ +"""Behaviour of the Postgres-backed ProductRepository: mapping a row of the +materials catalogue into a Product, with the per-measure-type contingency +joined on. See CONTEXT.md (Product, Cost, Contingency).""" + +import pytest +from sqlalchemy import Engine +from sqlmodel import Session + +from infrastructure.postgres.product_table import MaterialRow +from repositories.product.product_postgres_repository import ( + ProductPostgresRepository, +) +from domain.modelling.product import Product + + +def test_get_maps_active_material_to_product_with_contingency( + db_engine: Engine, +) -> None: + # Arrange + with Session(db_engine) as session: + session.add( + MaterialRow( + id=1, + type="cavity_wall_insulation", + total_cost=18.5, # fully-loaded GBP per m^2 + cost_unit="gbp_per_m2", + is_active=True, + description="Cavity wall insulation", + ) + ) + session.commit() + + # Act + with Session(db_engine) as session: + product: Product = ProductPostgresRepository(session).get( + "cavity_wall_insulation" + ) + + # Assert + assert product.measure_type == "cavity_wall_insulation" + assert abs(product.unit_cost_per_m2 - 18.5) <= 1e-9 + assert abs(product.contingency_rate - 0.10) <= 1e-9 + + +def test_get_raises_when_only_an_inactive_product_exists(db_engine: Engine) -> None: + # Arrange + with Session(db_engine) as session: + session.add( + MaterialRow( + id=1, + type="cavity_wall_insulation", + total_cost=18.5, + cost_unit="gbp_per_m2", + is_active=False, + description="Cavity wall insulation (retired)", + ) + ) + session.commit() + + # Act / Assert + with Session(db_engine) as session: + with pytest.raises(ValueError): + ProductPostgresRepository(session).get("cavity_wall_insulation")