Model/tests/harness/test_console.py
Khalim Conn-Kowlessar 07f534ee11 feat(modelling): system tune-up options (standard + zone controls)
Add the system tune-up to the heating Recommendation: keep the existing wet
boiler but install better heating controls and fix the cylinder. Two competing
Options (the Optimiser picks <=1 across the whole heating rec) per the user's
two best control end-states:

- system_tune_up        — standard controls (programmer + room thermostat +
  TRVs, SAP 10.2 Table 4e code 2106)
- system_tune_up_zoned  — time-and-temperature zone control (code 2110, type 3):
  more SAP uplift for more cost

Both keep the boiler (no fuel / SAP code / flue change), set the control
ABSOLUTELY to their end-state, and apply the conditional cylinder fixes (an
80 mm jacket when under-insulated, a thermostat when absent — only when a
cylinder exists). Each control option is offered only when it genuinely improves
the existing control — standard is skipped when the control is already 2106 /
2110 / 2112, zone when already 2110 / 2112 — so neither is ever a downgrade or a
no-op.

Validated against the Elmhurst "system tune up" re-lodgements (cert 001431):
nine befores spanning controls 2101-2113 all converge to the two common afters,
proving the control overlay is absolute. The cascade pin is parametrised over
two starting controls (2101 "no control" + 2113 "room thermostat and TRVs") x
both afters, delta 0 (SAP/CO2/PE).

Wires the two MeasureTypes through contingencies (0.15), the offline catalogue
(500 / 900), the catalogue-coverage list, the report triggers, and the ARA
first-run seed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:20:46 +00:00

250 lines
9.9 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.generators.heating_recommendation import recommend_heating
from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall
from harness.console import DEFAULT_CATALOGUE, 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",
)
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 "cavity_wall_insulation" 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_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