From caa0847b70af6a853138f757acaabce16b99126f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 11:44:31 +0000 Subject: [PATCH] =?UTF-8?q?Scenario=20carries=20its=20Fabric=20First=20fla?= =?UTF-8?q?g=20from=20the=20scenario=20table=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/modelling/scenario.py | 8 ++++++- .../postgres/modelling/scenario_table.py | 3 +++ .../test_scenario_postgres_repository.py | 22 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/domain/modelling/scenario.py b/domain/modelling/scenario.py index 6792e268d..85162d6d0 100644 --- a/domain/modelling/scenario.py +++ b/domain/modelling/scenario.py @@ -26,7 +26,12 @@ class Scenario: `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.""" + inclusions column). Empty means nothing is barred. + + `fabric_first` constrains the Optimiser to treat the building envelope + first: fabric measures are optimised with the full budget, and heating / + renewables are only considered on top of the committed fabric when the + fabric alone does not meet the brief's target.""" id: int goal: str @@ -34,6 +39,7 @@ class Scenario: budget: Optional[float] is_default: bool exclusions: frozenset[MeasureType] = _NO_EXCLUSIONS + fabric_first: bool = False def considered_measures(self) -> Optional[frozenset[MeasureType]]: """The measure-type allowlist the Scenario's exclusions imply: every diff --git a/infrastructure/postgres/modelling/scenario_table.py b/infrastructure/postgres/modelling/scenario_table.py index 5f7197bae..5d434b3be 100644 --- a/infrastructure/postgres/modelling/scenario_table.py +++ b/infrastructure/postgres/modelling/scenario_table.py @@ -78,6 +78,9 @@ class ScenarioModel(SQLModel, table=True): exclusions: Optional[str] = Field(default=None) multi_plan: bool = False is_default: bool = False + # Fabric First constraint (owned by the FE Drizzle schema: boolean NOT + # NULL DEFAULT false — do not deploy this mirror before that migration). + fabric_first: bool = False # Portfolio-level aggregates stored against the Scenario. cost: Optional[float] = Field(default=None) diff --git a/tests/repositories/scenario/test_scenario_postgres_repository.py b/tests/repositories/scenario/test_scenario_postgres_repository.py index 8b5804c7d..6fc537fe1 100644 --- a/tests/repositories/scenario/test_scenario_postgres_repository.py +++ b/tests/repositories/scenario/test_scenario_postgres_repository.py @@ -142,6 +142,28 @@ def test_get_many_raises_on_an_exclusion_that_is_not_a_measure_type( ScenarioPostgresRepository(session).get_many([7]) +def test_get_many_maps_the_fabric_first_flag(db_engine: Engine) -> None: + # Arrange — a Fabric First brief created in the scenario-builder. + with Session(db_engine) as session: + session.add( + ScenarioModel( + id=7, + goal=PortfolioGoal.INCREASING_EPC, + goal_value="C", + is_default=True, + fabric_first=True, + ) + ) + session.commit() + + # Act + with Session(db_engine) as session: + scenario: Scenario = ScenarioPostgresRepository(session).get_many([7])[0] + + # Assert + assert scenario.fabric_first is True + + def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> None: # Arrange with Session(db_engine) as session: