feat(modelling): robust offline modelling inspection (run_modelling)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 09:19:18 +00:00
parent b3f4609c2d
commit 98f5ee4fca
3 changed files with 97 additions and 1 deletions

View file

@ -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

View file

@ -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 }
}

View file

@ -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()