diff --git a/domain/modelling/generators/floor_recommendation.py b/domain/modelling/generators/floor_recommendation.py index f4bd3296..9e3815ba 100644 --- a/domain/modelling/generators/floor_recommendation.py +++ b/domain/modelling/generators/floor_recommendation.py @@ -40,7 +40,7 @@ def _is_uninsulated(thickness: Optional[Union[str, int]]) -> bool: 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 None when the construction is not a treatable suspended/solid floor.""" text = (construction_type or "").lower() diff --git a/domain/modelling/generators/glazing_recommendation.py b/domain/modelling/generators/glazing_recommendation.py index 5f0bb006..897b011c 100644 --- a/domain/modelling/generators/glazing_recommendation.py +++ b/domain/modelling/generators/glazing_recommendation.py @@ -43,7 +43,7 @@ class _GlazingTarget: `solar_transmittance` — SAP10.2 Table U2 code, then heat-loss U and solar g). """ - measure_type: str + measure_type: MeasureType description: str glazing_type: int u_value: float diff --git a/domain/modelling/generators/roof_recommendation.py b/domain/modelling/generators/roof_recommendation.py index a19921a5..2595e3f9 100644 --- a/domain/modelling/generators/roof_recommendation.py +++ b/domain/modelling/generators/roof_recommendation.py @@ -110,7 +110,7 @@ def _roof_recommendation( epc: EpcPropertyData, products: ProductRepository, *, - measure_type: str, + measure_type: MeasureType, description: str, thickness_mm: int, ) -> Recommendation: diff --git a/domain/modelling/generators/solid_wall_recommendation.py b/domain/modelling/generators/solid_wall_recommendation.py index 49addfa5..dff55b66 100644 --- a/domain/modelling/generators/solid_wall_recommendation.py +++ b/domain/modelling/generators/solid_wall_recommendation.py @@ -57,7 +57,7 @@ _SOLID_WALL_INSULATION_MM: Final[int] = 100 # Which solid-wall Options each construction can take (ADR-0019). Solid brick # and system-built take both; timber-frame takes IWI only (EWI not # 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_SYSTEM_BUILT: (_EXTERNAL_MEASURE_TYPE, _INTERNAL_MEASURE_TYPE), _WALL_TIMBER_FRAME: (_INTERNAL_MEASURE_TYPE,), @@ -75,7 +75,7 @@ _DESCRIPTION: Final[dict[str, str]] = { def _solid_wall_option( - epc: EpcPropertyData, products: ProductRepository, measure_type: str + epc: EpcPropertyData, products: ProductRepository, measure_type: MeasureType ) -> MeasureOption: """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.""" diff --git a/domain/modelling/optimisation/measure_dependency.py b/domain/modelling/optimisation/measure_dependency.py index 9df7d468..bd7e9d1f 100644 --- a/domain/modelling/optimisation/measure_dependency.py +++ b/domain/modelling/optimisation/measure_dependency.py @@ -24,17 +24,18 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.generators.ventilation_recommendation import ( recommend_ventilation, ) +from domain.modelling.measure_type import MeasureType from domain.modelling.optimisation.optimiser import MeasureDependency, ScoredOption from domain.modelling.recommendation import MeasureOption, Recommendation from repositories.product.product_repository import ProductRepository # The measure types that force a ventilation dependency (cf. legacy # `assumptions.measures_needing_ventilation`). -MEASURES_NEEDING_VENTILATION: frozenset[str] = frozenset( +MEASURES_NEEDING_VENTILATION: frozenset[MeasureType] = frozenset( { - "cavity_wall_insulation", - "internal_wall_insulation", - "external_wall_insulation", + MeasureType.CAVITY_WALL_INSULATION, + MeasureType.INTERNAL_WALL_INSULATION, + MeasureType.EXTERNAL_WALL_INSULATION, } ) diff --git a/domain/modelling/optimisation/optimiser.py b/domain/modelling/optimisation/optimiser.py index de5e0225..0e577657 100644 --- a/domain/modelling/optimisation/optimiser.py +++ b/domain/modelling/optimisation/optimiser.py @@ -24,6 +24,7 @@ from dataclasses import dataclass from typing import Optional, Protocol, Sequence 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.recommendation import MeasureOption from domain.modelling.simulation import EpcSimulation @@ -48,7 +49,7 @@ class MeasureDependency: figure, the repair decision, and the persisted package. Held as data so extending the triggers is a data edit, not control flow.""" - triggers: frozenset[str] + triggers: frozenset[MeasureType] required: ScoredOption @@ -300,11 +301,11 @@ def _inject( """``chosen`` plus every forced dependency whose triggers intersect the chosen measure-types, de-duplicated by required measure-type (a dependency 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) - present: set[str] = set(chosen_types) + present: set[MeasureType] = set(chosen_types) 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: injected.append(dependency.required) present.add(required_type) diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index 8483359f..9e8349e9 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -15,6 +15,7 @@ from typing import Optional from datatypes.epc.domain.epc import Epc from domain.billing.bill import Bill +from domain.modelling.measure_type import MeasureType from domain.modelling.scoring.package_scorer import Score from domain.modelling.recommendation import Cost 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 from `impact.energy_savings_kwh_per_yr`, which is *primary* energy.""" - measure_type: str + measure_type: MeasureType description: str cost: Cost impact: MeasureImpact diff --git a/domain/modelling/recommendation.py b/domain/modelling/recommendation.py index 96331c44..5f9b10a5 100644 --- a/domain/modelling/recommendation.py +++ b/domain/modelling/recommendation.py @@ -11,6 +11,7 @@ CONTEXT.md. from dataclasses import dataclass from typing import Optional +from domain.modelling.measure_type import MeasureType from domain.modelling.simulation import EpcSimulation @@ -28,7 +29,7 @@ class Cost: class MeasureOption: """One mutually-exclusive way to treat a Recommendation's surface.""" - measure_type: str + measure_type: MeasureType description: str overlay: EpcSimulation cost: Optional[Cost] = None diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 50344d14..9a0cf285 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -55,7 +55,7 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None: # bundle, whose overlay is the absolute HHR end-state. assert recommendation is not None 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 options["high_heat_retention_storage_heaters"].overlay.heating == HeatingOverlay( 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 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 options["air_source_heat_pump"].overlay.heating == HeatingOverlay( main_fuel_type=30, diff --git a/tests/domain/modelling/test_optimiser.py b/tests/domain/modelling/test_optimiser.py index 333909d0..80f39d7e 100644 --- a/tests/domain/modelling/test_optimiser.py +++ b/tests/domain/modelling/test_optimiser.py @@ -22,6 +22,7 @@ from domain.modelling.optimisation.optimiser import ( optimise_min_cost, optimise_package, ) +from domain.modelling.measure_type import MeasureType from domain.modelling.scoring.package_scorer import Score from domain.modelling.recommendation import Cost, MeasureOption 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: return ScoredOption( option=MeasureOption( - measure_type=measure_type, + measure_type=MeasureType(measure_type), description=measure_type, overlay=EpcSimulation(), cost=Cost(total=cost, contingency_rate=0.0), @@ -70,7 +71,7 @@ def _scored_overlay( ) -> ScoredOption: return ScoredOption( option=MeasureOption( - measure_type=measure_type, + measure_type=MeasureType(measure_type), description=measure_type, overlay=overlay, cost=Cost(total=cost, contingency_rate=0.0), @@ -524,10 +525,12 @@ class _VentStubScorer: def _ventilation_dependency(*, cost: float) -> MeasureDependency: """A forced 'fabric requires ventilation' edge for the tests.""" return MeasureDependency( - triggers=frozenset({"cavity_wall_insulation", "external_wall_insulation"}), + triggers=frozenset( + {MeasureType.CAVITY_WALL_INSULATION, MeasureType.EXTERNAL_WALL_INSULATION} + ), required=ScoredOption( option=MeasureOption( - measure_type="mechanical_ventilation", + measure_type=MeasureType.MECHANICAL_VENTILATION, description="mechanical_ventilation", overlay=_VENT_OVERLAY, 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) dependency = MeasureDependency( - triggers=frozenset({"cavity_wall_insulation"}), + triggers=frozenset({MeasureType.CAVITY_WALL_INSULATION}), required=ScoredOption( option=MeasureOption( - measure_type="mechanical_ventilation", + measure_type=MeasureType.MECHANICAL_VENTILATION, description="mechanical_ventilation", overlay=_VENT_OVERLAY, cost=Cost(total=300.0, contingency_rate=0.0), diff --git a/tests/domain/modelling/test_plan.py b/tests/domain/modelling/test_plan.py index 678d281f..1b191202 100644 --- a/tests/domain/modelling/test_plan.py +++ b/tests/domain/modelling/test_plan.py @@ -9,6 +9,7 @@ from __future__ import annotations from datatypes.epc.domain.epc import Epc 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.plan import Plan, PlanMeasure 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: return PlanMeasure( - measure_type=measure_type, + measure_type=MeasureType(measure_type), description=measure_type.replace("_", " "), cost=Cost(total=total, contingency_rate=rate), impact=MeasureImpact( diff --git a/tests/domain/modelling/test_scoring.py b/tests/domain/modelling/test_scoring.py index ab55706a..61cfb454 100644 --- a/tests/domain/modelling/test_scoring.py +++ b/tests/domain/modelling/test_scoring.py @@ -10,6 +10,7 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) +from domain.modelling.measure_type import MeasureType from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.recommendation import MeasureOption from domain.modelling.scoring.scoring import ( @@ -59,7 +60,7 @@ class _CountingScorer(PackageScorer): def _option(overlay: EpcSimulation) -> MeasureOption: return MeasureOption( - measure_type="cavity_wall_insulation", description="opt", overlay=overlay + measure_type=MeasureType.CAVITY_WALL_INSULATION, description="opt", overlay=overlay ) diff --git a/tests/harness/test_plan_table.py b/tests/harness/test_plan_table.py index fa7ac6e6..51031151 100644 --- a/tests/harness/test_plan_table.py +++ b/tests/harness/test_plan_table.py @@ -2,6 +2,7 @@ from __future__ import annotations +from domain.modelling.measure_type import MeasureType from domain.modelling.plan import Plan, PlanMeasure from domain.modelling.recommendation import Cost from domain.modelling.scoring.package_scorer import Score @@ -18,7 +19,7 @@ def _plan() -> Plan: ) measures = ( PlanMeasure( - measure_type="cavity_wall_insulation", + measure_type=MeasureType.CAVITY_WALL_INSULATION, description="Cavity wall insulation", cost=Cost(total=500.0, contingency_rate=0.1), impact=MeasureImpact( @@ -30,7 +31,7 @@ def _plan() -> Plan: energy_cost_savings=120.0, ), PlanMeasure( - measure_type="mechanical_ventilation", + measure_type=MeasureType.MECHANICAL_VENTILATION, description="Mechanical extract ventilation", cost=Cost(total=900.0, contingency_rate=0.26), impact=MeasureImpact( diff --git a/tests/repositories/plan/test_plan_postgres_repository.py b/tests/repositories/plan/test_plan_postgres_repository.py index 8ee0f0f8..3e428bd8 100644 --- a/tests/repositories/plan/test_plan_postgres_repository.py +++ b/tests/repositories/plan/test_plan_postgres_repository.py @@ -14,6 +14,7 @@ from sqlalchemy import Engine from sqlmodel import Session, col, select 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.plan import Plan, PlanMeasure from domain.modelling.recommendation import Cost @@ -25,7 +26,7 @@ from repositories.plan.plan_postgres_repository import PlanPostgresRepository def _plan() -> Plan: measures: tuple[PlanMeasure, ...] = ( PlanMeasure( - measure_type="cavity_wall_insulation", + measure_type=MeasureType.CAVITY_WALL_INSULATION, description="Cavity wall insulation", cost=Cost(total=1000.0, contingency_rate=0.10), impact=MeasureImpact( @@ -112,7 +113,7 @@ def test_save_persists_null_per_measure_savings_when_unbilled( ) -> None: # Arrange — a Plan Measure whose per-measure bills were never derived. measure = PlanMeasure( - measure_type="loft_insulation", + measure_type=MeasureType.LOFT_INSULATION, description="Loft insulation", cost=Cost(total=500.0, contingency_rate=0.20), impact=MeasureImpact(