mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
test(modelling): run First Run with no database via in-memory fakes
Slice 1 of the DB-less inspection harness. Complete the in-memory FakeUnitOfWork so the ModellingOrchestrator runs with no Postgres: add FakeScenarioRepository + FakePlanRepository (idempotent, keyed by (property_id, scenario_id)), expose scenario/product/plan on the fake unit, and grow FakePropertyRepo to compose the effective EPC from the EPC repo at read time — mirroring PropertyPostgresRepository, so the EPC Ingestion persists is visible to Baseline + Modelling (the through-repos hand-off, in memory). The new integration test drives the full AraFirstRunPipeline (Ingestion -> Baseline -> Modelling) against the FakeUnitOfWork — no Session ever opened — on the uninsulated 000490 fixture with its lodged recorded-performance filled in (it already carries the RHI block, so Baseline can run) and asserts a multi-measure Plan is produced. The committed product catalogue prices the wall/floor/ventilation measures it fires. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b8b7e02034
commit
d5f1fc335b
3 changed files with 237 additions and 3 deletions
|
|
@ -10,25 +10,51 @@ from types import TracebackType
|
|||
from typing import Any, Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.modelling.plan import Plan
|
||||
from domain.modelling.scenario import Scenario
|
||||
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
|
||||
from domain.property.properties import Properties
|
||||
from domain.property.property import Property
|
||||
from repositories.plan.plan_repository import PlanRepository
|
||||
from repositories.product.product_repository import ProductRepository
|
||||
from repositories.property_baseline.property_baseline_repository import PropertyBaselineRepository
|
||||
from repositories.epc.epc_repository import EpcRepository
|
||||
from repositories.property.property_repository import PropertyRepository
|
||||
from repositories.scenario.scenario_repository import ScenarioRepository
|
||||
from repositories.solar.solar_repository import SolarRepository
|
||||
from repositories.unit_of_work import UnitOfWork
|
||||
from domain.modelling.product import Product
|
||||
|
||||
|
||||
class FakePropertyRepo(PropertyRepository):
|
||||
def __init__(self, by_id: dict[int, Property]) -> None:
|
||||
"""Holds Properties by id. When an ``epc_repo`` is supplied it composes the
|
||||
effective EPC from it at read time — mirroring `PropertyPostgresRepository`,
|
||||
so an EPC that Ingestion persists becomes visible on the Property that
|
||||
Baseline / Modelling read back (the through-repos hand-off, in memory)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
by_id: dict[int, Property],
|
||||
epc_repo: Optional["FakeEpcRepo"] = None,
|
||||
) -> None:
|
||||
self._by_id = by_id
|
||||
self._epc_repo = epc_repo
|
||||
|
||||
def _hydrate(self, property_id: int) -> Property:
|
||||
prop = self._by_id[property_id]
|
||||
if self._epc_repo is None:
|
||||
return prop
|
||||
return Property(
|
||||
identity=prop.identity,
|
||||
epc=self._epc_repo.get_for_property(property_id),
|
||||
site_notes=prop.site_notes,
|
||||
)
|
||||
|
||||
def get(self, property_id: int) -> Property:
|
||||
return self._by_id[property_id]
|
||||
return self._hydrate(property_id)
|
||||
|
||||
def get_many(self, property_ids: list[int]) -> Properties:
|
||||
return Properties([self._by_id[property_id] for property_id in property_ids])
|
||||
return Properties([self._hydrate(property_id) for property_id in property_ids])
|
||||
|
||||
|
||||
class FakeEpcRepo(EpcRepository):
|
||||
|
|
@ -88,6 +114,52 @@ class FakePropertyBaselineRepo(PropertyBaselineRepository):
|
|||
raise NotImplementedError
|
||||
|
||||
|
||||
class FakeScenarioRepository(ScenarioRepository):
|
||||
def __init__(self, by_id: Optional[dict[int, Scenario]] = None) -> None:
|
||||
self._by_id = by_id or {}
|
||||
|
||||
def get_many(self, scenario_ids: list[int]) -> list[Scenario]:
|
||||
missing = [sid for sid in scenario_ids if sid not in self._by_id]
|
||||
if missing:
|
||||
raise ValueError(f"no scenario for ids {missing}")
|
||||
return [self._by_id[sid] for sid in scenario_ids]
|
||||
|
||||
|
||||
class FakePlanRepository(PlanRepository):
|
||||
"""Idempotent in-memory Plan store keyed by ``(property_id, scenario_id)`` —
|
||||
a re-run replaces rather than duplicates (ADR-0017). ``saved`` is the store
|
||||
a test (or the console harness) reads the Plan back from."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.saved: dict[tuple[int, int], Plan] = {}
|
||||
self._next_id = 1
|
||||
|
||||
def save(
|
||||
self,
|
||||
plan: Plan,
|
||||
*,
|
||||
property_id: int,
|
||||
scenario_id: int,
|
||||
portfolio_id: int,
|
||||
is_default: bool,
|
||||
) -> int:
|
||||
self.saved[(property_id, scenario_id)] = plan
|
||||
plan_id = self._next_id
|
||||
self._next_id += 1
|
||||
return plan_id
|
||||
|
||||
|
||||
class _UnsetProductRepo(ProductRepository):
|
||||
"""Default for a `FakeUnitOfWork` built without a catalogue — raises if a
|
||||
generator actually reaches for a Product, so the omission is loud."""
|
||||
|
||||
def get(self, measure_type: str) -> Product: # pragma: no cover
|
||||
raise ValueError(
|
||||
f"no product catalogue wired into this FakeUnitOfWork "
|
||||
f"(asked for {measure_type!r})"
|
||||
)
|
||||
|
||||
|
||||
class FakeUnitOfWork(UnitOfWork):
|
||||
"""A unit that holds in-memory repos and counts commits."""
|
||||
|
||||
|
|
@ -98,11 +170,17 @@ class FakeUnitOfWork(UnitOfWork):
|
|||
epc: Optional[FakeEpcRepo] = None,
|
||||
solar: Optional[FakeSolarRepo] = None,
|
||||
property_baseline: Optional[FakePropertyBaselineRepo] = None,
|
||||
scenario: Optional[FakeScenarioRepository] = None,
|
||||
product: Optional[ProductRepository] = None,
|
||||
plan: Optional[FakePlanRepository] = None,
|
||||
) -> None:
|
||||
self.property = property
|
||||
self.epc = epc or FakeEpcRepo()
|
||||
self.solar = solar or FakeSolarRepo()
|
||||
self.property_baseline = property_baseline or FakePropertyBaselineRepo()
|
||||
self.scenario = scenario or FakeScenarioRepository()
|
||||
self.product = product or _UnsetProductRepo()
|
||||
self.plan = plan or FakePlanRepository()
|
||||
self.commits = 0
|
||||
|
||||
def __enter__(self) -> "FakeUnitOfWork":
|
||||
|
|
|
|||
5
tests/orchestration/fixtures/product_catalogue.json
Normal file
5
tests/orchestration/fixtures/product_catalogue.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"cavity_wall_insulation": { "unit_cost_per_m2": 18.5 },
|
||||
"suspended_floor_insulation": { "unit_cost_per_m2": 25.0 },
|
||||
"mechanical_ventilation": { "unit_cost_per_m2": 450.0 }
|
||||
}
|
||||
151
tests/orchestration/test_first_run_without_database.py
Normal file
151
tests/orchestration/test_first_run_without_database.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""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 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)]
|
||||
assert len(plan.measures) >= 1
|
||||
assert plan.post_sap_continuous >= plan.baseline.sap_continuous
|
||||
Loading…
Add table
Reference in a new issue