Model/tests/domain/modelling/test_considered_measures.py
Khalim Conn-Kowlessar 3580d059ec feat(modelling): drive measure scoping from the Scenario's exclusions
The measures a run considers should come from the Scenario, not a CLI flag.
The live scenario table persists exclusions only (no inclusions column), as a
Postgres text-array of exact MeasureType values.

- Scenario gains `exclusions: frozenset[MeasureType]` + `considered_measures()`
  (all measures minus the excluded ones, or None when none are excluded).
- ScenarioModel.to_domain parses the `{a,b,c}` exclusions array into
  MeasureTypes, raising on a token that is not an exact MeasureType value
  (no high-level category expansion), per the strict-enum convention.
- ModellingOrchestrator._plan_for derives the allowlist from the Scenario's
  exclusions, combined (intersection) with any explicit considered_measures
  override via the new `combine_considered_measures`.
- run_modelling_e2e sources the allowlist from the Scenario; --measures /
  --exclude-measures become optional overlays (e.g. the technical
  secondary_heating_removal exclusion the catalogue cannot yet stock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:26:25 +00:00

98 lines
3.4 KiB
Python

"""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 (
combine_considered_measures,
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_combine_treats_none_as_all_and_intersects_two_allowlists() -> None:
# Arrange
a = frozenset({MeasureType.SOLAR_PV, MeasureType.LOFT_INSULATION})
b = frozenset({MeasureType.SOLAR_PV, MeasureType.CAVITY_WALL_INSULATION})
# Act / Assert — None means "all", so it never narrows; two sets intersect.
assert combine_considered_measures(None, b) == b
assert combine_considered_measures(a, None) == a
assert combine_considered_measures(None, None) is None
assert combine_considered_measures(a, b) == frozenset({MeasureType.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]