mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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>
This commit is contained in:
parent
31ced27162
commit
3580d059ec
9 changed files with 243 additions and 9 deletions
|
|
@ -20,6 +20,20 @@ from domain.modelling.measure_type import MeasureType
|
|||
from domain.modelling.recommendation import Recommendation
|
||||
|
||||
|
||||
def combine_considered_measures(
|
||||
a: Optional[frozenset[MeasureType]],
|
||||
b: Optional[frozenset[MeasureType]],
|
||||
) -> Optional[frozenset[MeasureType]]:
|
||||
"""Intersect two allowlists, treating ``None`` as "all measures". Used to
|
||||
layer an explicit override over the allowlist a Scenario's exclusions imply:
|
||||
None ∧ x = x, and both present narrows to their intersection."""
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return a & b
|
||||
|
||||
|
||||
def restrict_to_considered_measures(
|
||||
recommendations: Iterable[Recommendation],
|
||||
considered_measures: Optional[frozenset[MeasureType]],
|
||||
|
|
|
|||
|
|
@ -12,16 +12,33 @@ columns are not modelled. Carries no phases — multi-phase is deferred
|
|||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
|
||||
_NO_EXCLUSIONS: frozenset[MeasureType] = frozenset()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scenario:
|
||||
"""A retrofit brief: its goal, optional budget, and whether it is the
|
||||
Property's default Scenario. `goal` / `goal_value` are the lodged target
|
||||
(e.g. "INCREASING_EPC" → band "C"); carried for the Optimiser, not yet
|
||||
enforced."""
|
||||
enforced.
|
||||
|
||||
`exclusions` are the measure types the brief bars from the run (the only
|
||||
measure-scoping the live ``scenario`` table persists — there is no
|
||||
inclusions column). Empty means nothing is barred."""
|
||||
|
||||
id: int
|
||||
goal: str
|
||||
goal_value: str
|
||||
budget: Optional[float]
|
||||
is_default: bool
|
||||
exclusions: frozenset[MeasureType] = _NO_EXCLUSIONS
|
||||
|
||||
def considered_measures(self) -> Optional[frozenset[MeasureType]]:
|
||||
"""The measure-type allowlist the Scenario's exclusions imply: every
|
||||
modelled measure minus the excluded ones, or None (consider every
|
||||
measure) when nothing is excluded."""
|
||||
if not self.exclusions:
|
||||
return None
|
||||
return frozenset(MeasureType) - self.exclusions
|
||||
|
|
|
|||
|
|
@ -8,10 +8,35 @@ from sqlalchemy import Enum as SAEnum
|
|||
from sqlalchemy.sql import func
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.portfolio_goal import PortfolioGoal
|
||||
from domain.modelling.scenario import Scenario
|
||||
|
||||
|
||||
def _parse_exclusions(raw: Optional[str]) -> frozenset[MeasureType]:
|
||||
"""Parse the live ``scenario.exclusions`` column — a Postgres text-array
|
||||
literal like ``{solar_pv,internal_wall_insulation}`` — into the excluded
|
||||
MeasureTypes. Each token must be an exact MeasureType value (no high-level
|
||||
category expansion); an unknown token is a data error and raises, matching
|
||||
the repo's strict-enum convention."""
|
||||
if not raw:
|
||||
return frozenset()
|
||||
inner = raw.strip()
|
||||
if inner.startswith("{") and inner.endswith("}"):
|
||||
inner = inner[1:-1]
|
||||
tokens = [token.strip().strip('"') for token in inner.split(",") if token.strip()]
|
||||
excluded: set[MeasureType] = set()
|
||||
for token in tokens:
|
||||
try:
|
||||
excluded.add(MeasureType(token))
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"scenario excludes unknown measure type {token!r}; the "
|
||||
f"exclusions column must hold exact MeasureType values"
|
||||
) from error
|
||||
return frozenset(excluded)
|
||||
|
||||
|
||||
class ScenarioModel(SQLModel, table=True):
|
||||
"""The single SQLModel definition of the live ``scenario`` table (ADR-0017
|
||||
amendment). Full legacy column parity; ``goal`` is the ``PortfolioGoal``
|
||||
|
|
@ -89,4 +114,5 @@ class ScenarioModel(SQLModel, table=True):
|
|||
goal_value=self.goal_value,
|
||||
budget=self.budget,
|
||||
is_default=self.is_default,
|
||||
exclusions=_parse_exclusions(self.exclusions),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ from datatypes.epc.domain.epc import Epc
|
|||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.billing.bill import Bill, EnergyBreakdown
|
||||
from domain.billing.bill_derivation import BillDerivation
|
||||
from domain.modelling.considered_measures import restrict_to_considered_measures
|
||||
from domain.modelling.considered_measures import (
|
||||
combine_considered_measures,
|
||||
restrict_to_considered_measures,
|
||||
)
|
||||
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
|
||||
|
|
@ -159,18 +162,23 @@ class ModellingOrchestrator:
|
|||
) -> Plan:
|
||||
"""Generate → score → optimise → re-score/repair → attribute → bill →
|
||||
assemble the Plan for one Property + Scenario."""
|
||||
# The Scenario's own exclusions scope the run; an explicit
|
||||
# ``considered_measures`` (e.g. from a harness) narrows it further.
|
||||
considered: Optional[frozenset[MeasureType]] = combine_considered_measures(
|
||||
scenario.considered_measures(), considered_measures
|
||||
)
|
||||
groups: list[list[ScoredOption]] = _scored_candidate_groups(
|
||||
scorer,
|
||||
effective_epc,
|
||||
products,
|
||||
planning_restrictions,
|
||||
solar_potential,
|
||||
considered_measures,
|
||||
considered,
|
||||
)
|
||||
# Forced Measure Dependencies (ventilation) are excluded from the pool
|
||||
# but injected into the package before the re-score (ADR-0016).
|
||||
dependencies: list[MeasureDependency] = _measure_dependencies(
|
||||
effective_epc, products, considered_measures
|
||||
effective_epc, products, considered
|
||||
)
|
||||
package: OptimisedPackage = optimise_package(
|
||||
groups=groups,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ 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 domain.geospatial.planning_restrictions import PlanningRestrictions # noqa: E402
|
||||
from domain.geospatial.spatial_reference import SpatialReference # noqa: E402
|
||||
from domain.modelling.considered_measures import ( # noqa: E402
|
||||
combine_considered_measures,
|
||||
)
|
||||
from domain.modelling.measure_type import MeasureType # noqa: E402
|
||||
from domain.modelling.plan import Plan, PlanMeasure # noqa: E402
|
||||
from domain.modelling.recommendation import Recommendation # noqa: E402
|
||||
|
|
@ -339,13 +342,15 @@ def main() -> None:
|
|||
parser.add_argument(
|
||||
"--measures",
|
||||
default=None,
|
||||
help="comma-separated measure types to consider (default: all)",
|
||||
help="optional override: comma-separated measure types to consider. The "
|
||||
"Scenario's exclusions already drive this; the flag narrows it further.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-measures",
|
||||
default=None,
|
||||
help="comma-separated measure types to exclude (default: none) — e.g. "
|
||||
"secondary_heating_removal, which the live catalogue does not yet stock",
|
||||
help="optional override: comma-separated measure types to exclude on top "
|
||||
"of the Scenario's own exclusions (e.g. secondary_heating_removal, which "
|
||||
"the live catalogue does not yet stock)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--portfolio-id",
|
||||
|
|
@ -377,7 +382,7 @@ def main() -> None:
|
|||
geospatial = GeospatialS3Repository(_s3_parquet_reader(os.environ["DATA_BUCKET"]))
|
||||
solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"])
|
||||
engine = _engine()
|
||||
considered = _resolve_considered(
|
||||
cli_considered = _resolve_considered(
|
||||
_parse_measures(args.measures), _parse_measures(args.exclude_measures)
|
||||
)
|
||||
uprns = _uprns_for(engine, args.property_ids)
|
||||
|
|
@ -390,6 +395,12 @@ def main() -> None:
|
|||
if args.scenario_id is not None
|
||||
else None
|
||||
)
|
||||
# The Scenario's own exclusions drive which measures the run considers; the
|
||||
# --measures/--exclude-measures flags are an optional override layered on top.
|
||||
considered = combine_considered_measures(
|
||||
scenario.considered_measures() if scenario is not None else None,
|
||||
cli_considered,
|
||||
)
|
||||
|
||||
target = (
|
||||
f"scenario {scenario.id} (band {scenario.goal_value})"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ 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.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
|
||||
|
|
@ -68,6 +71,18 @@ def test_drops_recommendations_with_no_allowed_option() -> None:
|
|||
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(
|
||||
|
|
|
|||
43
tests/domain/modelling/test_scenario.py
Normal file
43
tests/domain/modelling/test_scenario.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""The Scenario's measure scoping: its exclusions imply the allowlist the run
|
||||
considers (the live `scenario` table persists exclusions only — no inclusions)."""
|
||||
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.scenario import Scenario
|
||||
|
||||
|
||||
def _scenario(exclusions: frozenset[MeasureType]) -> Scenario:
|
||||
return Scenario(
|
||||
id=1,
|
||||
goal="Increasing EPC",
|
||||
goal_value="C",
|
||||
budget=None,
|
||||
is_default=True,
|
||||
exclusions=exclusions,
|
||||
)
|
||||
|
||||
|
||||
def test_no_exclusions_considers_every_measure() -> None:
|
||||
# Arrange
|
||||
scenario = _scenario(frozenset())
|
||||
|
||||
# Act
|
||||
considered = scenario.considered_measures()
|
||||
|
||||
# Assert — None means "consider all" (the unrestricted default).
|
||||
assert considered is None
|
||||
|
||||
|
||||
def test_exclusions_imply_the_complement_allowlist() -> None:
|
||||
# Arrange — exclude solar PV and ASHP.
|
||||
scenario = _scenario(
|
||||
frozenset({MeasureType.SOLAR_PV, MeasureType.AIR_SOURCE_HEAT_PUMP})
|
||||
)
|
||||
|
||||
# Act
|
||||
considered = scenario.considered_measures()
|
||||
|
||||
# Assert — every modelled measure survives except the two excluded ones.
|
||||
assert considered is not None
|
||||
assert MeasureType.SOLAR_PV not in considered
|
||||
assert MeasureType.AIR_SOURCE_HEAT_PUMP not in considered
|
||||
assert considered == frozenset(MeasureType) - scenario.exclusions
|
||||
|
|
@ -10,6 +10,8 @@ from datatypes.epc.domain.epc import Epc
|
|||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||
from domain.modelling.contingencies import contingency_rate
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.scenario import Scenario
|
||||
from domain.modelling.generators.heating_recommendation import recommend_heating
|
||||
from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall
|
||||
from harness.console import (
|
||||
|
|
@ -254,6 +256,29 @@ def test_candidate_recommendations_surface_unselected_options_with_cost() -> Non
|
|||
)
|
||||
|
||||
|
||||
def test_run_modelling_honours_a_scenarios_exclusions() -> None:
|
||||
# Arrange — an electric dwelling whose optimised Plan normally leads with the
|
||||
# ASHP bundle; a Scenario that excludes ASHP must keep it out of the Plan.
|
||||
epc: EpcPropertyData = _electric_storage_lit_epc()
|
||||
scenario = Scenario(
|
||||
id=1,
|
||||
goal="Increasing EPC",
|
||||
goal_value="C",
|
||||
budget=None,
|
||||
is_default=True,
|
||||
exclusions=frozenset({MeasureType.AIR_SOURCE_HEAT_PUMP}),
|
||||
)
|
||||
|
||||
# Act — the orchestrator derives the allowlist from the Scenario's exclusions.
|
||||
plan = run_modelling(epc, scenario=scenario, print_table=False)
|
||||
|
||||
# Assert — ASHP is excluded; the Plan still improves the dwelling via other
|
||||
# measures (e.g. the HHR storage bundle).
|
||||
measure_types = {measure.measure_type for measure in plan.measures}
|
||||
assert MeasureType.AIR_SOURCE_HEAT_PUMP not in measure_types
|
||||
assert measure_types # a non-empty Plan still came back
|
||||
|
||||
|
||||
def test_sample_catalogue_prices_every_generator_measure_type() -> None:
|
||||
# Arrange — the default offline catalogue.
|
||||
products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import pytest
|
|||
from sqlalchemy import Engine
|
||||
from sqlmodel import Session
|
||||
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.portfolio_goal import PortfolioGoal
|
||||
from domain.modelling.scenario import Scenario
|
||||
from infrastructure.postgres.modelling import ScenarioModel
|
||||
|
|
@ -67,6 +68,80 @@ def test_get_many_maps_live_scenario_rows_to_domain_in_input_order(
|
|||
)
|
||||
|
||||
|
||||
def test_get_many_parses_the_exclusions_array_into_measure_types(
|
||||
db_engine: Engine,
|
||||
) -> None:
|
||||
# Arrange — the live `exclusions` column is a Postgres text-array literal of
|
||||
# exact MeasureType values.
|
||||
with Session(db_engine) as session:
|
||||
session.add(
|
||||
ScenarioModel(
|
||||
id=7,
|
||||
goal=PortfolioGoal.INCREASING_EPC,
|
||||
goal_value="C",
|
||||
is_default=True,
|
||||
exclusions="{solar_pv,internal_wall_insulation}",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Act
|
||||
with Session(db_engine) as session:
|
||||
scenario: Scenario = ScenarioPostgresRepository(session).get_many([7])[0]
|
||||
|
||||
# Assert
|
||||
assert scenario.exclusions == frozenset(
|
||||
{MeasureType.SOLAR_PV, MeasureType.INTERNAL_WALL_INSULATION}
|
||||
)
|
||||
|
||||
|
||||
def test_get_many_treats_a_null_exclusions_column_as_no_exclusions(
|
||||
db_engine: Engine,
|
||||
) -> None:
|
||||
# Arrange
|
||||
with Session(db_engine) as session:
|
||||
session.add(
|
||||
ScenarioModel(
|
||||
id=7,
|
||||
goal=PortfolioGoal.INCREASING_EPC,
|
||||
goal_value="C",
|
||||
is_default=True,
|
||||
exclusions=None,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Act
|
||||
with Session(db_engine) as session:
|
||||
scenario: Scenario = ScenarioPostgresRepository(session).get_many([7])[0]
|
||||
|
||||
# Assert
|
||||
assert scenario.exclusions == frozenset()
|
||||
|
||||
|
||||
def test_get_many_raises_on_an_exclusion_that_is_not_a_measure_type(
|
||||
db_engine: Engine,
|
||||
) -> None:
|
||||
# Arrange — a legacy high-level category (`heating`) is not an exact
|
||||
# MeasureType value; exact-only resolution must reject it loudly.
|
||||
with Session(db_engine) as session:
|
||||
session.add(
|
||||
ScenarioModel(
|
||||
id=7,
|
||||
goal=PortfolioGoal.INCREASING_EPC,
|
||||
goal_value="C",
|
||||
is_default=True,
|
||||
exclusions="{heating}",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Act / Assert
|
||||
with Session(db_engine) as session:
|
||||
with pytest.raises(ValueError):
|
||||
ScenarioPostgresRepository(session).get_many([7])
|
||||
|
||||
|
||||
def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> None:
|
||||
# Arrange
|
||||
with Session(db_engine) as session:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue