mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(modelling): wire the ventilation Measure Dependency into the orchestrator (#1161)
ModellingOrchestrator builds the ventilation dependency per Property (suppressed when already mechanically ventilated) and passes it to optimise_package, so a selected wall measure forces MEV into the package before the re-score. Ventilation joins the role-3 cascade in best-practice order (walls -> roof -> ventilation -> floor) and persists as a Plan Measure carrying its real negative marginal and its cost. Added the mechanical_ventilation contingency rate (0.26, per legacy Costs.CONTINGENCIES). Integration test now seeds the ventilation Product and asserts the forced measure persists with <=0 SAP and 2x900 cost; the full-pipeline test seeds the Product too (the dependency is built for every not-yet-ventilated dwelling). On 000490 the real calculator scores MEV at -1.275 SAP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1bf5b4102d
commit
0fec069988
3 changed files with 68 additions and 16 deletions
|
|
@ -10,6 +10,7 @@ _CONTINGENCY_RATES: dict[str, float] = {
|
|||
"loft_insulation": 0.10,
|
||||
"suspended_floor_insulation": 0.20,
|
||||
"solid_floor_insulation": 0.26,
|
||||
"mechanical_ventilation": 0.26,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ from typing import Final, Optional
|
|||
from datatypes.epc.domain.epc import Epc
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.modelling.floor_recommendation import recommend_floor_insulation
|
||||
from domain.modelling.measure_dependency import ventilation_dependency
|
||||
from domain.modelling.optimiser import (
|
||||
MeasureDependency,
|
||||
OptimisedPackage,
|
||||
ScoredOption,
|
||||
optimise_package,
|
||||
|
|
@ -33,12 +35,15 @@ from repositories.unit_of_work import UnitOfWork
|
|||
_INCREASING_EPC_GOAL: Final[str] = "Increasing EPC"
|
||||
|
||||
# Best-practice install sequence for the role-3 attribution cascade (ADR-0016):
|
||||
# fabric in walls → roof → floor order, per the legacy `Recommendations` class.
|
||||
# walls → roof → ventilation → floor, per the legacy `Recommendations` class.
|
||||
# Ventilation sits after the fabric that triggers it so its (negative) marginal
|
||||
# is attributed against the insulated envelope.
|
||||
_BEST_PRACTICE_ORDER: Final[tuple[str, ...]] = (
|
||||
"cavity_wall_insulation",
|
||||
"external_wall_insulation",
|
||||
"internal_wall_insulation",
|
||||
"loft_insulation",
|
||||
"mechanical_ventilation",
|
||||
"suspended_floor_insulation",
|
||||
"solid_floor_insulation",
|
||||
)
|
||||
|
|
@ -106,12 +111,18 @@ class ModellingOrchestrator:
|
|||
groups: list[list[ScoredOption]] = _scored_candidate_groups(
|
||||
scorer, effective_epc, products
|
||||
)
|
||||
# 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
|
||||
)
|
||||
package: OptimisedPackage = optimise_package(
|
||||
groups=groups,
|
||||
scorer=scorer,
|
||||
baseline_epc=effective_epc,
|
||||
budget=scenario.budget,
|
||||
target_sap=_target_sap(scenario),
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
# Role-3 attribution: re-apply the *selected* set in best-practice order
|
||||
|
|
@ -145,6 +156,18 @@ def _candidate_recommendations(
|
|||
return [recommendation for recommendation in found if recommendation is not None]
|
||||
|
||||
|
||||
def _measure_dependencies(
|
||||
effective_epc: EpcPropertyData, products: ProductRepository
|
||||
) -> list[MeasureDependency]:
|
||||
"""The forced Measure Dependencies for this Property — currently just
|
||||
ventilation, suppressed when the dwelling is already mechanically
|
||||
ventilated (ADR-0016)."""
|
||||
dependency: Optional[MeasureDependency] = ventilation_dependency(
|
||||
effective_epc, products
|
||||
)
|
||||
return [dependency] if dependency is not None else []
|
||||
|
||||
|
||||
def _scored_candidate_groups(
|
||||
scorer: PackageScorer,
|
||||
effective_epc: EpcPropertyData,
|
||||
|
|
|
|||
|
|
@ -115,16 +115,28 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
|
|||
)
|
||||
)
|
||||
# The sample EPC's solid floor is uninsulated, so the floor generator
|
||||
# fires during candidate generation and prices against this Product.
|
||||
session.add(
|
||||
MaterialRow(
|
||||
id=1,
|
||||
type="solid_floor_insulation",
|
||||
total_cost=25.0,
|
||||
cost_unit="gbp_per_m2",
|
||||
is_active=True,
|
||||
description="Solid floor insulation",
|
||||
)
|
||||
# fires during candidate generation and prices against this Product. The
|
||||
# ventilation Measure Dependency is built for every not-yet-ventilated
|
||||
# dwelling, so its Product must exist too (ADR-0016).
|
||||
session.add_all(
|
||||
[
|
||||
MaterialRow(
|
||||
id=1,
|
||||
type="solid_floor_insulation",
|
||||
total_cost=25.0,
|
||||
cost_unit="gbp_per_m2",
|
||||
is_active=True,
|
||||
description="Solid floor insulation",
|
||||
),
|
||||
MaterialRow(
|
||||
id=2,
|
||||
type="mechanical_ventilation",
|
||||
total_cost=450.0,
|
||||
cost_unit="gbp_per_unit",
|
||||
is_active=True,
|
||||
description="Mechanical extract ventilation unit",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
|
@ -220,6 +232,14 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
|
|||
is_active=True,
|
||||
description="Suspended floor insulation",
|
||||
),
|
||||
MaterialRow(
|
||||
id=3,
|
||||
type="mechanical_ventilation",
|
||||
total_cost=450.0,
|
||||
cost_unit="gbp_per_unit",
|
||||
is_active=True,
|
||||
description="Mechanical extract ventilation unit",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
|
@ -238,8 +258,9 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
|
|||
unit_of_work=unit_of_work, calculator=Sap10Calculator()
|
||||
).run(property_ids=[30], scenario_ids=[7], portfolio_id=1)
|
||||
|
||||
# Assert — one Plan with two Plan Measures (wall + floor), each priced and
|
||||
# attributed, linked by plan_id.
|
||||
# Assert — one Plan with three Plan Measures: the wall + floor the Optimiser
|
||||
# chose, plus the ventilation Measure Dependency the wall forces in
|
||||
# (ADR-0016). Each is priced and attributed, linked by plan_id.
|
||||
with Session(db_engine) as session:
|
||||
plan = session.exec(
|
||||
select(PlanRow).where(col(PlanRow.property_id) == 30)
|
||||
|
|
@ -259,14 +280,21 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
|
|||
assert plan.cost_of_works is not None
|
||||
assert plan.cost_of_works > 0.0
|
||||
|
||||
measure_types = {rec.type for rec in rec_rows}
|
||||
assert measure_types == {
|
||||
by_type = {rec.type: rec for rec in rec_rows}
|
||||
assert set(by_type) == {
|
||||
"cavity_wall_insulation",
|
||||
"suspended_floor_insulation",
|
||||
"mechanical_ventilation",
|
||||
}
|
||||
for rec in rec_rows:
|
||||
assert rec.default is True
|
||||
assert rec.already_installed is False
|
||||
assert rec.sap_points is not None
|
||||
assert rec.estimated_cost is not None
|
||||
assert rec.estimated_cost > 0.0
|
||||
# The forced ventilation costs two £450 units and is priced even though it
|
||||
# was never a free choice in the pool.
|
||||
assert abs(by_type["mechanical_ventilation"].estimated_cost - 900.0) <= 1e-6
|
||||
# The insulation measures earn positive SAP; ventilation's contribution is
|
||||
# not positive (it only ever costs SAP — ADR-0016).
|
||||
assert by_type["cavity_wall_insulation"].sap_points > 0.0
|
||||
assert by_type["mechanical_ventilation"].sap_points <= 0.0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue