Model/harness/console.py
Khalim Conn-Kowlessar 31ced27162 feat(modelling): surface the full candidate measure menu with per-measure cost
The run only showed the measures the Optimiser selected, so a candidate it
passed over (e.g. an ASHP it found too costly for the target band) and that
measure's cost were invisible.

Add `harness.console.candidate_recommendations` — every Generator Option
with its per-Option cost, before optimisation — and have run_modelling_e2e
print the full menu per property (flagging the selected Options), write a
"cost per measure" section into the markdown, and emit a per-Option
modelling_e2e_candidates.csv.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:03:26 +00:00

282 lines
10 KiB
Python

"""Run one property through the full First Run pipeline with no database.
The interactive inspection entrypoint: hand it an `EpcPropertyData` (e.g.
`EpcPropertyDataMapper.from_api_response(json)`), and it wires the whole
`AraFirstRunPipeline` (Ingestion -> Baseline -> Modelling) against in-memory
fakes — no Postgres, no network — runs it, prints the sense-check table, and
returns the `Plan` for further poking.
Dev tooling, not deployed: it reuses the in-memory test fakes, so run it from a
REPL at the worktree root::
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from harness.console import run_one
plan = run_one(EpcPropertyDataMapper.from_api_response(my_api_json), goal_band="C")
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.geospatial.coordinates import Coordinates
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.measure_type import MeasureType
from domain.modelling.plan import Plan
from domain.modelling.recommendation import Recommendation
from domain.modelling.scenario import Scenario
from domain.modelling.solar_potential import SolarPotential
from domain.property.property import Property, PropertyIdentity
from domain.property_baseline.rebaseliner import StubRebaseliner
from domain.sap10_calculator.calculator import Sap10Calculator
from harness.plan_table import format_plan_table
from orchestration.ara_first_run_pipeline import AraFirstRunPipeline
from orchestration.ingestion_orchestrator import IngestionOrchestrator
from orchestration.modelling_orchestrator import (
ModellingOrchestrator,
_candidate_recommendations, # pyright: ignore[reportPrivateUsage]
)
from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator
from repositories.fuel_rates.fuel_rates_static_file_repository import (
FuelRatesStaticFileRepository,
)
from repositories.geospatial.geospatial_repository import GeospatialRepository
from repositories.product.product_json_repository import ProductJsonRepository
from repositories.product.product_repository import ProductRepository
from tests.orchestration.fakes import (
FakeEpcRepo,
FakePlanRepository,
FakePropertyRepo,
FakeScenarioRepository,
FakeSolarRepo,
FakeUnitOfWork,
)
DEFAULT_CATALOGUE = Path(__file__).resolve().parent / "sample_catalogue.json"
_PROPERTY_ID = 1
_SCENARIO_ID = 7
_PORTFOLIO_ID = 1
_UPRN = 12345
@dataclass
class _Command:
portfolio_id: int
property_ids: list[int]
scenario_ids: list[int]
class _FetcherReturning:
def __init__(self, epc: EpcPropertyData) -> None:
self._epc = epc
def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]:
return self._epc
class _NoCoordinates(GeospatialRepository):
def coordinates_for(self, uprn: int) -> Optional[Coordinates]:
return None # skip the solar leg
class _UnusedSolarFetcher:
def get_building_insights(
self, longitude: float, latitude: float
) -> dict[str, Any]: # pragma: no cover
return {}
def run_one(
epc: EpcPropertyData,
*,
goal_band: str = "C",
catalogue_path: Path = DEFAULT_CATALOGUE,
current_market_value: Optional[float] = None,
print_table: bool = True,
) -> Plan:
"""Run ``epc`` through the full First Run pipeline with no database and
return its Plan for the default Increasing-EPC Scenario targeting
``goal_band``. Prints the sense-check table unless ``print_table`` is False.
Pass ``current_market_value`` (a Property Valuation) to value the Plan's
Valuation Uplift in £ — otherwise the uplift is percentage-only (ADR-0018).
``epc`` must carry lodged recorded-performance + the RHI block (a real lodged
EPC does) so the Baseline stage can run."""
epc_repo = FakeEpcRepo()
plan_repo = FakePlanRepository()
property_repo = FakePropertyRepo(
{
_PROPERTY_ID: Property(
identity=PropertyIdentity(
portfolio_id=_PORTFOLIO_ID,
postcode="A0 0AA",
address="1 Some Street",
uprn=_UPRN,
),
current_market_value=current_market_value,
)
},
epc_repo=epc_repo,
)
unit = FakeUnitOfWork(
property=property_repo,
epc=epc_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,
)
pipeline = AraFirstRunPipeline(
ingestion=IngestionOrchestrator(
unit_of_work=lambda: unit,
epc_fetcher=_FetcherReturning(epc),
geospatial_repo=_NoCoordinates(),
solar_fetcher=_UnusedSolarFetcher(),
),
baseline=PropertyBaselineOrchestrator(
unit_of_work=lambda: unit,
rebaseliner=StubRebaseliner(),
fuel_rates=FuelRatesStaticFileRepository(),
),
modelling=ModellingOrchestrator(
unit_of_work=lambda: unit,
calculator=Sap10Calculator(),
fuel_rates=FuelRatesStaticFileRepository(),
),
)
pipeline.run(
_Command(
portfolio_id=_PORTFOLIO_ID,
property_ids=[_PROPERTY_ID],
scenario_ids=[_SCENARIO_ID],
)
)
plan = plan_repo.saved[(_PROPERTY_ID, _SCENARIO_ID)]
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,
planning_restrictions: PlanningRestrictions = PlanningRestrictions(),
solar_insights: Optional[dict[str, Any]] = None,
considered_measures: Optional[frozenset[MeasureType]] = None,
products: Optional[ProductRepository] = None,
scenario: Optional[Scenario] = 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.
``solar_insights`` is the Property's raw Google Solar ``buildingInsights``
JSON (as persisted by ``SolarRepository``); when given, the solar
Recommendation Generator sees the dwelling's potential and can offer Solar
PV Options (ADR-0026).
``products`` overrides the Product catalogue source (default: the JSON
sample catalogue) — pass a read-only ``ProductPostgresRepository`` to price
against the live ``material`` table. ``scenario`` overrides the default
Increasing-EPC-to-``goal_band`` Scenario — pass a Scenario read from the DB
so the run targets a real ``scenario_id`` (its ``goal_value``/budget drive
the Optimiser); the computed Plan is then keyed by that Scenario's id."""
scenario_obj = scenario or Scenario(
id=_SCENARIO_ID,
goal="Increasing EPC",
goal_value=goal_band,
budget=None,
is_default=True,
)
scenario_id = scenario_obj.id
plan_repo = FakePlanRepository()
property_repo = FakePropertyRepo(
{
_PROPERTY_ID: Property(
identity=PropertyIdentity(
portfolio_id=_PORTFOLIO_ID,
postcode="A0 0AA",
address="1 Some Street",
uprn=_UPRN,
),
epc=epc,
current_market_value=current_market_value,
planning_restrictions=planning_restrictions,
)
},
)
unit = FakeUnitOfWork(
property=property_repo,
solar=FakeSolarRepo(
by_uprn={_UPRN: solar_insights}
if solar_insights is not None
else None
),
scenario=FakeScenarioRepository({scenario_id: scenario_obj}),
product=products or 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,
considered_measures=considered_measures,
)
plan = plan_repo.saved[(_PROPERTY_ID, scenario_id)]
if print_table:
print("\n" + format_plan_table(plan))
return plan
def candidate_recommendations(
epc: EpcPropertyData,
*,
catalogue_path: Path = DEFAULT_CATALOGUE,
planning_restrictions: PlanningRestrictions = PlanningRestrictions(),
solar_insights: Optional[dict[str, Any]] = None,
considered_measures: Optional[frozenset[MeasureType]] = None,
products: Optional[ProductRepository] = None,
) -> list[Recommendation]:
"""Every candidate Recommendation the Generators produce for ``epc`` — the
full menu of Measure Options with their per-Option cost, *before* the
Optimiser selects a Plan. Use this to inspect measures (and their cost) that
a Plan does not end up selecting, e.g. an ASHP the Optimiser passed over for
a cheaper route to the target band. Inputs mirror `run_modelling`."""
solar_potential: Optional[SolarPotential] = (
SolarPotential.from_building_insights(solar_insights)
if solar_insights is not None and "solarPotential" in solar_insights
else None
)
return _candidate_recommendations(
epc,
products or ProductJsonRepository(catalogue_path),
planning_restrictions,
solar_potential,
considered_measures,
)