mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-22 00:38:49 +00:00
The ASHP bundle is priced from the rate sheet (ADR-0025); the catalogue row is read only for its material id, which is nullable end-to-end. The live `material` catalogue has no `air_source_heat_pump` row, so `products.get` raised `ValueError: no active product` and aborted every ASHP-eligible property. Add `ProductNotFound(ValueError)` + a concrete `ProductRepository .get_optional`, raise the typed error from both repos, and have `_ashp_option` look the row up optionally — a missing row now yields an ASHP Option with `material_id=None` rather than crashing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
49 lines
2 KiB
Python
49 lines
2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
from domain.modelling.contingencies import contingency_rate
|
|
from domain.modelling.product import Product
|
|
from repositories.product.product_repository import ProductNotFound, ProductRepository
|
|
|
|
|
|
class ProductJsonRepository(ProductRepository):
|
|
"""Reads Products from a JSON catalogue file — the stopgap source for
|
|
costs the ETL does not yet supply, behind the same `ProductRepository`
|
|
port as the Postgres-backed catalogue.
|
|
|
|
The file maps each Measure Type to its fully-loaded unit cost::
|
|
|
|
{"cavity_wall_insulation": {"unit_cost_per_m2": 18.5}, ...}
|
|
|
|
The per-Measure-Type contingency is joined from config (not stored in the
|
|
file), exactly as `ProductPostgresRepository` joins it — config stays the
|
|
single source of truth for contingency.
|
|
"""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
with path.open(encoding="utf-8") as handle:
|
|
loaded: Any = json.load(handle)
|
|
if not isinstance(loaded, dict):
|
|
raise ValueError(f"product catalogue {path} is not a JSON object")
|
|
self._entries: dict[str, Any] = loaded
|
|
|
|
def get(self, measure_type: str) -> Product:
|
|
entry: Any = self._entries.get(measure_type)
|
|
if entry is None:
|
|
raise ProductNotFound(f"no product for measure type {measure_type!r}")
|
|
if not isinstance(entry, dict):
|
|
raise ValueError(f"product {measure_type!r} entry is not an object")
|
|
typed_entry: dict[str, Any] = cast("dict[str, Any]", entry)
|
|
unit_cost: Any = typed_entry.get("unit_cost_per_m2")
|
|
if isinstance(unit_cost, bool) or not isinstance(unit_cost, (int, float)):
|
|
raise ValueError(
|
|
f"product {measure_type!r} has no numeric unit_cost_per_m2"
|
|
)
|
|
return Product(
|
|
measure_type=measure_type,
|
|
unit_cost_per_m2=float(unit_cost),
|
|
contingency_rate=contingency_rate(measure_type),
|
|
)
|