Model/tests/orchestration/test_first_run_without_database.py
Khalim Conn-Kowlessar 9329978374 feat(modelling): sense-check table for a Plan in the DB-less harness
Slice 2. `harness.plan_table.format_plan_table(plan)` renders a Plan as a
plain-text table — one package summary line (baseline SAP/band -> post
SAP/band, CO2 saved, cost of works + contingency, bill saved) and one
line per Plan Measure (signed SAP points, cost, delivered kWh + £
savings). Pure presentation: reads the Plan, computes nothing. The
DB-less First Run test now prints it (visible under `pytest -s`) so the
modelled package can be eyeballed and debugged by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:06:53 +00:00

153 lines
5.5 KiB
Python

"""First Run end-to-end with NO database — in-memory fakes only.
The same `AraFirstRunPipeline` the Postgres integration test drives, but wired
against a `FakeUnitOfWork` instead of a `PostgresUnitOfWork`: Ingestion ->
Baseline -> Modelling run start-to-finish, hand off through in-memory repos, and
produce an inspectable multi-measure Plan without a `Session` ever being opened.
This is the harness the owner runs to sense-check recommendations interactively.
"""
from __future__ import annotations
import dataclasses
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.modelling.scenario import Scenario
from domain.property.property import Property, PropertyIdentity
from domain.property_baseline.rebaseliner import StubRebaseliner
from domain.sap10_calculator.calculator import Sap10Calculator
from orchestration.ara_first_run_pipeline import AraFirstRunPipeline
from orchestration.ingestion_orchestrator import IngestionOrchestrator
from orchestration.modelling_orchestrator import ModellingOrchestrator
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 harness.plan_table import format_plan_table
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,
)
from tests.orchestration.fakes import (
FakeEpcRepo,
FakePlanRepository,
FakePropertyRepo,
FakeScenarioRepository,
FakeUnitOfWork,
)
_CATALOGUE = Path(__file__).resolve().parent / "fixtures/product_catalogue.json"
@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): # type: ignore[no-untyped-def]
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 _uninsulated_lodged_epc() -> EpcPropertyData:
# 000490: an uninsulated cavity wall + suspended floor (loft already 300mm),
# so the wall + floor Generators fire and the ventilation Dependency follows.
# The calculator fixture carries no lodged recorded-performance, so we fill it
# in (as a real lodged EPC would) — it already carries the RHI block — so the
# Baseline stage can run inside the full pipeline.
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_first_run_produces_a_multi_measure_plan_without_a_database() -> None:
# Arrange — an in-memory Property (no EPC yet; Ingestion supplies it), a
# default Increasing-EPC Scenario, and a file-backed product catalogue.
epc_repo = FakeEpcRepo()
plan_repo = FakePlanRepository()
property_repo = FakePropertyRepo(
{
10: Property(
identity=PropertyIdentity(
portfolio_id=1,
postcode="A0 0AA",
address="1 Some Street",
uprn=12345,
)
)
},
epc_repo=epc_repo,
)
unit: FakeUnitOfWork = FakeUnitOfWork(
property=property_repo,
epc=epc_repo,
scenario=FakeScenarioRepository(
{
7: Scenario(
id=7,
goal="Increasing EPC",
goal_value="C",
budget=None,
is_default=True,
)
}
),
product=ProductJsonRepository(_CATALOGUE),
plan=plan_repo,
)
pipeline = AraFirstRunPipeline(
ingestion=IngestionOrchestrator(
unit_of_work=lambda: unit,
epc_fetcher=_FetcherReturning(_uninsulated_lodged_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(),
),
)
# Act — the whole First Run, no Session ever opened.
pipeline.run(_Command(portfolio_id=1, property_ids=[10], scenario_ids=[7]))
# Assert — a Plan was persisted in memory for (property 10, scenario 7),
# with at least one Plan Measure and a post-retrofit SAP no worse than baseline.
plan = plan_repo.saved[(10, 7)]
print("\n" + format_plan_table(plan)) # visible under `pytest -s` for sense-checking
assert len(plan.measures) >= 1
assert plan.post_sap_continuous >= plan.baseline.sap_continuous