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
|
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(
|
def restrict_to_considered_measures(
|
||||||
recommendations: Iterable[Recommendation],
|
recommendations: Iterable[Recommendation],
|
||||||
considered_measures: Optional[frozenset[MeasureType]],
|
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 dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
|
|
||||||
|
_NO_EXCLUSIONS: frozenset[MeasureType] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Scenario:
|
class Scenario:
|
||||||
"""A retrofit brief: its goal, optional budget, and whether it is the
|
"""A retrofit brief: its goal, optional budget, and whether it is the
|
||||||
Property's default Scenario. `goal` / `goal_value` are the lodged target
|
Property's default Scenario. `goal` / `goal_value` are the lodged target
|
||||||
(e.g. "INCREASING_EPC" → band "C"); carried for the Optimiser, not yet
|
(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
|
id: int
|
||||||
goal: str
|
goal: str
|
||||||
goal_value: str
|
goal_value: str
|
||||||
budget: Optional[float]
|
budget: Optional[float]
|
||||||
is_default: bool
|
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 sqlalchemy.sql import func
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
from domain.modelling.portfolio_goal import PortfolioGoal
|
from domain.modelling.portfolio_goal import PortfolioGoal
|
||||||
from domain.modelling.scenario import Scenario
|
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):
|
class ScenarioModel(SQLModel, table=True):
|
||||||
"""The single SQLModel definition of the live ``scenario`` table (ADR-0017
|
"""The single SQLModel definition of the live ``scenario`` table (ADR-0017
|
||||||
amendment). Full legacy column parity; ``goal`` is the ``PortfolioGoal``
|
amendment). Full legacy column parity; ``goal`` is the ``PortfolioGoal``
|
||||||
|
|
@ -89,4 +114,5 @@ class ScenarioModel(SQLModel, table=True):
|
||||||
goal_value=self.goal_value,
|
goal_value=self.goal_value,
|
||||||
budget=self.budget,
|
budget=self.budget,
|
||||||
is_default=self.is_default,
|
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 datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
from domain.billing.bill import Bill, EnergyBreakdown
|
from domain.billing.bill import Bill, EnergyBreakdown
|
||||||
from domain.billing.bill_derivation import BillDerivation
|
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.generators.floor_recommendation import recommend_floor_insulation
|
||||||
from domain.modelling.measure_type import MeasureType
|
from domain.modelling.measure_type import MeasureType
|
||||||
from domain.modelling.optimisation.measure_dependency import ventilation_dependency
|
from domain.modelling.optimisation.measure_dependency import ventilation_dependency
|
||||||
|
|
@ -159,18 +162,23 @@ class ModellingOrchestrator:
|
||||||
) -> Plan:
|
) -> Plan:
|
||||||
"""Generate → score → optimise → re-score/repair → attribute → bill →
|
"""Generate → score → optimise → re-score/repair → attribute → bill →
|
||||||
assemble the Plan for one Property + Scenario."""
|
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(
|
groups: list[list[ScoredOption]] = _scored_candidate_groups(
|
||||||
scorer,
|
scorer,
|
||||||
effective_epc,
|
effective_epc,
|
||||||
products,
|
products,
|
||||||
planning_restrictions,
|
planning_restrictions,
|
||||||
solar_potential,
|
solar_potential,
|
||||||
considered_measures,
|
considered,
|
||||||
)
|
)
|
||||||
# Forced Measure Dependencies (ventilation) are excluded from the pool
|
# Forced Measure Dependencies (ventilation) are excluded from the pool
|
||||||
# but injected into the package before the re-score (ADR-0016).
|
# but injected into the package before the re-score (ADR-0016).
|
||||||
dependencies: list[MeasureDependency] = _measure_dependencies(
|
dependencies: list[MeasureDependency] = _measure_dependencies(
|
||||||
effective_epc, products, considered_measures
|
effective_epc, products, considered
|
||||||
)
|
)
|
||||||
package: OptimisedPackage = optimise_package(
|
package: OptimisedPackage = optimise_package(
|
||||||
groups=groups,
|
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 datatypes.epc.domain.epc_property_data import EpcPropertyData # noqa: E402
|
||||||
from domain.geospatial.planning_restrictions import PlanningRestrictions # noqa: E402
|
from domain.geospatial.planning_restrictions import PlanningRestrictions # noqa: E402
|
||||||
from domain.geospatial.spatial_reference import SpatialReference # 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.measure_type import MeasureType # noqa: E402
|
||||||
from domain.modelling.plan import Plan, PlanMeasure # noqa: E402
|
from domain.modelling.plan import Plan, PlanMeasure # noqa: E402
|
||||||
from domain.modelling.recommendation import Recommendation # noqa: E402
|
from domain.modelling.recommendation import Recommendation # noqa: E402
|
||||||
|
|
@ -339,13 +342,15 @@ def main() -> None:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--measures",
|
"--measures",
|
||||||
default=None,
|
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(
|
parser.add_argument(
|
||||||
"--exclude-measures",
|
"--exclude-measures",
|
||||||
default=None,
|
default=None,
|
||||||
help="comma-separated measure types to exclude (default: none) — e.g. "
|
help="optional override: comma-separated measure types to exclude on top "
|
||||||
"secondary_heating_removal, which the live catalogue does not yet stock",
|
"of the Scenario's own exclusions (e.g. secondary_heating_removal, which "
|
||||||
|
"the live catalogue does not yet stock)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--portfolio-id",
|
"--portfolio-id",
|
||||||
|
|
@ -377,7 +382,7 @@ def main() -> None:
|
||||||
geospatial = GeospatialS3Repository(_s3_parquet_reader(os.environ["DATA_BUCKET"]))
|
geospatial = GeospatialS3Repository(_s3_parquet_reader(os.environ["DATA_BUCKET"]))
|
||||||
solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"])
|
solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"])
|
||||||
engine = _engine()
|
engine = _engine()
|
||||||
considered = _resolve_considered(
|
cli_considered = _resolve_considered(
|
||||||
_parse_measures(args.measures), _parse_measures(args.exclude_measures)
|
_parse_measures(args.measures), _parse_measures(args.exclude_measures)
|
||||||
)
|
)
|
||||||
uprns = _uprns_for(engine, args.property_ids)
|
uprns = _uprns_for(engine, args.property_ids)
|
||||||
|
|
@ -390,6 +395,12 @@ def main() -> None:
|
||||||
if args.scenario_id is not None
|
if args.scenario_id is not None
|
||||||
else 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 = (
|
target = (
|
||||||
f"scenario {scenario.id} (band {scenario.goal_value})"
|
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).
|
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.measure_type import MeasureType
|
||||||
from domain.modelling.recommendation import MeasureOption, Recommendation
|
from domain.modelling.recommendation import MeasureOption, Recommendation
|
||||||
from domain.modelling.simulation import EpcSimulation
|
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"}
|
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:
|
def test_filters_options_within_a_kept_recommendation() -> None:
|
||||||
# Arrange — HHRSH is allowed but the competing ASHP bundle is not.
|
# Arrange — HHRSH is allowed but the competing ASHP bundle is not.
|
||||||
considered = frozenset(
|
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 datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
from domain.modelling.contingencies import contingency_rate
|
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.heating_recommendation import recommend_heating
|
||||||
from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall
|
from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall
|
||||||
from harness.console import (
|
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:
|
def test_sample_catalogue_prices_every_generator_measure_type() -> None:
|
||||||
# Arrange — the default offline catalogue.
|
# Arrange — the default offline catalogue.
|
||||||
products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE)
|
products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import pytest
|
||||||
from sqlalchemy import Engine
|
from sqlalchemy import Engine
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from domain.modelling.measure_type import MeasureType
|
||||||
from domain.modelling.portfolio_goal import PortfolioGoal
|
from domain.modelling.portfolio_goal import PortfolioGoal
|
||||||
from domain.modelling.scenario import Scenario
|
from domain.modelling.scenario import Scenario
|
||||||
from infrastructure.postgres.modelling import ScenarioModel
|
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:
|
def test_get_many_raises_when_a_scenario_id_is_missing(db_engine: Engine) -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
with Session(db_engine) as session:
|
with Session(db_engine) as session:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue