Merge branch 'feature/bill-derivation' into feature/junte+khalim

This commit is contained in:
Jun-te Kim 2026-06-09 10:06:40 +00:00
commit 06cb4f7b6e
29 changed files with 579 additions and 104 deletions

View file

@ -0,0 +1,42 @@
"""Restricting a modelling run to a chosen set of measure types.
The allowlist a run "considers" mirroring the legacy engine's `inclusions`
(`backend/app/plan/schemas.py`). It filters the candidate Recommendations at the
Option level so a multi-option Recommendation (e.g. Heating & Hot Water competing
HHRSH against an ASHP bundle) is kept with only its allowed Options; a
Recommendation left with no allowed Option is dropped. The Optimiser still
freely chooses among what survives including choosing nothing.
A `None` allowlist means "consider every modelled measure" (the unrestricted
default).
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Optional
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Recommendation
def restrict_to_considered_measures(
recommendations: Iterable[Recommendation],
considered_measures: Optional[frozenset[MeasureType]],
) -> list[Recommendation]:
"""Keep only the Options whose measure type is in ``considered_measures``,
dropping any Recommendation left with none. ``None`` keeps everything."""
if considered_measures is None:
return list(recommendations)
restricted: list[Recommendation] = []
for recommendation in recommendations:
kept = tuple(
option
for option in recommendation.options
if option.measure_type in considered_measures
)
if kept:
restricted.append(
Recommendation(surface=recommendation.surface, options=kept)
)
return restricted

View file

@ -15,6 +15,7 @@ from datatypes.epc.domain.epc_property_data import (
SapBuildingPart, SapBuildingPart,
) )
from domain.building_geometry import ground_floor_area from domain.building_geometry import ground_floor_area
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
@ -39,14 +40,14 @@ def _is_uninsulated(thickness: Optional[Union[str, int]]) -> bool:
return thickness.strip() in ("", "0") return thickness.strip() in ("", "0")
def _floor_measure_type(construction_type: Optional[str]) -> Optional[str]: def _floor_measure_type(construction_type: Optional[str]) -> Optional[MeasureType]:
"""Map the lodged floor construction to the insulation Measure Type, or """Map the lodged floor construction to the insulation Measure Type, or
None when the construction is not a treatable suspended/solid floor.""" None when the construction is not a treatable suspended/solid floor."""
text = (construction_type or "").lower() text = (construction_type or "").lower()
if "suspended" in text: if "suspended" in text:
return "suspended_floor_insulation" return MeasureType.SUSPENDED_FLOOR_INSULATION
if "solid" in text: if "solid" in text:
return "solid_floor_insulation" return MeasureType.SOLID_FLOOR_INSULATION
return None return None

View file

@ -23,6 +23,7 @@ from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, WindowOverlay from domain.modelling.simulation import EpcSimulation, WindowOverlay
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
@ -42,7 +43,7 @@ class _GlazingTarget:
`solar_transmittance` SAP10.2 Table U2 code, then heat-loss U and solar g). `solar_transmittance` SAP10.2 Table U2 code, then heat-loss U and solar g).
""" """
measure_type: str measure_type: MeasureType
description: str description: str
glazing_type: int glazing_type: int
u_value: float u_value: float
@ -52,7 +53,7 @@ class _GlazingTarget:
# Unrestricted: replace the units with double glazing (gt=5 "Double post 2022"; # Unrestricted: replace the units with double glazing (gt=5 "Double post 2022";
# U 4.80→1.40, g 0.85→0.72). # U 4.80→1.40, g 0.85→0.72).
_DOUBLE: Final[_GlazingTarget] = _GlazingTarget( _DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
measure_type="double_glazing", measure_type=MeasureType.DOUBLE_GLAZING,
description="Replace the single-glazed windows with double glazing", description="Replace the single-glazed windows with double glazing",
glazing_type=5, glazing_type=5,
u_value=1.40, u_value=1.40,
@ -64,7 +65,7 @@ _DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
# solar gain). The external units can't be replaced on a protected/over-looked # solar gain). The external units can't be replaced on a protected/over-looked
# building, so this is the planning-picked Measure. # building, so this is the planning-picked Measure.
_SECONDARY: Final[_GlazingTarget] = _GlazingTarget( _SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
measure_type="secondary_glazing", measure_type=MeasureType.SECONDARY_GLAZING,
description="Fit secondary glazing to the single-glazed windows", description="Fit secondary glazing to the single-glazed windows",
glazing_type=11, glazing_type=11,
u_value=2.90, u_value=2.90,

View file

@ -18,14 +18,15 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingD
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.products import AshpCostInputs, AshpExistingSystem, Products from domain.modelling.products import AshpCostInputs, AshpExistingSystem, Products
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, HeatingOverlay from domain.modelling.simulation import EpcSimulation, HeatingOverlay
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
_HEATING_SURFACE = "Heating & Hot Water" _HEATING_SURFACE = "Heating & Hot Water"
_HHR_STORAGE_MEASURE_TYPE = "high_heat_retention_storage_heaters" _HHR_STORAGE_MEASURE_TYPE = MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS
_ASHP_MEASURE_TYPE = "air_source_heat_pump" _ASHP_MEASURE_TYPE = MeasureType.AIR_SOURCE_HEAT_PUMP
# Electricity main-fuel code (Elmhurst → SAP10 Table 12). # Electricity main-fuel code (Elmhurst → SAP10 Table 12).
_ELECTRICITY_FUEL = 30 _ELECTRICITY_FUEL = 30

View file

@ -18,11 +18,12 @@ scoring (ADR-0016).
from typing import Final, Optional from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, LightingOverlay from domain.modelling.simulation import EpcSimulation, LightingOverlay
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
_LIGHTING_MEASURE_TYPE: Final[str] = "low_energy_lighting" _LIGHTING_MEASURE_TYPE: Final[MeasureType] = MeasureType.LOW_ENERGY_LIGHTING
def recommend_lighting( def recommend_lighting(

View file

@ -17,12 +17,13 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData, EpcPropertyData,
) )
from domain.building_geometry import roof_area from domain.building_geometry import roof_area
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
_LOFT_MEASURE_TYPE = "loft_insulation" _LOFT_MEASURE_TYPE = MeasureType.LOFT_INSULATION
_SLOPING_CEILING_MEASURE_TYPE = "sloping_ceiling_insulation" _SLOPING_CEILING_MEASURE_TYPE = MeasureType.SLOPING_CEILING_INSULATION
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. The # RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. The
# Elmhurst mapper resolves "As Built" to 0 for pitched/sloping/loft roofs. # Elmhurst mapper resolves "As Built" to 0 for pitched/sloping/loft roofs.
_ROOF_UNINSULATED_MM = 0 _ROOF_UNINSULATED_MM = 0
@ -32,7 +33,7 @@ _ROOF_UNINSULATED_MM = 0
_RECOMMENDED_LOFT_THICKNESS_MM = 300 _RECOMMENDED_LOFT_THICKNESS_MM = 300
# Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021). # Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021).
_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100 _RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100
_FLAT_ROOF_MEASURE_TYPE = "flat_roof_insulation" _FLAT_ROOF_MEASURE_TYPE = MeasureType.FLAT_ROOF_INSULATION
# Recommended flat-roof depth (mm); Elmhurst re-lodges 200 mm (ADR-0021). # Recommended flat-roof depth (mm); Elmhurst re-lodges 200 mm (ADR-0021).
_RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200 _RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200
@ -109,7 +110,7 @@ def _roof_recommendation(
epc: EpcPropertyData, epc: EpcPropertyData,
products: ProductRepository, products: ProductRepository,
*, *,
measure_type: str, measure_type: MeasureType,
description: str, description: str,
thickness_mm: int, thickness_mm: int,
) -> Recommendation: ) -> Recommendation:

View file

@ -24,6 +24,7 @@ from datatypes.epc.domain.epc_property_data import (
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.products import Products, SolarCostInputs from domain.modelling.products import Products, SolarCostInputs
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, SolarOverlay from domain.modelling.simulation import EpcSimulation, SolarOverlay
from domain.modelling.solar_potential import ( from domain.modelling.solar_potential import (
@ -37,7 +38,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
_SOLAR_SURFACE = "Solar PV" _SOLAR_SURFACE = "Solar PV"
_SOLAR_MEASURE_TYPE = "solar_pv" _SOLAR_MEASURE_TYPE = MeasureType.SOLAR_PV
# The fixed, representative battery capacity for the with-battery variant # The fixed, representative battery capacity for the with-battery variant
# (ADR-0026) — a flagged estimate (see the rate sheet), 5 kWh. # (ADR-0026) — a flagged estimate (see the rate sheet), 5 kWh.

View file

@ -24,12 +24,13 @@ from datatypes.epc.domain.epc_property_data import (
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from domain.building_geometry import gross_heat_loss_wall_area from domain.building_geometry import gross_heat_loss_wall_area
from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
_EXTERNAL_MEASURE_TYPE: Final[str] = "external_wall_insulation" _EXTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.EXTERNAL_WALL_INSULATION
_INTERNAL_MEASURE_TYPE: Final[str] = "internal_wall_insulation" _INTERNAL_MEASURE_TYPE: Final[MeasureType] = MeasureType.INTERNAL_WALL_INSULATION
# RdSAP `wall_construction` codes (consistent across paths for 1-5). # RdSAP `wall_construction` codes (consistent across paths for 1-5).
_WALL_SOLID_BRICK: Final[int] = 3 _WALL_SOLID_BRICK: Final[int] = 3
@ -56,7 +57,7 @@ _SOLID_WALL_INSULATION_MM: Final[int] = 100
# Which solid-wall Options each construction can take (ADR-0019). Solid brick # Which solid-wall Options each construction can take (ADR-0019). Solid brick
# and system-built take both; timber-frame takes IWI only (EWI not # and system-built take both; timber-frame takes IWI only (EWI not
# constructable). The breathable cob/stone exclusions take neither (never keyed). # constructable). The breathable cob/stone exclusions take neither (never keyed).
_CONSTRUCTABLE_OPTIONS: Final[dict[int, tuple[str, ...]]] = { _CONSTRUCTABLE_OPTIONS: Final[dict[int, tuple[MeasureType, ...]]] = {
_WALL_SOLID_BRICK: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE), _WALL_SOLID_BRICK: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE),
_WALL_SYSTEM_BUILT: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE), _WALL_SYSTEM_BUILT: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE),
_WALL_TIMBER_FRAME: (_INTERNAL_MEASURE_TYPE,), _WALL_TIMBER_FRAME: (_INTERNAL_MEASURE_TYPE,),
@ -74,7 +75,7 @@ _DESCRIPTION: Final[dict[str, str]] = {
def _solid_wall_option( def _solid_wall_option(
epc: EpcPropertyData, products: ProductRepository, measure_type: str epc: EpcPropertyData, products: ProductRepository, measure_type: MeasureType
) -> MeasureOption: ) -> MeasureOption:
"""Build one solid-wall Measure Option: its insulation overlay (100 mm at the """Build one solid-wall Measure Option: its insulation overlay (100 mm at the
External/Internal `wall_insulation_type`) priced at the heat-loss wall area.""" External/Internal `wall_insulation_type`) priced at the heat-loss wall area."""

View file

@ -16,11 +16,12 @@ mirrors legacy ``Property.has_ventilation``.
from typing import Optional from typing import Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, VentilationOverlay from domain.modelling.simulation import EpcSimulation, VentilationOverlay
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
_VENTILATION_MEASURE_TYPE = "mechanical_ventilation" _VENTILATION_MEASURE_TYPE = MeasureType.MECHANICAL_VENTILATION
# The SAP10.2 §2 mechanical-ventilation kind installed: decentralised MEV # The SAP10.2 §2 mechanical-ventilation kind installed: decentralised MEV
# ("mechanical extract, decentralised (MEV dc)" → MechanicalVentilationKind # ("mechanical extract, decentralised (MEV dc)" → MechanicalVentilationKind

View file

@ -12,11 +12,12 @@ from datatypes.epc.domain.epc_property_data import (
EpcPropertyData, EpcPropertyData,
) )
from domain.building_geometry import gross_heat_loss_wall_area from domain.building_geometry import gross_heat_loss_wall_area
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
_CAVITY_MEASURE_TYPE = "cavity_wall_insulation" _CAVITY_MEASURE_TYPE = MeasureType.CAVITY_WALL_INSULATION
# RdSAP 10 Table 5 wall_construction: 4 = "Cavity". Table 6 # RdSAP 10 Table 5 wall_construction: 4 = "Cavity". Table 6
# wall_insulation_type: 4 = "as-built / assumed" (uninsulated), 2 = "Filled # wall_insulation_type: 4 = "as-built / assumed" (uninsulated), 2 = "Filled

View file

@ -0,0 +1,35 @@
"""MeasureType — the canonical vocabulary of the measures the Modelling stage
models.
One member per Recommendation Generator option. A ``StrEnum`` so each member
*is* its string value: it persists straight into the ``recommendation`` varchar
column, is the optimiser's group-by key, and compares equal to the raw strings
the catalogue and EPC carry so it can replace the per-generator string
constants as the single source of truth without a persistence or optimiser
change. It is also the vocabulary the ``considered_measures`` allowlist speaks
(mirroring the legacy engine's ``inclusions``).
"""
from __future__ import annotations
from enum import StrEnum
class MeasureType(StrEnum):
"""A measure the Modelling stage can recommend (CONTEXT.md)."""
CAVITY_WALL_INSULATION = "cavity_wall_insulation"
EXTERNAL_WALL_INSULATION = "external_wall_insulation"
INTERNAL_WALL_INSULATION = "internal_wall_insulation"
LOFT_INSULATION = "loft_insulation"
SLOPING_CEILING_INSULATION = "sloping_ceiling_insulation"
FLAT_ROOF_INSULATION = "flat_roof_insulation"
SUSPENDED_FLOOR_INSULATION = "suspended_floor_insulation"
SOLID_FLOOR_INSULATION = "solid_floor_insulation"
DOUBLE_GLAZING = "double_glazing"
SECONDARY_GLAZING = "secondary_glazing"
LOW_ENERGY_LIGHTING = "low_energy_lighting"
MECHANICAL_VENTILATION = "mechanical_ventilation"
HIGH_HEAT_RETENTION_STORAGE_HEATERS = "high_heat_retention_storage_heaters"
AIR_SOURCE_HEAT_PUMP = "air_source_heat_pump"
SOLAR_PV = "solar_pv"

View file

@ -24,17 +24,18 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.generators.ventilation_recommendation import ( from domain.modelling.generators.ventilation_recommendation import (
recommend_ventilation, recommend_ventilation,
) )
from domain.modelling.measure_type import MeasureType
from domain.modelling.optimisation.optimiser import MeasureDependency, ScoredOption from domain.modelling.optimisation.optimiser import MeasureDependency, ScoredOption
from domain.modelling.recommendation import MeasureOption, Recommendation from domain.modelling.recommendation import MeasureOption, Recommendation
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
# The measure types that force a ventilation dependency (cf. legacy # The measure types that force a ventilation dependency (cf. legacy
# `assumptions.measures_needing_ventilation`). # `assumptions.measures_needing_ventilation`).
MEASURES_NEEDING_VENTILATION: frozenset[str] = frozenset( MEASURES_NEEDING_VENTILATION: frozenset[MeasureType] = frozenset(
{ {
"cavity_wall_insulation", MeasureType.CAVITY_WALL_INSULATION,
"internal_wall_insulation", MeasureType.INTERNAL_WALL_INSULATION,
"external_wall_insulation", MeasureType.EXTERNAL_WALL_INSULATION,
} }
) )

View file

@ -24,6 +24,7 @@ from dataclasses import dataclass
from typing import Optional, Protocol, Sequence from typing import Optional, Protocol, Sequence
from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import Score from domain.modelling.scoring.package_scorer import Score
from domain.modelling.recommendation import MeasureOption from domain.modelling.recommendation import MeasureOption
from domain.modelling.simulation import EpcSimulation from domain.modelling.simulation import EpcSimulation
@ -48,7 +49,7 @@ class MeasureDependency:
figure, the repair decision, and the persisted package. Held as data so figure, the repair decision, and the persisted package. Held as data so
extending the triggers is a data edit, not control flow.""" extending the triggers is a data edit, not control flow."""
triggers: frozenset[str] triggers: frozenset[MeasureType]
required: ScoredOption required: ScoredOption
@ -300,11 +301,11 @@ def _inject(
"""``chosen`` plus every forced dependency whose triggers intersect the """``chosen`` plus every forced dependency whose triggers intersect the
chosen measure-types, de-duplicated by required measure-type (a dependency chosen measure-types, de-duplicated by required measure-type (a dependency
several measures trigger is injected once).""" several measures trigger is injected once)."""
chosen_types: set[str] = {s.option.measure_type for s in chosen} chosen_types: set[MeasureType] = {s.option.measure_type for s in chosen}
injected: list[ScoredOption] = list(chosen) injected: list[ScoredOption] = list(chosen)
present: set[str] = set(chosen_types) present: set[MeasureType] = set(chosen_types)
for dependency in dependencies: for dependency in dependencies:
required_type: str = dependency.required.option.measure_type required_type: MeasureType = dependency.required.option.measure_type
if dependency.triggers & chosen_types and required_type not in present: if dependency.triggers & chosen_types and required_type not in present:
injected.append(dependency.required) injected.append(dependency.required)
present.add(required_type) present.add(required_type)

View file

@ -15,6 +15,7 @@ from typing import Optional
from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc import Epc
from domain.billing.bill import Bill from domain.billing.bill import Bill
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import Score from domain.modelling.scoring.package_scorer import Score
from domain.modelling.recommendation import Cost from domain.modelling.recommendation import Cost
from domain.modelling.scoring.scoring import MeasureImpact from domain.modelling.scoring.scoring import MeasureImpact
@ -33,7 +34,7 @@ class PlanMeasure:
billing has run (persisted as NULL ADR-0014 amendment). They are distinct billing has run (persisted as NULL ADR-0014 amendment). They are distinct
from `impact.energy_savings_kwh_per_yr`, which is *primary* energy.""" from `impact.energy_savings_kwh_per_yr`, which is *primary* energy."""
measure_type: str measure_type: MeasureType
description: str description: str
cost: Cost cost: Cost
impact: MeasureImpact impact: MeasureImpact

View file

@ -11,6 +11,7 @@ CONTEXT.md.
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
from domain.modelling.measure_type import MeasureType
from domain.modelling.simulation import EpcSimulation from domain.modelling.simulation import EpcSimulation
@ -28,7 +29,7 @@ class Cost:
class MeasureOption: class MeasureOption:
"""One mutually-exclusive way to treat a Recommendation's surface.""" """One mutually-exclusive way to treat a Recommendation's surface."""
measure_type: str measure_type: MeasureType
description: str description: str
overlay: EpcSimulation overlay: EpcSimulation
cost: Optional[Cost] = None cost: Optional[Cost] = None

View file

@ -23,6 +23,7 @@ from typing import Any, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.coordinates import Coordinates from domain.geospatial.coordinates import Coordinates
from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType
from domain.modelling.plan import Plan from domain.modelling.plan import Plan
from domain.modelling.scenario import Scenario from domain.modelling.scenario import Scenario
from domain.property.property import Property, PropertyIdentity from domain.property.property import Property, PropertyIdentity
@ -38,6 +39,7 @@ from repositories.fuel_rates.fuel_rates_static_file_repository import (
) )
from repositories.geospatial.geospatial_repository import GeospatialRepository from repositories.geospatial.geospatial_repository import GeospatialRepository
from repositories.product.product_json_repository import ProductJsonRepository from repositories.product.product_json_repository import ProductJsonRepository
from repositories.product.product_repository import ProductRepository
from tests.orchestration.fakes import ( from tests.orchestration.fakes import (
FakeEpcRepo, FakeEpcRepo,
FakePlanRepository, FakePlanRepository,
@ -171,6 +173,9 @@ def run_modelling(
current_market_value: Optional[float] = None, current_market_value: Optional[float] = None,
planning_restrictions: PlanningRestrictions = PlanningRestrictions(), planning_restrictions: PlanningRestrictions = PlanningRestrictions(),
solar_insights: Optional[dict[str, Any]] = None, solar_insights: Optional[dict[str, Any]] = None,
considered_measures: Optional[frozenset[MeasureType]] = None,
products: Optional[ProductRepository] = None,
scenario: Optional[Scenario] = None,
print_table: bool = True, print_table: bool = True,
) -> Plan: ) -> Plan:
"""Run ONLY the Modelling stage over ``epc`` with no database — skipping """Run ONLY the Modelling stage over ``epc`` with no database — skipping
@ -182,7 +187,22 @@ def run_modelling(
``solar_insights`` is the Property's raw Google Solar ``buildingInsights`` ``solar_insights`` is the Property's raw Google Solar ``buildingInsights``
JSON (as persisted by ``SolarRepository``); when given, the solar JSON (as persisted by ``SolarRepository``); when given, the solar
Recommendation Generator sees the dwelling's potential and can offer Solar Recommendation Generator sees the dwelling's potential and can offer Solar
PV Options (ADR-0026).""" PV Options (ADR-0026).
``products`` overrides the Product catalogue source (default: the JSON
sample catalogue) pass a read-only ``ProductPostgresRepository`` to price
against the live ``material`` table. ``scenario`` overrides the default
Increasing-EPC-to-``goal_band`` Scenario pass a Scenario read from the DB
so the run targets a real ``scenario_id`` (its ``goal_value``/budget drive
the Optimiser); the computed Plan is then keyed by that Scenario's id."""
scenario_obj = scenario or Scenario(
id=_SCENARIO_ID,
goal="Increasing EPC",
goal_value=goal_band,
budget=None,
is_default=True,
)
scenario_id = scenario_obj.id
plan_repo = FakePlanRepository() plan_repo = FakePlanRepository()
property_repo = FakePropertyRepo( property_repo = FakePropertyRepo(
{ {
@ -206,18 +226,8 @@ def run_modelling(
if solar_insights is not None if solar_insights is not None
else None else None
), ),
scenario=FakeScenarioRepository( scenario=FakeScenarioRepository({scenario_id: scenario_obj}),
{ product=products or ProductJsonRepository(catalogue_path),
_SCENARIO_ID: Scenario(
id=_SCENARIO_ID,
goal="Increasing EPC",
goal_value=goal_band,
budget=None,
is_default=True,
)
}
),
product=ProductJsonRepository(catalogue_path),
plan=plan_repo, plan=plan_repo,
) )
@ -227,11 +237,12 @@ def run_modelling(
fuel_rates=FuelRatesStaticFileRepository(), fuel_rates=FuelRatesStaticFileRepository(),
).run( ).run(
property_ids=[_PROPERTY_ID], property_ids=[_PROPERTY_ID],
scenario_ids=[_SCENARIO_ID], scenario_ids=[scenario_id],
portfolio_id=_PORTFOLIO_ID, portfolio_id=_PORTFOLIO_ID,
considered_measures=considered_measures,
) )
plan = plan_repo.saved[(_PROPERTY_ID, _SCENARIO_ID)] plan = plan_repo.saved[(_PROPERTY_ID, scenario_id)]
if print_table: if print_table:
print("\n" + format_plan_table(plan)) print("\n" + format_plan_table(plan))
return plan return plan

View file

@ -7,7 +7,9 @@ from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.billing.bill import Bill, EnergyBreakdown from domain.billing.bill import Bill, EnergyBreakdown
from domain.billing.bill_derivation import BillDerivation from domain.billing.bill_derivation import BillDerivation
from domain.modelling.considered_measures import restrict_to_considered_measures
from domain.modelling.generators.floor_recommendation import recommend_floor_insulation from domain.modelling.generators.floor_recommendation import recommend_floor_insulation
from domain.modelling.measure_type import MeasureType
from domain.modelling.optimisation.measure_dependency import ventilation_dependency from domain.modelling.optimisation.measure_dependency import ventilation_dependency
from domain.modelling.optimisation.optimiser import ( from domain.modelling.optimisation.optimiser import (
MeasureDependency, MeasureDependency,
@ -92,8 +94,16 @@ class ModellingOrchestrator:
self._fuel_rates = fuel_rates self._fuel_rates = fuel_rates
def run( def run(
self, property_ids: list[int], scenario_ids: list[int], portfolio_id: int self,
property_ids: list[int],
scenario_ids: list[int],
portfolio_id: int,
*,
considered_measures: Optional[frozenset[MeasureType]] = None,
) -> None: ) -> None:
"""Model the batch. ``considered_measures`` restricts the run to those
measure types (mirroring the legacy `inclusions`); None considers every
modelled measure."""
scorer = PackageScorer(self._calculator) scorer = PackageScorer(self._calculator)
# Resolve Fuel Rates once and reuse the BillDerivation across the batch, # Resolve Fuel Rates once and reuse the BillDerivation across the batch,
# so every baseline/post bill is priced at the same snapshot (ADR-0014). # so every baseline/post bill is priced at the same snapshot (ADR-0014).
@ -120,6 +130,7 @@ class ModellingOrchestrator:
current_market_value=prop.current_market_value, current_market_value=prop.current_market_value,
planning_restrictions=prop.planning_restrictions, planning_restrictions=prop.planning_restrictions,
solar_potential=solar_potential, solar_potential=solar_potential,
considered_measures=considered_measures,
) )
uow.plan.save( uow.plan.save(
plan, plan,
@ -141,16 +152,22 @@ class ModellingOrchestrator:
current_market_value: Optional[float], current_market_value: Optional[float],
planning_restrictions: PlanningRestrictions, planning_restrictions: PlanningRestrictions,
solar_potential: Optional[SolarPotential], solar_potential: Optional[SolarPotential],
considered_measures: Optional[frozenset[MeasureType]],
) -> Plan: ) -> Plan:
"""Generate → score → optimise → re-score/repair → attribute → bill → """Generate → score → optimise → re-score/repair → attribute → bill →
assemble the Plan for one Property + Scenario.""" assemble the Plan for one Property + Scenario."""
groups: list[list[ScoredOption]] = _scored_candidate_groups( groups: list[list[ScoredOption]] = _scored_candidate_groups(
scorer, effective_epc, products, planning_restrictions, solar_potential scorer,
effective_epc,
products,
planning_restrictions,
solar_potential,
considered_measures,
) )
# Forced Measure Dependencies (ventilation) are excluded from the pool # Forced Measure Dependencies (ventilation) are excluded from the pool
# but injected into the package before the re-score (ADR-0016). # but injected into the package before the re-score (ADR-0016).
dependencies: list[MeasureDependency] = _measure_dependencies( dependencies: list[MeasureDependency] = _measure_dependencies(
effective_epc, products effective_epc, products, considered_measures
) )
package: OptimisedPackage = optimise_package( package: OptimisedPackage = optimise_package(
groups=groups, groups=groups,
@ -224,11 +241,13 @@ def _candidate_recommendations(
products: ProductRepository, products: ProductRepository,
planning_restrictions: PlanningRestrictions, planning_restrictions: PlanningRestrictions,
solar_potential: Optional[SolarPotential], solar_potential: Optional[SolarPotential],
considered_measures: Optional[frozenset[MeasureType]],
) -> list[Recommendation]: ) -> list[Recommendation]:
"""Run every Recommendation Generator; keep the ones that apply. Solid-wall """Run every Recommendation Generator; keep the ones that apply. Solid-wall
insulation, glazing, heating and solar are additionally gated by the insulation, glazing, heating and solar are additionally gated by the
Property's planning protections (ADR-0019 / ADR-0022 / ADR-0024 / ADR-0026); Property's planning protections (ADR-0019 / ADR-0022 / ADR-0024 / ADR-0026);
solar also needs the Property's Google solar potential.""" solar also needs the Property's Google solar potential. ``considered_measures``
then restricts the survivors to the run's allowlist (None = all)."""
found = ( found = (
recommend_cavity_wall(effective_epc, products), recommend_cavity_wall(effective_epc, products),
recommend_solid_wall(effective_epc, products, planning_restrictions), recommend_solid_wall(effective_epc, products, planning_restrictions),
@ -241,19 +260,33 @@ def _candidate_recommendations(
effective_epc, products, solar_potential, planning_restrictions effective_epc, products, solar_potential, planning_restrictions
), ),
) )
return [recommendation for recommendation in found if recommendation is not None] applicable = [
recommendation for recommendation in found if recommendation is not None
]
return restrict_to_considered_measures(applicable, considered_measures)
def _measure_dependencies( def _measure_dependencies(
effective_epc: EpcPropertyData, products: ProductRepository effective_epc: EpcPropertyData,
products: ProductRepository,
considered_measures: Optional[frozenset[MeasureType]],
) -> list[MeasureDependency]: ) -> list[MeasureDependency]:
"""The forced Measure Dependencies for this Property — currently just """The forced Measure Dependencies for this Property — currently just
ventilation, suppressed when the dwelling is already mechanically ventilation, suppressed when the dwelling is already mechanically
ventilated (ADR-0016).""" ventilated (ADR-0016). A dependency whose required measure is outside the
run's allowlist is also suppressed, so a restricted run forces nothing it is
not considering."""
dependency: Optional[MeasureDependency] = ventilation_dependency( dependency: Optional[MeasureDependency] = ventilation_dependency(
effective_epc, products effective_epc, products
) )
return [dependency] if dependency is not None else [] if dependency is None:
return []
if (
considered_measures is not None
and dependency.required.option.measure_type not in considered_measures
):
return []
return [dependency]
def _scored_candidate_groups( def _scored_candidate_groups(
@ -262,12 +295,13 @@ def _scored_candidate_groups(
products: ProductRepository, products: ProductRepository,
planning_restrictions: PlanningRestrictions, planning_restrictions: PlanningRestrictions,
solar_potential: Optional[SolarPotential], solar_potential: Optional[SolarPotential],
considered_measures: Optional[frozenset[MeasureType]],
) -> list[list[ScoredOption]]: ) -> list[list[ScoredOption]]:
"""One group per Recommendation: each Option scored independently against """One group per Recommendation: each Option scored independently against
the baseline (role-1 warm-start signal, ADR-0016).""" the baseline (role-1 warm-start signal, ADR-0016)."""
groups: list[list[ScoredOption]] = [] groups: list[list[ScoredOption]] = []
for recommendation in _candidate_recommendations( for recommendation in _candidate_recommendations(
effective_epc, products, planning_restrictions, solar_potential effective_epc, products, planning_restrictions, solar_potential, considered_measures
): ):
options = list(recommendation.options) options = list(recommendation.options)
impacts: list[MeasureImpact] = independent_option_impacts( impacts: list[MeasureImpact] = independent_option_impacts(

View file

@ -17,11 +17,16 @@ class ProductPostgresRepository(ProductRepository):
self._session = session self._session = session
def get(self, measure_type: str) -> Product: def get(self, measure_type: str) -> Product:
# The live catalogue holds many active rows per type; order by id so the
# pick is deterministic (a re-seed prices the same) rather than relying
# on the database's physical row order.
row: MaterialRow | None = self._session.exec( row: MaterialRow | None = self._session.exec(
select(MaterialRow).where( select(MaterialRow)
.where(
col(MaterialRow.type) == measure_type, col(MaterialRow.type) == measure_type,
col(MaterialRow.is_active).is_(True), col(MaterialRow.is_active).is_(True),
) )
.order_by(col(MaterialRow.id))
).first() ).first()
if row is None: if row is None:
raise ValueError(f"no active product for measure type {measure_type!r}") raise ValueError(f"no active product for measure type {measure_type!r}")

View file

@ -3,30 +3,42 @@ print the recommendations for inspection.
The local DB's Properties have no linked, ingested EPC yet (Ingestion's source The local DB's Properties have no linked, ingested EPC yet (Ingestion's source
clients are still stubbed #1136), so this script does the ingestion step clients are still stubbed #1136), so this script does the ingestion step
inline for inspection: it reads each Property's UPRN from the DB, fetches the inline: it reads each Property's UPRN from the DB, fetches the latest EPC
latest EPC **live** from the gov EPC API by UPRN, then runs the Modelling stage **live** from the gov EPC API by UPRN, resolves the UPRN's spatial reference
in memory (every Recommendation Generator the Optimiser a costed, attributed from S3, and fetches Google Solar then runs the Modelling stage (every
Plan). It is read-only on the DB (just the UPRN lookup) and persists nothing Recommendation Generator the Optimiser a costed, attributed Plan). The same
purely for inspecting recommendations. Prints a per-Property plan table and local computation runs whether or not you store the result: by default it
writes a Markdown + CSV summary. persists **nothing** (the run is for inspecting recommendations); pass
`--persist` to write the inputs + the Plan to the DB.
To keep the inspected recommendations identical to what gets stored, **both
modes price against the live ``material`` catalogue (read-only)** and model
against a real **Scenario** read from the DB not the JSON sample catalogue.
Pass `--scenario-id` to target a real Scenario (its ``goal_value`` drives the
band); without it the run synthesises an Increasing-EPC-to-``--goal`` Scenario.
``--measures`` restricts the run to a comma-separated set of measure types
(mirroring the legacy `inclusions`) e.g. only HHRSH + Solar PV.
Config: loads `backend/.env` for the DB creds (`DB_*`), the EPC API token Config: loads `backend/.env` for the DB creds (`DB_*`), the EPC API token
(`EPC_AUTH_TOKEN`), the Google Solar key (`GOOGLE_SOLAR_API_KEY`) and the S3 (`OPEN_EPC_API_TOKEN` the Bearer token for the new gov API), the Google Solar
key (`GOOGLE_SOLAR_API_KEY`) and the S3
reference bucket (`DATA_BUCKET`) the agent never sees the secrets. AWS creds reference bucket (`DATA_BUCKET`) the agent never sees the secrets. AWS creds
come from the ambient `~/.aws` profile. Run from the worktree root so imports come from the ambient `~/.aws` profile. Run from the worktree root:
resolve to this checkout:
python -m scripts.run_modelling_e2e 115 116 117 # goal band C (default) # inspect only (no DB writes), HHRSH + Solar PV, against Scenario 1263:
python -m scripts.run_modelling_e2e --goal B 115 116 117 # a different target band python -m scripts.run_modelling_e2e --scenario-id 1263 \
python -m scripts.run_modelling_e2e --no-solar 115 116 # skip the Google Solar leg --measures high_heat_retention_storage_heaters,solar_pv 115 116 117
# same run, but persist the Plans (needs --portfolio-id):
python -m scripts.run_modelling_e2e --scenario-id 1263 --portfolio-id 4 \
--measures high_heat_retention_storage_heaters,solar_pv --persist 115 116 117
python -m scripts.run_modelling_e2e --no-solar 115 116 # skip the Google leg
Per Property the script resolves the UPRN's spatial reference from the Ordnance Per Property the spatial reference (S3 Open-UPRN parquet) gives the planning
Survey Open-UPRN parquet in S3 (`GeospatialS3Repository`): the planning protections (conservation/listed/heritage gate the wall + solar measures) and
protections (conservation/listed/heritage) gate the wall + solar measures, and the coordinates that drive the Google Solar fetch (ADR-0026). Buildings S3
the coordinates drive a live Google Solar `buildingInsights` fetch so the Solar doesn't cover, or that Google has no solar coverage for, fall back to
PV Options can fire (ADR-0026). Buildings S3 doesn't cover, or that Google has unrestricted / no-solar and are still modelled. Pass `--no-solar` to skip the
no solar coverage for, fall back to unrestricted / no-solar and are still Google leg.
modelled. Pass `--no-solar` to skip the Google leg entirely.
""" """
from __future__ import annotations from __future__ import annotations
@ -47,8 +59,10 @@ sys.path.insert(0, str(_REPO_ROOT)) # worktree root first — avoid the import
from datatypes.epc.domain.epc_property_data import EpcPropertyData # noqa: E402 from datatypes.epc.domain.epc_property_data import EpcPropertyData # noqa: E402
from domain.geospatial.planning_restrictions import PlanningRestrictions # noqa: E402 from domain.geospatial.planning_restrictions import PlanningRestrictions # noqa: E402
from domain.geospatial.spatial_reference import SpatialReference # noqa: E402 from domain.geospatial.spatial_reference import SpatialReference # noqa: E402
from domain.modelling.measure_type import MeasureType # noqa: E402
from domain.modelling.plan import Plan, PlanMeasure # noqa: E402 from domain.modelling.plan import Plan, PlanMeasure # noqa: E402
from harness.console import DEFAULT_CATALOGUE, run_modelling # noqa: E402 from domain.modelling.scenario import Scenario # noqa: E402
from harness.console import run_modelling # noqa: E402
from harness.plan_table import format_plan_table # noqa: E402 from harness.plan_table import format_plan_table # noqa: E402
from infrastructure.epc_client.epc_client_service import EpcClientService # noqa: E402 from infrastructure.epc_client.epc_client_service import EpcClientService # noqa: E402
from infrastructure.solar.google_solar_api_client import ( # noqa: E402 from infrastructure.solar.google_solar_api_client import ( # noqa: E402
@ -59,7 +73,15 @@ from repositories.geospatial.geospatial_s3_repository import ( # noqa: E402
GeospatialS3Repository, GeospatialS3Repository,
ParquetReader, ParquetReader,
) )
from sqlalchemy import create_engine, text # noqa: E402 from repositories.product.product_postgres_repository import ( # noqa: E402
ProductPostgresRepository,
)
from repositories.postgres_unit_of_work import PostgresUnitOfWork # noqa: E402
from repositories.scenario.scenario_postgres_repository import ( # noqa: E402
ScenarioPostgresRepository,
)
from sqlalchemy import Engine, create_engine, text # noqa: E402
from sqlmodel import Session # noqa: E402
_ENV_PATH = _REPO_ROOT / "backend" / ".env" _ENV_PATH = _REPO_ROOT / "backend" / ".env"
_MARKDOWN_PATH = Path("modelling_e2e.md") _MARKDOWN_PATH = Path("modelling_e2e.md")
@ -130,11 +152,15 @@ def _solar_insights_for(
return None # no Google solar coverage at this point — model without it return None # no Google solar coverage at this point — model without it
def _uprns_for(property_ids: list[int]) -> dict[int, Optional[int]]: def _engine() -> Engine:
"""Read each Property's UPRN from the DB (read-only).""" """A connection-pooled engine to DevAssessmentModelDB (DB_* creds)."""
engine = create_engine( return create_engine(
_db_url(), pool_pre_ping=True, connect_args={"connect_timeout": 10} _db_url(), pool_pre_ping=True, connect_args={"connect_timeout": 10}
) )
def _uprns_for(engine: Engine, property_ids: list[int]) -> dict[int, Optional[int]]:
"""Read each Property's UPRN from the DB (read-only)."""
with engine.connect() as conn: with engine.connect() as conn:
rows = conn.execute( rows = conn.execute(
text("SELECT id, uprn FROM property WHERE id = ANY(:ids)"), text("SELECT id, uprn FROM property WHERE id = ANY(:ids)"),
@ -143,6 +169,26 @@ def _uprns_for(property_ids: list[int]) -> dict[int, Optional[int]]:
return {int(pid): (int(uprn) if uprn is not None else None) for pid, uprn in rows} return {int(pid): (int(uprn) if uprn is not None else None) for pid, uprn in rows}
def _scenario_for(session: Session, scenario_id: int) -> Scenario:
"""Read the Scenario the run targets (read-only). An Increasing-EPC Scenario
must carry a ``goal_value`` (band) the old null-band rows were a fixed bug
and crash the Optimiser's target — so reject one that does not."""
scenario: Scenario = ScenarioPostgresRepository(session).get_many([scenario_id])[0]
if scenario.goal == "Increasing EPC" and not scenario.goal_value:
raise ValueError(
f"scenario {scenario_id} has no goal_value (band); pick a recent one"
)
return scenario
def _parse_measures(raw: Optional[str]) -> Optional[frozenset[MeasureType]]:
"""Parse `--measures a,b,c` into a `considered_measures` allowlist, or None
(consider every modelled measure) when unset. Raises on an unknown type."""
if raw is None:
return None
return frozenset(MeasureType(token.strip()) for token in raw.split(",") if token.strip())
def _context_summary( def _context_summary(
spatial: Optional[SpatialReference], solar_insights: Optional[dict[str, Any]] spatial: Optional[SpatialReference], solar_insights: Optional[dict[str, Any]]
) -> str: ) -> str:
@ -173,10 +219,57 @@ def _measure_summary(measure: PlanMeasure) -> str:
) )
def _persist(
engine: Engine,
*,
property_id: int,
uprn: int,
portfolio_id: int,
scenario: Scenario,
epc: EpcPropertyData,
spatial: Optional[SpatialReference],
solar_insights: Optional[dict[str, Any]],
plan: Plan,
) -> None:
"""Write the run's inputs (EPC + spatial + solar) and the computed Plan to
the DB in one Unit of Work, then commit. ``PlanPostgresRepository`` replaces
any existing Plan for ``(property_id, scenario.id)`` (idempotent re-run)."""
with PostgresUnitOfWork(lambda: Session(engine)) as uow:
uow.epc.save(epc, property_id=property_id, portfolio_id=portfolio_id)
if spatial is not None:
uow.spatial.save(uprn, spatial)
if solar_insights is not None:
uow.solar.save(property_id, solar_insights)
uow.plan.save(
plan,
property_id=property_id,
scenario_id=scenario.id,
portfolio_id=portfolio_id,
is_default=scenario.is_default,
)
uow.commit()
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("property_ids", type=int, nargs="+", help="Property ids to model") parser.add_argument("property_ids", type=int, nargs="+", help="Property ids to model")
parser.add_argument("--goal", default="C", help="target EPC band (default C)") parser.add_argument("--goal", default="C", help="target band when no --scenario-id (default C)")
parser.add_argument(
"--scenario-id", type=int, default=None, help="model against this DB Scenario"
)
parser.add_argument(
"--measures",
default=None,
help="comma-separated measure types to consider (default: all)",
)
parser.add_argument(
"--portfolio-id", type=int, default=None, help="portfolio id (required for --persist)"
)
parser.add_argument(
"--persist",
action="store_true",
help="WRITE the inputs + Plan to the DB (default: inspect only, no writes)",
)
parser.add_argument( parser.add_argument(
"--no-solar", "--no-solar",
action="store_true", action="store_true",
@ -184,18 +277,42 @@ def main() -> None:
) )
args = parser.parse_args() args = parser.parse_args()
if args.persist and (args.scenario_id is None or args.portfolio_id is None):
parser.error("--persist requires --scenario-id and --portfolio-id")
_load_env(_ENV_PATH) _load_env(_ENV_PATH)
epc_client = EpcClientService(os.environ["EPC_AUTH_TOKEN"]) # The new gov EPC API (Bearer) authenticates with OPEN_EPC_API_TOKEN — the
# name is misleading; EPC_AUTH_TOKEN is dead (403). Verified against the
# /api/domestic/search endpoint.
epc_client = EpcClientService(os.environ["OPEN_EPC_API_TOKEN"])
geospatial = GeospatialS3Repository(_s3_parquet_reader(os.environ["DATA_BUCKET"])) geospatial = GeospatialS3Repository(_s3_parquet_reader(os.environ["DATA_BUCKET"]))
solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"]) solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"])
uprns = _uprns_for(args.property_ids) engine = _engine()
considered = _parse_measures(args.measures)
print( uprns = _uprns_for(engine, args.property_ids)
f"modelling {len(args.property_ids)} propertie(s) (goal band {args.goal}); " # One read-only session for the live `material` catalogue, reused across the
f"EPCs fetched live by UPRN, modelled in memory — no DB writes...\n" # batch so both store and no-store runs price against the same DB rows.
catalogue_session = Session(engine)
products = ProductPostgresRepository(catalogue_session)
scenario: Optional[Scenario] = (
_scenario_for(catalogue_session, args.scenario_id)
if args.scenario_id is not None
else None
) )
md_lines: list[str] = [f"# Modelling recommendations (goal band {args.goal})\n"] target = (
f"scenario {scenario.id} (band {scenario.goal_value})"
if scenario is not None
else f"synthesised Increasing-EPC band {args.goal}"
)
measures_note = ",".join(sorted(considered)) if considered else "all measures"
mode = "PERSISTING to DB" if args.persist else "no DB writes"
print(
f"modelling {len(args.property_ids)} propertie(s) · {target} · {measures_note} · "
f"{mode} (DB material catalogue, live EPC/solar)...\n"
)
md_lines: list[str] = [f"# Modelling recommendations ({target}, {measures_note})\n"]
csv_rows: list[str] = [ csv_rows: list[str] = [
"property_id,uprn,baseline_sap,post_sap,measures,measure_types,cost_of_works" "property_id,uprn,baseline_sap,post_sap,measures,measure_types,cost_of_works"
] ]
@ -218,11 +335,26 @@ def main() -> None:
plan: Plan = run_modelling( plan: Plan = run_modelling(
epc, epc,
goal_band=args.goal, goal_band=args.goal,
catalogue_path=DEFAULT_CATALOGUE,
planning_restrictions=restrictions, planning_restrictions=restrictions,
solar_insights=solar_insights, solar_insights=solar_insights,
considered_measures=considered,
products=products,
scenario=scenario,
print_table=False, print_table=False,
) )
if args.persist:
assert scenario is not None # guaranteed by the --persist guard
_persist(
engine,
property_id=property_id,
uprn=uprn,
portfolio_id=args.portfolio_id,
scenario=scenario,
epc=epc,
spatial=spatial,
solar_insights=solar_insights,
plan=plan,
)
except Exception as error: # noqa: BLE001 — one bad property must not stop the run except Exception as error: # noqa: BLE001 — one bad property must not stop the run
line = f"property {property_id} (uprn {uprn}): ERROR — {type(error).__name__}: {error}" line = f"property {property_id} (uprn {uprn}): ERROR — {type(error).__name__}: {error}"
print(line + "\n") print(line + "\n")
@ -255,6 +387,7 @@ def main() -> None:
f"{'|'.join(measure_types)},{plan.cost_of_works:.0f}" f"{'|'.join(measure_types)},{plan.cost_of_works:.0f}"
) )
catalogue_session.close()
_MARKDOWN_PATH.write_text("\n".join(md_lines) + "\n", encoding="utf-8") _MARKDOWN_PATH.write_text("\n".join(md_lines) + "\n", encoding="utf-8")
_CSV_PATH.write_text("\n".join(csv_rows) + "\n", encoding="utf-8") _CSV_PATH.write_text("\n".join(csv_rows) + "\n", encoding="utf-8")
print(f"wrote {_MARKDOWN_PATH.resolve()}") print(f"wrote {_MARKDOWN_PATH.resolve()}")

View file

@ -0,0 +1,83 @@
"""Slice 3 — `restrict_to_considered_measures`, the pure allowlist that limits a
run to a chosen set of measure types (mirroring the legacy engine's
`inclusions`).
It filters at the Option level, so a multi-option Recommendation (e.g. Heating &
Hot Water offering both HHRSH and an ASHP bundle) is kept with only its allowed
Options; a Recommendation left with no allowed Option is dropped entirely. A
None allowlist means "consider everything" (today's unrestricted behaviour).
"""
from domain.modelling.considered_measures import restrict_to_considered_measures
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation
def _option(measure_type: MeasureType) -> MeasureOption:
return MeasureOption(
measure_type=measure_type, description=str(measure_type), overlay=EpcSimulation()
)
def _heating_rec() -> Recommendation:
# Heating & Hot Water competes HHRSH against an ASHP bundle in one rec.
return Recommendation(
surface="Heating & Hot Water",
options=(
_option(MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS),
_option(MeasureType.AIR_SOURCE_HEAT_PUMP),
),
)
def _solar_rec() -> Recommendation:
return Recommendation(surface="Solar PV", options=(_option(MeasureType.SOLAR_PV),))
def _wall_rec() -> Recommendation:
return Recommendation(
surface="Wall", options=(_option(MeasureType.CAVITY_WALL_INSULATION),)
)
def test_none_allowlist_keeps_everything() -> None:
# Arrange
recommendations = [_heating_rec(), _solar_rec(), _wall_rec()]
# Act
kept = restrict_to_considered_measures(recommendations, None)
# Assert
assert kept == recommendations
def test_drops_recommendations_with_no_allowed_option() -> None:
# Arrange
considered = frozenset(
{MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, MeasureType.SOLAR_PV}
)
# Act
kept = restrict_to_considered_measures(
[_heating_rec(), _solar_rec(), _wall_rec()], considered
)
# Assert — the wall rec is gone; heating + solar survive.
surfaces = {rec.surface for rec in kept}
assert surfaces == {"Heating & Hot Water", "Solar PV"}
def test_filters_options_within_a_kept_recommendation() -> None:
# Arrange — HHRSH is allowed but the competing ASHP bundle is not.
considered = frozenset(
{MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, MeasureType.SOLAR_PV}
)
# Act
kept = restrict_to_considered_measures([_heating_rec()], considered)
# Assert — the heating rec keeps ONLY its HHRSH option, ASHP is dropped.
assert len(kept) == 1
kept_types = [option.measure_type for option in kept[0].options]
assert kept_types == [MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS]

View file

@ -55,7 +55,7 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None:
# bundle, whose overlay is the absolute HHR end-state. # bundle, whose overlay is the absolute HHR end-state.
assert recommendation is not None assert recommendation is not None
assert recommendation.surface == "Heating & Hot Water" assert recommendation.surface == "Heating & Hot Water"
options = {o.measure_type: o for o in recommendation.options} options = {o.measure_type.value: o for o in recommendation.options}
assert "high_heat_retention_storage_heaters" in options assert "high_heat_retention_storage_heaters" in options
assert options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay( assert options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay(
main_fuel_type=30, main_fuel_type=30,
@ -150,7 +150,7 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None:
# Assert — the ASHP bundle carries the absolute heat-pump end-state. # Assert — the ASHP bundle carries the absolute heat-pump end-state.
assert recommendation is not None assert recommendation is not None
options = {o.measure_type: o for o in recommendation.options} options = {o.measure_type.value: o for o in recommendation.options}
assert "air_source_heat_pump" in options assert "air_source_heat_pump" in options
assert options["air_source_heat_pump"].overlay.heating == HeatingOverlay( assert options["air_source_heat_pump"].overlay.heating == HeatingOverlay(
main_fuel_type=30, main_fuel_type=30,

View file

@ -0,0 +1,52 @@
"""Slice 1 — `MeasureType`, the canonical enum of the measures the Modelling
stage actually models.
A `StrEnum` so each member *is* its persisted/optimiser string value (e.g.
`MeasureType.SOLAR_PV == "solar_pv"`), letting it flow through the `recommendation`
varchar column and the optimiser's group-by-type key unchanged. Replaces the
per-generator string constants as the single source of truth, and is the
vocabulary the `considered_measures` allowlist speaks.
"""
from domain.modelling.measure_type import MeasureType
# The full set of measures the generators emit today (one per Generator option).
_EXPECTED_VALUES = {
"cavity_wall_insulation",
"external_wall_insulation",
"internal_wall_insulation",
"loft_insulation",
"sloping_ceiling_insulation",
"flat_roof_insulation",
"suspended_floor_insulation",
"solid_floor_insulation",
"double_glazing",
"secondary_glazing",
"low_energy_lighting",
"mechanical_ventilation",
"high_heat_retention_storage_heaters",
"air_source_heat_pump",
"solar_pv",
}
def test_members_cover_exactly_the_modelled_measures() -> None:
# Arrange / Act
values = {member.value for member in MeasureType}
# Assert
assert values == _EXPECTED_VALUES
def test_member_is_its_string_value() -> None:
# Arrange / Act / Assert — a StrEnum member compares and persists as its str.
assert MeasureType.SOLAR_PV == "solar_pv"
assert MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS == (
"high_heat_retention_storage_heaters"
)
assert isinstance(MeasureType.SOLAR_PV, str)
def test_round_trips_through_its_value() -> None:
# Arrange / Act / Assert — a raw catalogue/DB string maps back to the member.
assert MeasureType("cavity_wall_insulation") is MeasureType.CAVITY_WALL_INSULATION

View file

@ -22,6 +22,7 @@ from domain.modelling.optimisation.optimiser import (
optimise_min_cost, optimise_min_cost,
optimise_package, optimise_package,
) )
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import Score from domain.modelling.scoring.package_scorer import Score
from domain.modelling.recommendation import Cost, MeasureOption from domain.modelling.recommendation import Cost, MeasureOption
from domain.modelling.simulation import ( from domain.modelling.simulation import (
@ -37,7 +38,7 @@ from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption: def _scored(measure_type: str, *, gain: float, cost: float) -> ScoredOption:
return ScoredOption( return ScoredOption(
option=MeasureOption( option=MeasureOption(
measure_type=measure_type, measure_type=MeasureType(measure_type),
description=measure_type, description=measure_type,
overlay=EpcSimulation(), overlay=EpcSimulation(),
cost=Cost(total=cost, contingency_rate=0.0), cost=Cost(total=cost, contingency_rate=0.0),
@ -70,7 +71,7 @@ def _scored_overlay(
) -> ScoredOption: ) -> ScoredOption:
return ScoredOption( return ScoredOption(
option=MeasureOption( option=MeasureOption(
measure_type=measure_type, measure_type=MeasureType(measure_type),
description=measure_type, description=measure_type,
overlay=overlay, overlay=overlay,
cost=Cost(total=cost, contingency_rate=0.0), cost=Cost(total=cost, contingency_rate=0.0),
@ -524,10 +525,12 @@ class _VentStubScorer:
def _ventilation_dependency(*, cost: float) -> MeasureDependency: def _ventilation_dependency(*, cost: float) -> MeasureDependency:
"""A forced 'fabric requires ventilation' edge for the tests.""" """A forced 'fabric requires ventilation' edge for the tests."""
return MeasureDependency( return MeasureDependency(
triggers=frozenset({"cavity_wall_insulation", "external_wall_insulation"}), triggers=frozenset(
{MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_WALL_INSULATION}
),
required=ScoredOption( required=ScoredOption(
option=MeasureOption( option=MeasureOption(
measure_type="mechanical_ventilation", measure_type=MeasureType.MECHANICAL_VENTILATION,
description="mechanical_ventilation", description="mechanical_ventilation",
overlay=_VENT_OVERLAY, overlay=_VENT_OVERLAY,
cost=Cost(total=cost, contingency_rate=0.0), cost=Cost(total=cost, contingency_rate=0.0),
@ -549,10 +552,10 @@ def test_min_cost_warm_start_avoids_a_wall_whose_forced_ventilation_dooms_it() -
] ]
scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0) scorer = _VentStubScorer(base=60.0, wall=6.0, roof=8.0, vent=-5.0)
dependency = MeasureDependency( dependency = MeasureDependency(
triggers=frozenset({"cavity_wall_insulation"}), triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}),
required=ScoredOption( required=ScoredOption(
option=MeasureOption( option=MeasureOption(
measure_type="mechanical_ventilation", measure_type=MeasureType.MECHANICAL_VENTILATION,
description="mechanical_ventilation", description="mechanical_ventilation",
overlay=_VENT_OVERLAY, overlay=_VENT_OVERLAY,
cost=Cost(total=300.0, contingency_rate=0.0), cost=Cost(total=300.0, contingency_rate=0.0),

View file

@ -9,6 +9,7 @@ from __future__ import annotations
from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc import Epc
from domain.billing.bill import Bill, BillSection, BillSectionCost from domain.billing.bill import Bill, BillSection, BillSectionCost
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import Score from domain.modelling.scoring.package_scorer import Score
from domain.modelling.plan import Plan, PlanMeasure from domain.modelling.plan import Plan, PlanMeasure
from domain.modelling.recommendation import Cost from domain.modelling.recommendation import Cost
@ -28,7 +29,7 @@ def _bill(*, heating_kwh: float, total_gbp: float) -> Bill:
def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure: def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure:
return PlanMeasure( return PlanMeasure(
measure_type=measure_type, measure_type=MeasureType(measure_type),
description=measure_type.replace("_", " "), description=measure_type.replace("_", " "),
cost=Cost(total=total, contingency_rate=rate), cost=Cost(total=total, contingency_rate=rate),
impact=MeasureImpact( impact=MeasureImpact(

View file

@ -10,6 +10,7 @@ from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier, BuildingPartIdentifier,
EpcPropertyData, EpcPropertyData,
) )
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.scoring.package_scorer import PackageScorer, Score
from domain.modelling.recommendation import MeasureOption from domain.modelling.recommendation import MeasureOption
from domain.modelling.scoring.scoring import ( from domain.modelling.scoring.scoring import (
@ -59,7 +60,7 @@ class _CountingScorer(PackageScorer):
def _option(overlay: EpcSimulation) -> MeasureOption: def _option(overlay: EpcSimulation) -> MeasureOption:
return MeasureOption( return MeasureOption(
measure_type="cavity_wall_insulation", description="opt", overlay=overlay measure_type=MeasureType.CAVITY_WALL_INSULATION, description="opt", overlay=overlay
) )

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from domain.modelling.measure_type import MeasureType
from domain.modelling.plan import Plan, PlanMeasure from domain.modelling.plan import Plan, PlanMeasure
from domain.modelling.recommendation import Cost from domain.modelling.recommendation import Cost
from domain.modelling.scoring.package_scorer import Score from domain.modelling.scoring.package_scorer import Score
@ -18,7 +19,7 @@ def _plan() -> Plan:
) )
measures = ( measures = (
PlanMeasure( PlanMeasure(
measure_type="cavity_wall_insulation", measure_type=MeasureType.CAVITY_WALL_INSULATION,
description="Cavity wall insulation", description="Cavity wall insulation",
cost=Cost(total=500.0, contingency_rate=0.1), cost=Cost(total=500.0, contingency_rate=0.1),
impact=MeasureImpact( impact=MeasureImpact(
@ -30,7 +31,7 @@ def _plan() -> Plan:
energy_cost_savings=120.0, energy_cost_savings=120.0,
), ),
PlanMeasure( PlanMeasure(
measure_type="mechanical_ventilation", measure_type=MeasureType.MECHANICAL_VENTILATION,
description="Mechanical extract ventilation", description="Mechanical extract ventilation",
cost=Cost(total=900.0, contingency_rate=0.26), cost=Cost(total=900.0, contingency_rate=0.26),
impact=MeasureImpact( impact=MeasureImpact(

View file

@ -15,6 +15,7 @@ from typing import Any
from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType
from domain.modelling.product import Product from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation from domain.modelling.recommendation import Recommendation
from orchestration.modelling_orchestrator import ( from orchestration.modelling_orchestrator import (
@ -95,7 +96,7 @@ def test_candidate_recommendations_includes_solar_when_potential_present() -> No
# Act # Act
recommendations: list[Recommendation] = _candidate_recommendations( recommendations: list[Recommendation] = _candidate_recommendations(
epc, _StubProducts(), PlanningRestrictions(), potential epc, _StubProducts(), PlanningRestrictions(), potential, None
) )
# Assert — a "Solar PV" Recommendation is among the candidates. # Assert — a "Solar PV" Recommendation is among the candidates.
@ -108,8 +109,30 @@ def test_candidate_recommendations_excludes_solar_without_potential() -> None:
# Act # Act
recommendations = _candidate_recommendations( recommendations = _candidate_recommendations(
epc, _StubProducts(), PlanningRestrictions(), None epc, _StubProducts(), PlanningRestrictions(), None, None
) )
# Assert # Assert
assert "Solar PV" not in {r.surface for r in recommendations} assert "Solar PV" not in {r.surface for r in recommendations}
def test_considered_measures_restricts_candidates_to_the_allowlist() -> None:
# Arrange — a solar-eligible house, with its solar potential present, so the
# unrestricted run offers Solar PV alongside any fabric/heating candidates.
epc = _eligible_house()
potential = _solar_potential_for(
FakeSolarRepo(by_property={1: json.loads(_INSIGHTS_FIXTURE.read_text())}), 1
)
# Act — restrict the run to Solar PV only.
recommendations = _candidate_recommendations(
epc, _StubProducts(), PlanningRestrictions(), potential, frozenset({MeasureType.SOLAR_PV})
)
# Assert — every surviving Option is solar_pv; nothing else leaks through.
option_types = {
option.measure_type
for recommendation in recommendations
for option in recommendation.options
}
assert option_types == {MeasureType.SOLAR_PV}

View file

@ -14,6 +14,7 @@ from sqlalchemy import Engine
from sqlmodel import Session, col, select from sqlmodel import Session, col, select
from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc import Epc
from domain.modelling.measure_type import MeasureType
from domain.modelling.scoring.package_scorer import Score from domain.modelling.scoring.package_scorer import Score
from domain.modelling.plan import Plan, PlanMeasure from domain.modelling.plan import Plan, PlanMeasure
from domain.modelling.recommendation import Cost from domain.modelling.recommendation import Cost
@ -25,7 +26,7 @@ from repositories.plan.plan_postgres_repository import PlanPostgresRepository
def _plan() -> Plan: def _plan() -> Plan:
measures: tuple[PlanMeasure, ...] = ( measures: tuple[PlanMeasure, ...] = (
PlanMeasure( PlanMeasure(
measure_type="cavity_wall_insulation", measure_type=MeasureType.CAVITY_WALL_INSULATION,
description="Cavity wall insulation", description="Cavity wall insulation",
cost=Cost(total=1000.0, contingency_rate=0.10), cost=Cost(total=1000.0, contingency_rate=0.10),
impact=MeasureImpact( impact=MeasureImpact(
@ -112,7 +113,7 @@ def test_save_persists_null_per_measure_savings_when_unbilled(
) -> None: ) -> None:
# Arrange — a Plan Measure whose per-measure bills were never derived. # Arrange — a Plan Measure whose per-measure bills were never derived.
measure = PlanMeasure( measure = PlanMeasure(
measure_type="loft_insulation", measure_type=MeasureType.LOFT_INSULATION,
description="Loft insulation", description="Loft insulation",
cost=Cost(total=500.0, contingency_rate=0.20), cost=Cost(total=500.0, contingency_rate=0.20),
impact=MeasureImpact( impact=MeasureImpact(

View file

@ -42,6 +42,43 @@ def test_get_maps_active_material_to_product_with_contingency(
assert abs(product.contingency_rate - 0.10) <= 1e-9 assert abs(product.contingency_rate - 0.10) <= 1e-9
def test_get_picks_the_lowest_id_when_several_active_rows_share_a_type(
db_engine: Engine,
) -> None:
# Arrange — the live catalogue holds many active rows per type (e.g. 74
# solar_pv); the choice must be deterministic so a re-run prices the same.
with Session(db_engine) as session:
session.add_all(
[
MaterialRow(
id=7,
type="solar_pv",
total_cost=99.0,
cost_unit="gbp_per_unit",
is_active=True,
description="Solar PV (higher id)",
),
MaterialRow(
id=3,
type="solar_pv",
total_cost=42.0,
cost_unit="gbp_per_unit",
is_active=True,
description="Solar PV (lowest id)",
),
]
)
session.commit()
# Act
with Session(db_engine) as session:
product: Product = ProductPostgresRepository(session).get("solar_pv")
# Assert — the lowest-id active row wins, deterministically.
assert product.id == 3
assert abs(product.unit_cost_per_m2 - 42.0) <= 1e-9
def test_get_raises_when_only_an_inactive_product_exists(db_engine: Engine) -> None: def test_get_raises_when_only_an_inactive_product_exists(db_engine: Engine) -> None:
# Arrange # Arrange
with Session(db_engine) as session: with Session(db_engine) as session: