mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
306 lines
12 KiB
Python
306 lines
12 KiB
Python
"""The one-property console entrypoint for interactive sense-checking."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
|
|
import pytest
|
|
|
|
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 (
|
|
DEFAULT_CATALOGUE,
|
|
candidate_recommendations,
|
|
run_modelling,
|
|
run_one,
|
|
)
|
|
from repositories.product.product_json_repository import ProductJsonRepository
|
|
from tests.domain.modelling._elmhurst_recommendation import (
|
|
parse_recommendation_summary,
|
|
)
|
|
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
|
|
build_epc as _build_uninsulated_cavity_and_floor_epc,
|
|
)
|
|
|
|
# Every Measure Type the fabric generators can emit; the harness catalogue
|
|
# must price all of them or an offline run raises mid-pipeline.
|
|
_GENERATOR_MEASURE_TYPES = (
|
|
"cavity_wall_insulation",
|
|
"external_wall_insulation",
|
|
"internal_wall_insulation",
|
|
"loft_insulation",
|
|
"sloping_ceiling_insulation",
|
|
"flat_roof_insulation",
|
|
"suspended_floor_insulation",
|
|
"solid_floor_insulation",
|
|
"mechanical_ventilation",
|
|
"double_glazing",
|
|
"secondary_glazing",
|
|
"low_energy_lighting",
|
|
"high_heat_retention_storage_heaters",
|
|
"air_source_heat_pump",
|
|
"gas_boiler_upgrade",
|
|
"system_tune_up",
|
|
"system_tune_up_zoned",
|
|
"secondary_heating_removal",
|
|
)
|
|
|
|
|
|
def _uninsulated_lodged_epc() -> EpcPropertyData:
|
|
epc = _build_uninsulated_cavity_and_floor_epc()
|
|
return dataclasses.replace(
|
|
epc,
|
|
energy_rating_current=57,
|
|
current_energy_efficiency_band=Epc.D,
|
|
co2_emissions_current=3.0,
|
|
energy_consumption_current=300,
|
|
)
|
|
|
|
|
|
def test_run_one_returns_a_plan_and_prints_the_table(
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
# Arrange
|
|
epc: EpcPropertyData = _uninsulated_lodged_epc()
|
|
|
|
# Act — run one property end-to-end with no database, against the default
|
|
# sample catalogue.
|
|
plan = run_one(epc, goal_band="C")
|
|
|
|
# Assert — a multi-measure Plan came back, and its sense-check table printed.
|
|
assert len(plan.measures) >= 1
|
|
printed: str = capsys.readouterr().out
|
|
assert "Plan SAP" in printed
|
|
assert "air_source_heat_pump" in printed
|
|
|
|
|
|
def test_run_modelling_inspects_a_plan_without_baseline_or_lodged_performance() -> None:
|
|
# Arrange — the RAW 000490 fixture, with NO lodged recorded-performance, so
|
|
# the Baseline stage could not run on it. Modelling re-scores the EPC itself.
|
|
epc: EpcPropertyData = _build_uninsulated_cavity_and_floor_epc()
|
|
|
|
# Act — Modelling only, no Ingestion / Baseline, no database.
|
|
plan = run_modelling(epc, goal_band="C", print_table=False)
|
|
|
|
# Assert — a multi-measure Plan came straight out of Modelling.
|
|
assert len(plan.measures) >= 1
|
|
|
|
|
|
def test_run_modelling_recommends_solid_wall_insulation_for_solid_brick() -> None:
|
|
# Arrange — an uninsulated solid-brick dwelling (cert 001431 before),
|
|
# which has no cavity to fill, so any wall measure must be solid-wall.
|
|
epc: EpcPropertyData = parse_recommendation_summary(
|
|
"solid_brick_ewi_001431_before.pdf"
|
|
)
|
|
|
|
# Assert — the solid-wall generator is wired in and OFFERS a solid-wall
|
|
# Option for this dwelling. Whether the Optimiser then selects it depends on
|
|
# the package: the efficient ASHP bundle (ADR-0025) now often reaches the
|
|
# band first, so we assert the Option is OFFERED (the wiring/eligibility
|
|
# intent) rather than selected.
|
|
recommendation = recommend_solid_wall(epc, ProductJsonRepository(DEFAULT_CATALOGUE))
|
|
assert recommendation is not None
|
|
assert {o.measure_type for o in recommendation.options} & {
|
|
"external_wall_insulation",
|
|
"internal_wall_insulation",
|
|
}
|
|
|
|
# And Modelling still produces a plan end to end (now ASHP-led).
|
|
plan = run_modelling(epc, goal_band="C", print_table=False)
|
|
assert len(plan.measures) >= 1
|
|
|
|
|
|
def test_run_modelling_listed_building_yields_no_wall_insulation() -> None:
|
|
# Arrange — the same uninsulated solid-brick dwelling that gets IWI when
|
|
# unrestricted; listing it protects the fabric, blocking both EWI and IWI.
|
|
epc: EpcPropertyData = parse_recommendation_summary(
|
|
"solid_brick_ewi_001431_before.pdf"
|
|
)
|
|
|
|
# Act — thread a listed-building restriction through to the generator.
|
|
plan = run_modelling(
|
|
epc,
|
|
goal_band="C",
|
|
print_table=False,
|
|
planning_restrictions=PlanningRestrictions(is_listed=True),
|
|
)
|
|
|
|
# Assert — no wall-insulation measure survives the restriction.
|
|
measure_types = {measure.measure_type for measure in plan.measures}
|
|
assert not (
|
|
measure_types & {"external_wall_insulation", "internal_wall_insulation"}
|
|
)
|
|
|
|
|
|
def _single_glazed_epc() -> EpcPropertyData:
|
|
"""The cavity/floor dwelling with all windows single-glazed — the glazing
|
|
generator's trigger, sized so the upgrade reaches the optimised package."""
|
|
epc: EpcPropertyData = _build_uninsulated_cavity_and_floor_epc()
|
|
for window in epc.sap_windows:
|
|
window.glazing_type = 1 # SAP10.2 Table U2 code 1 = single.
|
|
return epc
|
|
|
|
|
|
def test_run_modelling_recommends_double_glazing_for_single_glazed_windows() -> None:
|
|
# Arrange — a single-glazed dwelling; the glazing generator is wired into
|
|
# the candidate pool.
|
|
epc: EpcPropertyData = _single_glazed_epc()
|
|
|
|
# Act — Modelling only, no database, unrestricted.
|
|
plan = run_modelling(epc, goal_band="C", print_table=False)
|
|
|
|
# Assert — double glazing reaches the optimised package.
|
|
measure_types = {measure.measure_type for measure in plan.measures}
|
|
assert "double_glazing" in measure_types
|
|
|
|
|
|
def test_run_modelling_protected_dwelling_yields_secondary_glazing() -> None:
|
|
# Arrange — the same single-glazed dwelling, listed (blocks external work).
|
|
epc: EpcPropertyData = _single_glazed_epc()
|
|
|
|
# Act — thread a listed-building restriction through to the generator.
|
|
plan = run_modelling(
|
|
epc,
|
|
goal_band="C",
|
|
print_table=False,
|
|
planning_restrictions=PlanningRestrictions(is_listed=True),
|
|
)
|
|
|
|
# Assert — the picked glazing Measure is secondary, never double.
|
|
measure_types = {measure.measure_type for measure in plan.measures}
|
|
assert "secondary_glazing" in measure_types
|
|
|
|
|
|
def _incandescent_lit_epc() -> EpcPropertyData:
|
|
"""The cavity/floor dwelling lit entirely by incandescent bulbs — the
|
|
lighting generator's trigger, sized so the LED upgrade reaches the package."""
|
|
epc: EpcPropertyData = _build_uninsulated_cavity_and_floor_epc()
|
|
epc.led_fixed_lighting_bulbs_count = 0
|
|
epc.cfl_fixed_lighting_bulbs_count = 0
|
|
epc.incandescent_fixed_lighting_bulbs_count = 10
|
|
epc.low_energy_fixed_lighting_bulbs_count = 0
|
|
return epc
|
|
|
|
|
|
def test_run_modelling_recommends_low_energy_lighting_for_non_led_bulbs() -> None:
|
|
# Arrange — a dwelling lit by incandescent bulbs; the lighting generator is
|
|
# wired into the candidate pool.
|
|
epc: EpcPropertyData = _incandescent_lit_epc()
|
|
|
|
# Act — Modelling only, no database.
|
|
plan = run_modelling(epc, goal_band="C", print_table=False)
|
|
|
|
# Assert — the LED upgrade reaches the optimised package.
|
|
measure_types = {measure.measure_type for measure in plan.measures}
|
|
assert "low_energy_lighting" in measure_types
|
|
assert "double_glazing" not in measure_types
|
|
|
|
|
|
def _electric_storage_lit_epc() -> EpcPropertyData:
|
|
epc: EpcPropertyData = _build_uninsulated_cavity_and_floor_epc()
|
|
main = epc.sap_heating.main_heating_details[0]
|
|
main.main_fuel_type = 30
|
|
main.sap_main_heating_code = 402
|
|
main.main_heating_control = 2401
|
|
return epc
|
|
|
|
|
|
def test_run_modelling_recommends_hhr_storage_for_an_electric_dwelling() -> None:
|
|
# Arrange — an electrically-heated dwelling on old storage heaters; the
|
|
# heating generator is wired into the candidate pool (ADR-0024).
|
|
epc: EpcPropertyData = _electric_storage_lit_epc()
|
|
|
|
# Assert — the heating generator is wired in and OFFERS the HHR storage
|
|
# bundle for an electric dwelling. The Optimiser now selects the ASHP bundle
|
|
# instead — the efficient Vaillant (ADR-0025) beats resistance storage on SAP
|
|
# — so HHR is offered-but-not-selected; assert it is OFFERED to preserve the
|
|
# wiring check, and that the optimised package leads with ASHP.
|
|
recommendation = recommend_heating(epc, ProductJsonRepository(DEFAULT_CATALOGUE))
|
|
assert recommendation is not None
|
|
assert "high_heat_retention_storage_heaters" in {
|
|
o.measure_type for o in recommendation.options
|
|
}
|
|
|
|
plan = run_modelling(epc, goal_band="C", print_table=False)
|
|
assert "air_source_heat_pump" in {m.measure_type for m in plan.measures}
|
|
|
|
|
|
def test_candidate_recommendations_surface_unselected_options_with_cost() -> None:
|
|
# Arrange — an electric dwelling whose heating Recommendation offers both an
|
|
# ASHP and an HHR-storage bundle; the Optimiser selects only one of them.
|
|
epc: EpcPropertyData = _electric_storage_lit_epc()
|
|
|
|
# Act — the full candidate menu (every Generator Option, pre-optimisation)
|
|
# alongside the optimised Plan.
|
|
candidates = candidate_recommendations(epc)
|
|
plan = run_modelling(epc, goal_band="C", print_table=False)
|
|
|
|
# Assert — the menu carries every offered Option (so a measure the Plan did
|
|
# not select, like the passed-over HHR bundle, is still inspectable), and
|
|
# every Option carries a cost so "cost per measure" is always available.
|
|
offered = {
|
|
option.measure_type for rec in candidates for option in rec.options
|
|
}
|
|
selected = {measure.measure_type for measure in plan.measures}
|
|
assert "high_heat_retention_storage_heaters" in offered
|
|
assert "air_source_heat_pump" in offered
|
|
assert offered - selected # at least one offered measure was not selected
|
|
assert all(
|
|
option.cost is not None for rec in candidates for option in rec.options
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
# Act / Assert — get() and contingency_rate() each raise on a missing
|
|
# Measure Type, so an offline run over arbitrary EPCs never dies on a
|
|
# missing catalogue or contingency entry.
|
|
for measure_type in _GENERATOR_MEASURE_TYPES:
|
|
products.get(measure_type)
|
|
contingency_rate(measure_type)
|
|
|
|
|
|
def test_run_one_threads_a_current_market_value_onto_the_plan() -> None:
|
|
# Arrange
|
|
epc: EpcPropertyData = _uninsulated_lodged_epc()
|
|
|
|
# Act — supply a Property Valuation so the Plan can value the uplift.
|
|
plan = run_one(
|
|
epc, goal_band="C", current_market_value=250_000.0, print_table=False
|
|
)
|
|
|
|
# Assert — the value reached the Plan, which derives its Valuation Uplift
|
|
# from it (the £ amount is 0 here as 000490 stays within band D).
|
|
assert plan.current_market_value == 250_000.0
|
|
assert plan.valuation.average_value is not None
|