feat(modelling): ProductRepository + Postgres materials-table source

Product(measure_type, unit_cost_per_m2, contingency_rate). ProductRepository
is the DDD port abstracting the catalogue source; ProductPostgresRepository
reads the externally-owned material table (defensive SQLModel view
MaterialRow) and maps an active row to a Product — total_cost becomes the
fully-loaded unit_cost_per_m2 — joining the per-measure-type contingency
(contingencies.py, mirrors Costs.CONTINGENCIES; cavity 0.10). Strict-raise
on missing/inactive row. A JSON-backed impl will follow behind the same
port for ETL-gap costs.

Two DB tests against an ephemeral Postgres (map active row; raise on
inactive-only). Toward #1155 cost (4b). Also generalises the CONTEXT
Simulation Overlay wording: windows are targeted by index, building-part
association carried via window_location (_window_bp_index). pyright clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 08:32:38 +00:00
parent 0ba0575877
commit b2c8980dd2
9 changed files with 177 additions and 1 deletions

View file

@ -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**:

View file

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

View file

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

View file

@ -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)

View file

View file

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

View file

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

View file

View file

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