diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index b10e1b76..475039df 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -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, ) diff --git a/repositories/product/product_json_repository.py b/repositories/product/product_json_repository.py index 902f931f..5c566a92 100644 --- a/repositories/product/product_json_repository.py +++ b/repositories/product/product_json_repository.py @@ -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) diff --git a/repositories/product/product_postgres_repository.py b/repositories/product/product_postgres_repository.py index c43a0ae1..5f27a68e 100644 --- a/repositories/product/product_postgres_repository.py +++ b/repositories/product/product_postgres_repository.py @@ -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( diff --git a/repositories/product/product_repository.py b/repositories/product/product_repository.py index eab7b202..8aabd07d 100644 --- a/repositories/product/product_repository.py +++ b/repositories/product/product_repository.py @@ -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 diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 81b9f692..4f1a321a 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -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).