mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): considered_measures allowlist on the orchestrator
Add domain/modelling/considered_measures.py::restrict_to_considered_measures —
the pure allowlist that limits a run to a chosen set of MeasureType (mirroring
the legacy engine's `inclusions`). It filters at the Option level, so a
multi-option Recommendation (e.g. Heating & Hot Water competing HHRSH against
an ASHP bundle) is kept with only its allowed Options; a Recommendation left
with none is dropped. None = consider everything (unrestricted default).
Thread `considered_measures: frozenset[MeasureType] | None` through
ModellingOrchestrator.run -> _plan_for -> _scored_candidate_groups /
_candidate_recommendations (applies the filter) and _measure_dependencies
(suppresses a forced dependency whose required measure is outside the
allowlist, so a restricted run forces nothing it is not considering). The
local-run seam (harness.console.run_modelling) gains the same param.
The Optimiser still freely chooses among survivors — including none. Tests:
the pure filter (3 cases) + an orchestrator-seam test proving a
{solar_pv}-restricted run yields only solar_pv options. 257 pass + 3 xfail;
pyright clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9ef97be958
commit
7942a8101a
5 changed files with 196 additions and 11 deletions
42
domain/modelling/considered_measures.py
Normal file
42
domain/modelling/considered_measures.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Restricting a modelling run to a chosen set of measure types.
|
||||
|
||||
The allowlist a run "considers" — mirroring the legacy engine's `inclusions`
|
||||
(`backend/app/plan/schemas.py`). It filters the candidate Recommendations at the
|
||||
Option level so a multi-option Recommendation (e.g. Heating & Hot Water competing
|
||||
HHRSH against an ASHP bundle) is kept with only its allowed Options; a
|
||||
Recommendation left with no allowed Option is dropped. The Optimiser still
|
||||
freely chooses among what survives — including choosing nothing.
|
||||
|
||||
A `None` allowlist means "consider every modelled measure" (the unrestricted
|
||||
default).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Optional
|
||||
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.recommendation import Recommendation
|
||||
|
||||
|
||||
def restrict_to_considered_measures(
|
||||
recommendations: Iterable[Recommendation],
|
||||
considered_measures: Optional[frozenset[MeasureType]],
|
||||
) -> list[Recommendation]:
|
||||
"""Keep only the Options whose measure type is in ``considered_measures``,
|
||||
dropping any Recommendation left with none. ``None`` keeps everything."""
|
||||
if considered_measures is None:
|
||||
return list(recommendations)
|
||||
restricted: list[Recommendation] = []
|
||||
for recommendation in recommendations:
|
||||
kept = tuple(
|
||||
option
|
||||
for option in recommendation.options
|
||||
if option.measure_type in considered_measures
|
||||
)
|
||||
if kept:
|
||||
restricted.append(
|
||||
Recommendation(surface=recommendation.surface, options=kept)
|
||||
)
|
||||
return restricted
|
||||
|
|
@ -23,6 +23,7 @@ from typing import Any, Optional
|
|||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.geospatial.coordinates import Coordinates
|
||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.plan import Plan
|
||||
from domain.modelling.scenario import Scenario
|
||||
from domain.property.property import Property, PropertyIdentity
|
||||
|
|
@ -171,6 +172,7 @@ def run_modelling(
|
|||
current_market_value: Optional[float] = None,
|
||||
planning_restrictions: PlanningRestrictions = PlanningRestrictions(),
|
||||
solar_insights: Optional[dict[str, Any]] = None,
|
||||
considered_measures: Optional[frozenset[MeasureType]] = None,
|
||||
print_table: bool = True,
|
||||
) -> Plan:
|
||||
"""Run ONLY the Modelling stage over ``epc`` with no database — skipping
|
||||
|
|
@ -229,6 +231,7 @@ def run_modelling(
|
|||
property_ids=[_PROPERTY_ID],
|
||||
scenario_ids=[_SCENARIO_ID],
|
||||
portfolio_id=_PORTFOLIO_ID,
|
||||
considered_measures=considered_measures,
|
||||
)
|
||||
|
||||
plan = plan_repo.saved[(_PROPERTY_ID, _SCENARIO_ID)]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ 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.generators.floor_recommendation import recommend_floor_insulation
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.optimisation.measure_dependency import ventilation_dependency
|
||||
from domain.modelling.optimisation.optimiser import (
|
||||
MeasureDependency,
|
||||
|
|
@ -92,8 +94,16 @@ class ModellingOrchestrator:
|
|||
self._fuel_rates = fuel_rates
|
||||
|
||||
def run(
|
||||
self, property_ids: list[int], scenario_ids: list[int], portfolio_id: int
|
||||
self,
|
||||
property_ids: list[int],
|
||||
scenario_ids: list[int],
|
||||
portfolio_id: int,
|
||||
*,
|
||||
considered_measures: Optional[frozenset[MeasureType]] = None,
|
||||
) -> None:
|
||||
"""Model the batch. ``considered_measures`` restricts the run to those
|
||||
measure types (mirroring the legacy `inclusions`); None considers every
|
||||
modelled measure."""
|
||||
scorer = PackageScorer(self._calculator)
|
||||
# Resolve Fuel Rates once and reuse the BillDerivation across the batch,
|
||||
# so every baseline/post bill is priced at the same snapshot (ADR-0014).
|
||||
|
|
@ -120,6 +130,7 @@ class ModellingOrchestrator:
|
|||
current_market_value=prop.current_market_value,
|
||||
planning_restrictions=prop.planning_restrictions,
|
||||
solar_potential=solar_potential,
|
||||
considered_measures=considered_measures,
|
||||
)
|
||||
uow.plan.save(
|
||||
plan,
|
||||
|
|
@ -141,16 +152,22 @@ class ModellingOrchestrator:
|
|||
current_market_value: Optional[float],
|
||||
planning_restrictions: PlanningRestrictions,
|
||||
solar_potential: Optional[SolarPotential],
|
||||
considered_measures: Optional[frozenset[MeasureType]],
|
||||
) -> Plan:
|
||||
"""Generate → score → optimise → re-score/repair → attribute → bill →
|
||||
assemble the Plan for one Property + Scenario."""
|
||||
groups: list[list[ScoredOption]] = _scored_candidate_groups(
|
||||
scorer, effective_epc, products, planning_restrictions, solar_potential
|
||||
scorer,
|
||||
effective_epc,
|
||||
products,
|
||||
planning_restrictions,
|
||||
solar_potential,
|
||||
considered_measures,
|
||||
)
|
||||
# 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
|
||||
effective_epc, products, considered_measures
|
||||
)
|
||||
package: OptimisedPackage = optimise_package(
|
||||
groups=groups,
|
||||
|
|
@ -224,11 +241,13 @@ def _candidate_recommendations(
|
|||
products: ProductRepository,
|
||||
planning_restrictions: PlanningRestrictions,
|
||||
solar_potential: Optional[SolarPotential],
|
||||
considered_measures: Optional[frozenset[MeasureType]],
|
||||
) -> list[Recommendation]:
|
||||
"""Run every Recommendation Generator; keep the ones that apply. Solid-wall
|
||||
insulation, glazing, heating and solar are additionally gated by the
|
||||
Property's planning protections (ADR-0019 / ADR-0022 / ADR-0024 / ADR-0026);
|
||||
solar also needs the Property's Google solar potential."""
|
||||
solar also needs the Property's Google solar potential. ``considered_measures``
|
||||
then restricts the survivors to the run's allowlist (None = all)."""
|
||||
found = (
|
||||
recommend_cavity_wall(effective_epc, products),
|
||||
recommend_solid_wall(effective_epc, products, planning_restrictions),
|
||||
|
|
@ -241,19 +260,33 @@ def _candidate_recommendations(
|
|||
effective_epc, products, solar_potential, planning_restrictions
|
||||
),
|
||||
)
|
||||
return [recommendation for recommendation in found if recommendation is not None]
|
||||
applicable = [
|
||||
recommendation for recommendation in found if recommendation is not None
|
||||
]
|
||||
return restrict_to_considered_measures(applicable, considered_measures)
|
||||
|
||||
|
||||
def _measure_dependencies(
|
||||
effective_epc: EpcPropertyData, products: ProductRepository
|
||||
effective_epc: EpcPropertyData,
|
||||
products: ProductRepository,
|
||||
considered_measures: Optional[frozenset[MeasureType]],
|
||||
) -> list[MeasureDependency]:
|
||||
"""The forced Measure Dependencies for this Property — currently just
|
||||
ventilation, suppressed when the dwelling is already mechanically
|
||||
ventilated (ADR-0016)."""
|
||||
ventilated (ADR-0016). A dependency whose required measure is outside the
|
||||
run's allowlist is also suppressed, so a restricted run forces nothing it is
|
||||
not considering."""
|
||||
dependency: Optional[MeasureDependency] = ventilation_dependency(
|
||||
effective_epc, products
|
||||
)
|
||||
return [dependency] if dependency is not None else []
|
||||
if dependency is None:
|
||||
return []
|
||||
if (
|
||||
considered_measures is not None
|
||||
and dependency.required.option.measure_type not in considered_measures
|
||||
):
|
||||
return []
|
||||
return [dependency]
|
||||
|
||||
|
||||
def _scored_candidate_groups(
|
||||
|
|
@ -262,12 +295,13 @@ def _scored_candidate_groups(
|
|||
products: ProductRepository,
|
||||
planning_restrictions: PlanningRestrictions,
|
||||
solar_potential: Optional[SolarPotential],
|
||||
considered_measures: Optional[frozenset[MeasureType]],
|
||||
) -> list[list[ScoredOption]]:
|
||||
"""One group per Recommendation: each Option scored independently against
|
||||
the baseline (role-1 warm-start signal, ADR-0016)."""
|
||||
groups: list[list[ScoredOption]] = []
|
||||
for recommendation in _candidate_recommendations(
|
||||
effective_epc, products, planning_restrictions, solar_potential
|
||||
effective_epc, products, planning_restrictions, solar_potential, considered_measures
|
||||
):
|
||||
options = list(recommendation.options)
|
||||
impacts: list[MeasureImpact] = independent_option_impacts(
|
||||
|
|
|
|||
83
tests/domain/modelling/test_considered_measures.py
Normal file
83
tests/domain/modelling/test_considered_measures.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""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 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_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]
|
||||
|
|
@ -15,6 +15,7 @@ from typing import Any
|
|||
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.product import Product
|
||||
from domain.modelling.recommendation import Recommendation
|
||||
from orchestration.modelling_orchestrator import (
|
||||
|
|
@ -95,7 +96,7 @@ def test_candidate_recommendations_includes_solar_when_potential_present() -> No
|
|||
|
||||
# Act
|
||||
recommendations: list[Recommendation] = _candidate_recommendations(
|
||||
epc, _StubProducts(), PlanningRestrictions(), potential
|
||||
epc, _StubProducts(), PlanningRestrictions(), potential, None
|
||||
)
|
||||
|
||||
# Assert — a "Solar PV" Recommendation is among the candidates.
|
||||
|
|
@ -108,8 +109,30 @@ def test_candidate_recommendations_excludes_solar_without_potential() -> None:
|
|||
|
||||
# Act
|
||||
recommendations = _candidate_recommendations(
|
||||
epc, _StubProducts(), PlanningRestrictions(), None
|
||||
epc, _StubProducts(), PlanningRestrictions(), None, None
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert "Solar PV" not in {r.surface for r in recommendations}
|
||||
|
||||
|
||||
def test_considered_measures_restricts_candidates_to_the_allowlist() -> None:
|
||||
# Arrange — a solar-eligible house, with its solar potential present, so the
|
||||
# unrestricted run offers Solar PV alongside any fabric/heating candidates.
|
||||
epc = _eligible_house()
|
||||
potential = _solar_potential_for(
|
||||
FakeSolarRepo(by_property={1: json.loads(_INSIGHTS_FIXTURE.read_text())}), 1
|
||||
)
|
||||
|
||||
# Act — restrict the run to Solar PV only.
|
||||
recommendations = _candidate_recommendations(
|
||||
epc, _StubProducts(), PlanningRestrictions(), potential, frozenset({MeasureType.SOLAR_PV})
|
||||
)
|
||||
|
||||
# Assert — every surviving Option is solar_pv; nothing else leaks through.
|
||||
option_types = {
|
||||
option.measure_type
|
||||
for recommendation in recommendations
|
||||
for option in recommendation.options
|
||||
}
|
||||
assert option_types == {MeasureType.SOLAR_PV}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue