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:
Khalim Conn-Kowlessar 2026-06-16 14:55:41 +00:00
parent 90bed458f4
commit 53d9f21f73
5 changed files with 72 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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

View file

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