mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
fix(modelling): offer ASHP when the catalogue has no ASHP row
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>
This commit is contained in:
parent
90bed458f4
commit
53d9f21f73
5 changed files with 72 additions and 10 deletions
|
|
@ -25,6 +25,7 @@ from domain.modelling.products import (
|
|||
TuneUpCostInputs,
|
||||
)
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.product import Product
|
||||
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
|
||||
from domain.sap10_calculator.tables.table_4b import (
|
||||
|
|
@ -641,8 +642,9 @@ def _ashp_option(
|
|||
if not _ashp_eligible(epc, restrictions):
|
||||
return None
|
||||
# Cost is composed per-dwelling from the rate sheet (ADR-0025), not the
|
||||
# single catalogue scalar; the catalogue row is still read for its id.
|
||||
product = products.get(_ASHP_MEASURE_TYPE)
|
||||
# single catalogue scalar; the catalogue row is read only for its id, so an
|
||||
# absent ASHP row must not suppress the bundle — it just carries no id.
|
||||
product: Optional[Product] = products.get_optional(_ASHP_MEASURE_TYPE)
|
||||
cost: Cost = Products().ashp_bundle_cost(ashp_cost_inputs(epc))
|
||||
return MeasureOption(
|
||||
measure_type=_ASHP_MEASURE_TYPE,
|
||||
|
|
@ -652,7 +654,7 @@ def _ashp_option(
|
|||
),
|
||||
overlay=EpcSimulation(heating=_ASHP_OVERLAY),
|
||||
cost=cost,
|
||||
material_id=product.id,
|
||||
material_id=product.id if product is not None else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from typing import Any, cast
|
|||
|
||||
from domain.modelling.contingencies import contingency_rate
|
||||
from domain.modelling.product import Product
|
||||
from repositories.product.product_repository import ProductRepository
|
||||
from repositories.product.product_repository import ProductNotFound, ProductRepository
|
||||
|
||||
|
||||
class ProductJsonRepository(ProductRepository):
|
||||
|
|
@ -33,7 +33,7 @@ class ProductJsonRepository(ProductRepository):
|
|||
def get(self, measure_type: str) -> Product:
|
||||
entry: Any = self._entries.get(measure_type)
|
||||
if entry is None:
|
||||
raise ValueError(f"no product for measure type {measure_type!r}")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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
|
||||
from repositories.product.product_repository import ProductNotFound, ProductRepository
|
||||
|
||||
|
||||
# The domain ``MeasureType`` vocabulary and the catalogue's ``material.type``
|
||||
|
|
@ -47,7 +47,9 @@ class ProductPostgresRepository(ProductRepository):
|
|||
.order_by(col(MaterialRow.id))
|
||||
).first()
|
||||
if row is None:
|
||||
raise ValueError(f"no active product for measure type {measure_type!r}")
|
||||
raise ProductNotFound(
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from domain.modelling.product import Product
|
||||
|
||||
|
||||
class ProductNotFound(ValueError):
|
||||
"""Raised when the catalogue has no active entry for a Measure Type. A
|
||||
subclass of ``ValueError`` so existing callers that catch ``ValueError``
|
||||
keep working, while callers that only want to know *whether* a row exists
|
||||
(see ``get_optional``) can catch this case alone."""
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -13,6 +21,17 @@ class ProductRepository(ABC):
|
|||
|
||||
@abstractmethod
|
||||
def get(self, measure_type: str) -> Product:
|
||||
"""Return the Product for a Measure Type, raising if there is no active
|
||||
catalogue entry."""
|
||||
"""Return the Product for a Measure Type, raising ``ProductNotFound``
|
||||
if there is no active catalogue entry."""
|
||||
...
|
||||
|
||||
def get_optional(self, measure_type: str) -> Optional[Product]:
|
||||
"""Return the Product for a Measure Type, or None when the catalogue has
|
||||
no active entry. For measures whose cost is composed off-catalogue (e.g.
|
||||
ASHP, priced from the rate sheet per ADR-0025) the catalogue row is read
|
||||
only for its id, so a missing row is not an error — the measure is still
|
||||
offered, just without a ``material_id``."""
|
||||
try:
|
||||
return self.get(measure_type)
|
||||
except ProductNotFound:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ later slices. Detection + pricing only; impact is produced by scoring (ADR-0016)
|
|||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||
from domain.modelling.generators.heating_recommendation import recommend_heating
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.product import Product
|
||||
from domain.modelling.recommendation import Recommendation
|
||||
from domain.modelling.simulation import HeatingOverlay
|
||||
from repositories.product.product_repository import ProductRepository
|
||||
from repositories.product.product_repository import (
|
||||
ProductNotFound,
|
||||
ProductRepository,
|
||||
)
|
||||
from tests.domain.modelling._elmhurst_recommendation import (
|
||||
parse_recommendation_summary,
|
||||
)
|
||||
|
|
@ -170,6 +174,41 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None:
|
|||
)
|
||||
|
||||
|
||||
class _StubProductsWithoutAshp(ProductRepository):
|
||||
"""A catalogue with no ASHP row. ASHP's cost is composed from the rate sheet
|
||||
(ADR-0025) and the catalogue row is read only for its id, so a missing row
|
||||
must not suppress the bundle — it just carries no material_id."""
|
||||
|
||||
def get(self, measure_type: str) -> Product:
|
||||
if measure_type == MeasureType.AIR_SOURCE_HEAT_PUMP:
|
||||
raise ProductNotFound(f"no active product for {measure_type!r}")
|
||||
return Product(
|
||||
measure_type=measure_type, unit_cost_per_m2=3500.0, contingency_rate=0.26
|
||||
)
|
||||
|
||||
|
||||
def test_ashp_bundle_offered_when_catalogue_lacks_an_ashp_product() -> None:
|
||||
# Arrange — a mains-gas house (ASHP-eligible) priced against a catalogue with
|
||||
# no ASHP row; ASHP is costed from the rate sheet, so the bundle must still
|
||||
# be offered, just without a material id.
|
||||
baseline: EpcPropertyData = _gas_boiler_house()
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_heating(
|
||||
baseline, _StubProductsWithoutAshp()
|
||||
)
|
||||
|
||||
# Assert — the ASHP bundle is still offered, carrying its composite cost and
|
||||
# no material id.
|
||||
assert recommendation is not None
|
||||
option = next(
|
||||
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
|
||||
)
|
||||
assert option.material_id is None
|
||||
assert option.cost is not None
|
||||
assert option.cost.total > 0.0
|
||||
|
||||
|
||||
def test_ashp_bundle_carries_the_composite_per_dwelling_cost() -> None:
|
||||
# Arrange — a mains-gas regular boiler with a cylinder (90 m2, 7 habitable
|
||||
# rooms): the ASHP reuses the existing wet system (ADR-0025).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue