mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
refactor(modelling): type measure_type fields as MeasureType
Tighten the recommendation/plan vocabulary off generic str: MeasureOption.measure_type and PlanMeasure.measure_type are now MeasureType (also _GlazingTarget.measure_type, MeasureDependency.triggers -> frozenset[MeasureType], and the optimiser's chosen/required-type locals). Because MeasureType is a StrEnum the change is transparent to persistence (the `recommendation` varchar column), the optimiser group-by key, and every `== "solar_pv"`-style comparison — so pyright now enforces the enum at every construction site with no runtime behaviour change. The catalogue boundary stays str: ProductRepository.get(measure_type: str) and Product.measure_type are unchanged (they map arbitrary DB/JSON rows), so the fake product repos in tests need no edit. Test construction helpers coerce their str arg via MeasureType(...); direct constructions use members. Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3 xfail; pyright clean on production + tests (pre-existing moto + property- override-rowcount baselines untouched). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d58ac60d29
commit
9ef97be958
14 changed files with 40 additions and 29 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ def _roof_recommendation(
|
|||
epc: EpcPropertyData,
|
||||
products: ProductRepository,
|
||||
*,
|
||||
measure_type: str,
|
||||
measure_type: MeasureType,
|
||||
description: str,
|
||||
thickness_mm: int,
|
||||
) -> Recommendation:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue