diff --git a/domain/modelling/considered_measures.py b/domain/modelling/considered_measures.py index 530ca053..81fe20cb 100644 --- a/domain/modelling/considered_measures.py +++ b/domain/modelling/considered_measures.py @@ -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]], diff --git a/domain/modelling/scenario.py b/domain/modelling/scenario.py index 07f95ecb..6792e268 100644 --- a/domain/modelling/scenario.py +++ b/domain/modelling/scenario.py @@ -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 diff --git a/infrastructure/postgres/modelling/scenario_table.py b/infrastructure/postgres/modelling/scenario_table.py index 47b40b73..5f7197ba 100644 --- a/infrastructure/postgres/modelling/scenario_table.py +++ b/infrastructure/postgres/modelling/scenario_table.py @@ -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), ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index df2c83ae..867cb8b2 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -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, diff --git a/scripts/run_modelling_e2e.py b/scripts/run_modelling_e2e.py index fca5bc11..00628860 100644 --- a/scripts/run_modelling_e2e.py +++ b/scripts/run_modelling_e2e.py @@ -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})" diff --git a/tests/domain/modelling/test_considered_measures.py b/tests/domain/modelling/test_considered_measures.py index b5b5b4e7..e2ef9ad9 100644 --- a/tests/domain/modelling/test_considered_measures.py +++ b/tests/domain/modelling/test_considered_measures.py @@ -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( diff --git a/tests/domain/modelling/test_scenario.py b/tests/domain/modelling/test_scenario.py new file mode 100644 index 00000000..f159aff2 --- /dev/null +++ b/tests/domain/modelling/test_scenario.py @@ -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 diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 4896163f..3a8a80a6 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -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) diff --git a/tests/repositories/scenario/test_scenario_postgres_repository.py b/tests/repositories/scenario/test_scenario_postgres_repository.py index 8e0df21c..8b5804c7 100644 --- a/tests/repositories/scenario/test_scenario_postgres_repository.py +++ b/tests/repositories/scenario/test_scenario_postgres_repository.py @@ -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: