mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
0ba0575877
commit
b2c8980dd2
9 changed files with 177 additions and 1 deletions
|
|
@ -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**:
|
||||
|
|
|
|||
21
domain/modelling/contingencies.py
Normal file
21
domain/modelling/contingencies.py
Normal 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
|
||||
16
domain/modelling/product.py
Normal file
16
domain/modelling/product.py
Normal 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
|
||||
24
infrastructure/postgres/product_table.py
Normal file
24
infrastructure/postgres/product_table.py
Normal 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)
|
||||
0
repositories/product/__init__.py
Normal file
0
repositories/product/__init__.py
Normal file
34
repositories/product/product_postgres_repository.py
Normal file
34
repositories/product/product_postgres_repository.py
Normal 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),
|
||||
)
|
||||
18
repositories/product/product_repository.py
Normal file
18
repositories/product/product_repository.py
Normal 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."""
|
||||
...
|
||||
0
tests/repositories/product/__init__.py
Normal file
0
tests/repositories/product/__init__.py
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue