From 98f5ee4fca1a99f33f98ee2765e7c4ccdd160c74 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 09:19:18 +0000 Subject: [PATCH] feat(modelling): robust offline modelling inspection (run_modelling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes that unblock offline, no-database inspection over an arbitrary EPC dump: - Complete the harness sample catalogue with loft_insulation and solid_floor_insulation — the four fabric generators can emit five Measure Types, but the catalogue priced only three, so an offline run on a property with an uninsulated loft or solid floor raised mid-run. A new test pins the catalogue to cover every generator Measure Type. - Add `run_modelling(epc, ...)` — runs ONLY the Modelling stage (no Ingestion / Baseline), so it needs no lodged recorded-performance / RHI and inspects recommendations on any calculator-scorable EPC. `run_one` (full pipeline) stays for when you want Baseline too. Co-Authored-By: Claude Opus 4.8 --- harness/console.py | 61 +++++++++++++++++++++++++++++++++++ harness/sample_catalogue.json | 2 ++ tests/harness/test_console.py | 35 +++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/harness/console.py b/harness/console.py index 648ff739..68285b94 100644 --- a/harness/console.py +++ b/harness/console.py @@ -159,3 +159,64 @@ def run_one( if print_table: print("\n" + format_plan_table(plan)) return plan + + +def run_modelling( + epc: EpcPropertyData, + *, + goal_band: str = "C", + catalogue_path: Path = DEFAULT_CATALOGUE, + current_market_value: Optional[float] = None, + print_table: bool = True, +) -> Plan: + """Run ONLY the Modelling stage over ``epc`` with no database — skipping + Ingestion and Baseline. Modelling re-scores the EPC itself, so unlike + `run_one` this needs no lodged recorded-performance / RHI: it runs on any + EPC the calculator can score, which is what you want for inspecting + recommendations across an arbitrary EPC dump offline.""" + plan_repo = FakePlanRepository() + property_repo = FakePropertyRepo( + { + _PROPERTY_ID: Property( + identity=PropertyIdentity( + portfolio_id=_PORTFOLIO_ID, + postcode="A0 0AA", + address="1 Some Street", + uprn=12345, + ), + epc=epc, + current_market_value=current_market_value, + ) + }, + ) + unit = FakeUnitOfWork( + property=property_repo, + scenario=FakeScenarioRepository( + { + _SCENARIO_ID: Scenario( + id=_SCENARIO_ID, + goal="Increasing EPC", + goal_value=goal_band, + budget=None, + is_default=True, + ) + } + ), + product=ProductJsonRepository(catalogue_path), + plan=plan_repo, + ) + + ModellingOrchestrator( + unit_of_work=lambda: unit, + calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), + ).run( + property_ids=[_PROPERTY_ID], + scenario_ids=[_SCENARIO_ID], + portfolio_id=_PORTFOLIO_ID, + ) + + plan = plan_repo.saved[(_PROPERTY_ID, _SCENARIO_ID)] + if print_table: + print("\n" + format_plan_table(plan)) + return plan diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json index ab006317..f3cb49c2 100644 --- a/harness/sample_catalogue.json +++ b/harness/sample_catalogue.json @@ -1,5 +1,7 @@ { "cavity_wall_insulation": { "unit_cost_per_m2": 18.5 }, + "loft_insulation": { "unit_cost_per_m2": 12.0 }, "suspended_floor_insulation": { "unit_cost_per_m2": 25.0 }, + "solid_floor_insulation": { "unit_cost_per_m2": 45.0 }, "mechanical_ventilation": { "unit_cost_per_m2": 450.0 } } diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index d7b9675d..1de1a345 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -8,11 +8,22 @@ import pytest from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData -from harness.console import run_one +from harness.console import DEFAULT_CATALOGUE, run_modelling, run_one +from repositories.product.product_json_repository import ProductJsonRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc as _build_uninsulated_cavity_and_floor_epc, ) +# Every Measure Type the four 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", + "loft_insulation", + "suspended_floor_insulation", + "solid_floor_insulation", + "mechanical_ventilation", +) + def _uninsulated_lodged_epc() -> EpcPropertyData: epc = _build_uninsulated_cavity_and_floor_epc() @@ -42,6 +53,28 @@ def test_run_one_returns_a_plan_and_prints_the_table( 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_sample_catalogue_prices_every_generator_measure_type() -> None: + # Arrange — the default offline catalogue. + products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE) + + # Act / Assert — get() raises if a Measure Type is unpriced, so an offline + # run over arbitrary EPCs never dies on a missing catalogue entry. + for measure_type in _GENERATOR_MEASURE_TYPES: + products.get(measure_type) + + def test_run_one_threads_a_current_market_value_onto_the_plan() -> None: # Arrange epc: EpcPropertyData = _uninsulated_lodged_epc()