Model/domain/modelling/recommendation.py
Khalim Conn-Kowlessar 9ef97be958 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>
2026-06-08 20:13:31 +00:00

49 lines
1.6 KiB
Python

"""Recommendation and Measure Option — the Modelling stage's proposal types.
A Recommendation is a labelled group of mutually-exclusive Measure Options for
one target surface; the Optimiser selects at most one. The target itself is
encoded entirely in each Option's Simulation Overlay (which addresses building
parts, windows, or systems), so this type stays stable as new surfaces land.
Impact is never stored here — it is cascade-conditional (ADR-0016). See
CONTEXT.md.
"""
from dataclasses import dataclass
from typing import Optional
from domain.modelling.measure_type import MeasureType
from domain.modelling.simulation import EpcSimulation
@dataclass(frozen=True)
class Cost:
"""A Measure Option's cost: a single fully-loaded total (labour + VAT +
preliminaries + margin rolled in) plus a separately-carried per-Measure-Type
contingency rate."""
total: float
contingency_rate: float
@dataclass(frozen=True)
class MeasureOption:
"""One mutually-exclusive way to treat a Recommendation's surface."""
measure_type: MeasureType
description: str
overlay: EpcSimulation
cost: Optional[Cost] = None
# The catalogue id of the Product this Option installs (Product.id), carried
# through to the persisted Plan Measure's ``material_id``. None when priced
# from a catalogue with no ids.
material_id: Optional[int] = None
@dataclass(frozen=True)
class Recommendation:
"""A target surface and the mutually-exclusive Measure Options that treat
it. `surface` is a human label for display/grouping; the actual target is
in each Option's overlay."""
surface: str
options: tuple[MeasureOption, ...]