feat(baseline): BaselineOrchestrator + BaselinePerformance aggregate (#1135)

Stage 2 of First Run. Establishes each Property's Baseline Performance
from persisted source data and writes it back — reads only from repos,
never a Fetcher or HTTP (ADR-0003), so it is byte-identical whether
Ingestion ran milliseconds ago or last week.

Domain (`domain/baseline/`):
- `Performance` VO — the four rated quantities: SAP / EPC Band / CO2 /
  Primary Energy Intensity. `lodged_performance(epc)` reads them off the
  EPC's recorded fields (PEUI = `energy_consumption_current`).
- `BaselinePerformance` (ADR-0004) — the paired `lodged` + `effective`
  Performance + `rebaseline_reason`, plus the no-derivation part of the
  energy block (`space_heating_kwh` / `water_heating_kwh`, off the RHI,
  deterministic per ADR-0006). Both halves always populated.
- `Rebaseliner` port + `StubRebaseliner`: the re-score-on-override seam
  (ADR-0011). SAP10 certs pass through (effective == lodged, reason
  "none"); a pre-SAP10 cert raises `RebaselineNotImplemented` rather
  than fabricating a plausible-but-wrong "none" — ML rebaselining is not
  wired yet. Mirrors the repo's strict-raise culture.

Persistence: new `BaselineRepository` port + `BaselinePostgresRepository`
+ flat-column `baseline_performance` SQLModel (one row per Property). Per
ADR-0004's amendment this is a standalone table, NOT columns on the
retiring `property_details_epc`. Production migration is FE-owned
(Drizzle) — docs/migrations/baseline-performance-table.md.

Docs (grill-with-docs): corrected CONTEXT.md Lodged/Effective Performance
to Primary Energy Intensity (the term collided with its own _Avoid_ entry
under "heat demand") + fixed stale RHI field names; amended ADR-0004
Consequences for the standalone-table decision.

Fuel split + bills (rest of EPC Energy Derivation) deferred to a
follow-up — they need a Fuel Rates source (Ofgem-cap ETL) that does not
exist yet.

TDD, one test -> one impl: 7 tests (lodged read, rebaseliner pass-through
+ raise, orchestrator establish-and-persist + pre-SAP10 raise, Postgres
round-trip + absent). pyright strict clean; AAA layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 21:21:34 +00:00
parent 75fbba60fc
commit 76717dfc3a
18 changed files with 660 additions and 4 deletions

View file

@ -90,11 +90,11 @@ A Property's current performance aggregate, holding both Lodged Performance and
_Avoid_: baseline predictions, predicted baseline, rebaselined values
**Lodged Performance**:
The SAP / EPC Band / carbon emissions / heat demand recorded on the public EPC (or the Site Notes' as-surveyed values when Site Notes are the source) — unmodified by modelling. The half of Baseline Performance that says "what the government register says about this Property".
The SAP / EPC Band / carbon emissions / Primary Energy Intensity recorded on the public EPC (or the Site Notes' as-surveyed values when Site Notes are the source) — unmodified by modelling. The half of Baseline Performance that says "what the government register says about this Property".
_Avoid_: original performance, raw EPC values, recorded baseline
**Effective Performance**:
The SAP / EPC Band / carbon emissions / heat demand the modelling pipeline actually scored against — equal to Lodged Performance when no Rebaselining trigger fires, replaced by ML output when triggered. The half of Baseline Performance that says "what we modelled".
The SAP / EPC Band / carbon emissions / Primary Energy Intensity the modelling pipeline actually scored against — equal to Lodged Performance when no Rebaselining trigger fires, replaced by ML output when triggered. The half of Baseline Performance that says "what we modelled".
_Avoid_: modelled performance, rebaselined performance (only correct when rebaselining ran), scored values
**Calculated SAP10 Performance**:
@ -118,7 +118,7 @@ The process that translates an Optimised Package into cert-field changes and pro
_Avoid_: measure overrides (rejected during ADR-0009 grill — phantom mid-layer), package applier, retrofit simulator
**EPC Energy Derivation**:
The process that derives a Property's fuel split and annual bills from its space heating kWh and hot water kWh values plus the heating fuel deduced from SAP fields. kWh values themselves come from the EPC's recorded fields (`renewable_heat_incentive.space_heating_existing_dwelling` and `.water_heating`) for SAP10 baselines, or from ML prediction when Rebaselining fires or when scoring a post-measure state. Bills are computed deterministically from delivered kWh × current Fuel Rates + standing charges + SEG credits. The UCL Correction is no longer applied at runtime — it is folded into ML training labels (see [[epc-ml-transform]] and ADR-0007).
The process that derives a Property's fuel split and annual bills from its space heating kWh and hot water kWh values plus the heating fuel deduced from SAP fields. kWh values themselves come from the EPC's recorded fields (`renewable_heat_incentive.space_heating_kwh` and `.water_heating_kwh`) for SAP10 baselines, or from ML prediction when Rebaselining fires or when scoring a post-measure state. Bills are computed deterministically from delivered kWh × current Fuel Rates + standing charges + SEG credits. The UCL Correction is no longer applied at runtime — it is folded into ML training labels (see [[epc-ml-transform]] and ADR-0007).
_Avoid_: kWh prediction (kWh is now an ML target — see Rebaselining), baseline kWh, energy estimation
**UCL Correction**:

View file

@ -8,6 +8,34 @@ The cost is a wider row + the discipline that **every** `BaselinePerformance` po
## Consequences
- Schema migration: `property_details_epc` (or its successor) carries 8 fields instead of 4 for the SAP-equivalent block.
- Reversing this means rewriting every consumer that has learned to read both values. Hard to roll back once the FE depends on the pair.
- The rebaseline trigger has two reasons (`pre_sap10`, `physical_state_changed`, or `both`) — store the reason alongside so we know *why* a property was rebaselined when debugging.
### Amendment (2026-05-30, #1135): standalone `baseline_performance` table
The original consequence read *"`property_details_epc` (or its successor) carries 8 fields
instead of 4 for the SAP-equivalent block"* — i.e. the pair as columns on the EPC-details table.
That is superseded. `property_details_epc` is being **retired**: it is too tightly coupled to the
schema of the legacy EPC API, which the Ara rebuild is moving off. So the pair has no home there.
`BaselinePerformance` instead persists as its **own standalone `baseline_performance` table, one
row per Property**, behind a dedicated `BaselineRepository` port (`save` / `get_for_property`),
mirroring the EPC slice's repo shape. This is the cleaner model regardless of the retirement:
`BaselinePerformance` is its own aggregate (a Property's current performance), not a detail of any
single EPC.
The row is **flat typed columns**, not a JSONB blob, because the FE both surfaces the block and
queries the lodged-vs-effective pair: `lodged_{sap_score, epc_band, co2_emissions,
primary_energy_intensity}`, the four `effective_*` mirrors, `rebaseline_reason`, and (for the part
of the energy block that needs no derivation) `space_heating_kwh` / `water_heating_kwh`. The
fourth paired quantity is **Primary Energy Intensity**, not "heat demand" — see CONTEXT.md
(the prose above predates that term being sharpened).
Fuel split and bills — the rest of the EPC Energy Derivation block — are **deferred to a
follow-up**: bills require a current Fuel Rates source (Ofgem-cap ETL) that does not yet exist, and
fuel split is produced by the same `EpcEnergyDerivationService`, so the two land together rather
than churning the table twice.
The SQLModel row is defined in `infrastructure/postgres/` so the ephemeral-Postgres tests build it
via `create_all`; the production migration is FE-owned (Drizzle ORM) and tracked in
`docs/migrations/`.

View file

@ -0,0 +1,43 @@
# `baseline_performance` table — FE-owned migration
**Context:** Slice 6 (Hestia-Homes/Model#1135) of the `ara_first_run` rebuild. The
`BaselineOrchestrator` establishes a Property's **Baseline Performance** (ADR-0004) and persists it
via a new `BaselineRepository` port. This is a brand-new table — no predecessor.
Per ADR-0004's amendment, the lodged/effective pair does **not** land on `property_details_epc`
(which is being retired as too coupled to the legacy EPC-API schema). It lands here, as its own
aggregate's table.
The SQLModel row is defined in `infrastructure/postgres/` so the ephemeral-Postgres tests build it
via `SQLModel.metadata.create_all`. The **production migration is FE-owned (Drizzle ORM)** — a
straight lift-and-shift of the columns below.
## `baseline_performance` — one row per Property
| Column | Type | Notes |
|---|---|---|
| `id` | serial PK | |
| `property_id` | int, FK → `property.id`, **unique** | one Baseline Performance per Property |
| `lodged_sap_score` | int | Lodged Performance — gov register, off the Effective EPC |
| `lodged_epc_band` | text | the `Epc` enum, stored as its string value (e.g. `"C"`) |
| `lodged_co2_emissions` | float | |
| `lodged_primary_energy_intensity` | int | PEUI (kWh/m²/yr); **not** "heat demand" — see CONTEXT.md |
| `effective_sap_score` | int | Effective Performance — what modelling scored against |
| `effective_epc_band` | text | |
| `effective_co2_emissions` | float | |
| `effective_primary_energy_intensity` | int | |
| `rebaseline_reason` | text | `none` \| `pre_sap10` \| `physical_state_changed` \| `both` |
| `space_heating_kwh` | float | off `renewable_heat_incentive`; deterministic (ADR-0006) |
| `water_heating_kwh` | float | off `renewable_heat_incentive` |
This slice has no ML rebaselining, so `effective_* == lodged_*` and `rebaseline_reason = 'none'`
for every row written (a pre-SAP10 cert raises rather than persisting a wrong-but-plausible row —
see #1135). The `effective_*` columns exist now so the table shape is stable when ML lands.
## Deferred (follow-up — EPC Energy Derivation + Fuel Rates)
`fuel_split` and `bills` are **not** in this table yet. They are produced by
`EpcEnergyDerivationService`, which needs a current **Fuel Rates** source (Ofgem-cap ETL) that does
not exist yet. They land together in the follow-up so this table is not migrated twice. Likely
shape: a `bills`-style block (per-fuel kWh + standing charge + SEG) — to be specified in that
slice's migration note.

View file

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from dataclasses import dataclass
from domain.baseline.performance import Performance
from domain.baseline.rebaseliner import RebaselineReason
@dataclass(frozen=True)
class BaselinePerformance:
"""A Property's current performance aggregate (CONTEXT.md, ADR-0004).
Holds both halves ``lodged`` (what the gov register says) and
``effective`` (what the modelling pipeline scored against) plus the
``rebaseline_reason`` recording *why* they differ (``"none"`` when equal).
Both halves are always populated, even when equal.
Carries the part of the energy block that needs no derivation: annual
``space_heating_kwh`` / ``water_heating_kwh`` read off the EPC's RHI.
Fuel split and bills (the rest of EPC Energy Derivation) land in a
follow-up once a Fuel Rates source exists.
"""
lodged: Performance
effective: Performance
rebaseline_reason: RebaselineReason
space_heating_kwh: float
water_heating_kwh: float

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, TypeVar
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import EpcPropertyData
_T = TypeVar("_T")
@dataclass(frozen=True)
class Performance:
"""One half of a Baseline Performance — a single set of SAP10 figures.
The four quantities a Property is rated on (CONTEXT.md: Lodged / Effective
Performance): SAP score, EPC Band, carbon emissions, and Primary Energy
Intensity. Used for both the Lodged half (off the gov register) and the
Effective half (what the modelling pipeline scored against).
"""
sap_score: int
epc_band: Epc
co2_emissions: float
primary_energy_intensity: int
def _require(value: Optional[_T], field: str) -> _T:
if value is None:
raise ValueError(
f"EPC is missing recorded performance field {field!r}; "
"cannot establish Lodged Performance"
)
return value
def lodged_performance(epc: EpcPropertyData) -> Performance:
"""The Lodged Performance recorded on an EPC — what the gov register says.
Reads the four rated quantities straight off the EPC's recorded fields
(CONTEXT.md: Primary Energy Intensity is recorded as `energy_consumption_current`).
Unmodified by modelling.
"""
return Performance(
sap_score=_require(epc.energy_rating_current, "energy_rating_current"),
epc_band=_require(
epc.current_energy_efficiency_band, "current_energy_efficiency_band"
),
co2_emissions=_require(epc.co2_emissions_current, "co2_emissions_current"),
primary_energy_intensity=_require(
epc.energy_consumption_current, "energy_consumption_current"
),
)

View file

@ -0,0 +1,60 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Literal
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.baseline.performance import Performance
RebaselineReason = Literal["none", "pre_sap10", "physical_state_changed", "both"]
# The SAP spec version below which a cert's recorded scores reflect a superseded
# methodology and must be ML-rebaselined (CONTEXT.md: Rebaselining).
_SAP10_FLOOR = 10.0
class RebaselineNotImplemented(Exception):
"""A Property needs Rebaselining, but the ML adapter is not wired yet.
Raised rather than silently recording ``reason="none"`` for a property that
genuinely needs rebaselining a plausible-but-wrong baseline is expensive to
discover downstream. Surfaces how much of a First Run cohort the pipeline can
handle today (#1135).
"""
class Rebaseliner(ABC):
"""Produces a Property's Effective Performance from its Effective EPC.
Rebaselining (CONTEXT.md) re-predicts the rated quantities via ML when the
EPC was lodged pre-SAP10 or its physical state diverged from the lodged EPC;
otherwise Effective Performance equals Lodged. Injected into the
BaselineOrchestrator (ADR-0011) so the ML adapter can swap in without
touching the orchestrator, and so the single-property re-score-on-override
flow reuses the same port.
"""
@abstractmethod
def rebaseline(
self, effective_epc: EpcPropertyData, lodged: Performance
) -> tuple[Performance, RebaselineReason]: ...
class StubRebaseliner(Rebaseliner):
"""The no-ML stub for the validation phase.
SAP10 certs pass through untouched Effective Performance equals Lodged,
reason ``"none"``. A pre-SAP10 cert genuinely needs ML rebaselining, which is
not implemented yet (#1135), so it raises rather than fabricating a "none".
"""
def rebaseline(
self, effective_epc: EpcPropertyData, lodged: Performance
) -> tuple[Performance, RebaselineReason]:
sap_version = effective_epc.sap_version
if sap_version is not None and sap_version < _SAP10_FLOOR:
raise RebaselineNotImplemented(
f"Property needs rebaselining (pre-SAP10 cert, sap_version="
f"{sap_version}); ML rebaselining is not implemented yet"
)
return lodged, "none"

View file

@ -0,0 +1,77 @@
from __future__ import annotations
from typing import ClassVar, Optional, cast
from sqlmodel import Field, SQLModel
from datatypes.epc.domain.epc import Epc
from domain.baseline.baseline_performance import BaselinePerformance
from domain.baseline.performance import Performance
from domain.baseline.rebaseliner import RebaselineReason
class BaselinePerformanceModel(SQLModel, table=True):
"""The ``baseline_performance`` row — one per Property (ADR-0004).
Flat typed columns (not a JSONB blob) so the FE can both surface the block
and query the lodged-vs-effective pair. The production migration is FE-owned
(Drizzle); see docs/migrations/baseline-performance-table.md.
"""
__tablename__: ClassVar[str] = "baseline_performance" # pyright: ignore[reportIncompatibleVariableOverride]
id: Optional[int] = Field(default=None, primary_key=True)
property_id: int = Field(unique=True, index=True)
lodged_sap_score: int
lodged_epc_band: str
lodged_co2_emissions: float
lodged_primary_energy_intensity: int
effective_sap_score: int
effective_epc_band: str
effective_co2_emissions: float
effective_primary_energy_intensity: int
rebaseline_reason: str
space_heating_kwh: float
water_heating_kwh: float
@classmethod
def from_domain(
cls, baseline: BaselinePerformance, property_id: int
) -> "BaselinePerformanceModel":
return cls(
property_id=property_id,
lodged_sap_score=baseline.lodged.sap_score,
lodged_epc_band=baseline.lodged.epc_band.value,
lodged_co2_emissions=baseline.lodged.co2_emissions,
lodged_primary_energy_intensity=baseline.lodged.primary_energy_intensity,
effective_sap_score=baseline.effective.sap_score,
effective_epc_band=baseline.effective.epc_band.value,
effective_co2_emissions=baseline.effective.co2_emissions,
effective_primary_energy_intensity=baseline.effective.primary_energy_intensity,
rebaseline_reason=baseline.rebaseline_reason,
space_heating_kwh=baseline.space_heating_kwh,
water_heating_kwh=baseline.water_heating_kwh,
)
def to_domain(self) -> BaselinePerformance:
return BaselinePerformance(
lodged=Performance(
sap_score=self.lodged_sap_score,
epc_band=Epc(self.lodged_epc_band),
co2_emissions=self.lodged_co2_emissions,
primary_energy_intensity=self.lodged_primary_energy_intensity,
),
effective=Performance(
sap_score=self.effective_sap_score,
epc_band=Epc(self.effective_epc_band),
co2_emissions=self.effective_co2_emissions,
primary_energy_intensity=self.effective_primary_energy_intensity,
),
rebaseline_reason=cast(RebaselineReason, self.rebaseline_reason),
space_heating_kwh=self.space_heating_kwh,
water_heating_kwh=self.water_heating_kwh,
)

View file

@ -0,0 +1,63 @@
from __future__ import annotations
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
RenewableHeatIncentive,
)
from domain.baseline.baseline_performance import BaselinePerformance
from domain.baseline.performance import lodged_performance
from domain.baseline.rebaseliner import Rebaseliner
from repositories.baseline.baseline_repository import BaselineRepository
from repositories.property.property_repository import PropertyRepository
class BaselineOrchestrator:
"""Stage 2: establish each Property's Baseline Performance and persist it.
For each property: hydrate the Property aggregate via PropertyRepo, resolve
its Effective EPC, read Lodged Performance off it, run the Rebaseliner to
produce Effective Performance (equal to Lodged unless a trigger fires), and
persist the pair plus the deterministic kWh.
Reads only from repos never a Fetcher or HTTP (ADR-0003). That is what
makes it byte-identical whether Ingestion ran milliseconds ago (First Run)
or last week (single-property review). The injected Rebaseliner is the
re-score-on-override seam: the future single-property flow re-runs the same
step after a Landlord Override changes the Effective EPC (ADR-0011).
"""
def __init__(
self,
*,
property_repo: PropertyRepository,
rebaseliner: Rebaseliner,
baseline_repo: BaselineRepository,
) -> None:
self._property_repo = property_repo
self._rebaseliner = rebaseliner
self._baseline_repo = baseline_repo
def run(self, property_ids: list[int]) -> None:
for property_id in property_ids:
effective_epc = self._property_repo.get(property_id).effective_epc
lodged = lodged_performance(effective_epc)
effective, reason = self._rebaseliner.rebaseline(effective_epc, lodged)
rhi = _require_rhi(effective_epc)
baseline = BaselinePerformance(
lodged=lodged,
effective=effective,
rebaseline_reason=reason,
space_heating_kwh=rhi.space_heating_kwh,
water_heating_kwh=rhi.water_heating_kwh,
)
self._baseline_repo.save(baseline, property_id)
def _require_rhi(epc: EpcPropertyData) -> RenewableHeatIncentive:
rhi = epc.renewable_heat_incentive
if rhi is None:
raise ValueError(
"Effective EPC is missing renewable_heat_incentive; cannot read "
"baseline space-heating / hot-water kWh"
)
return rhi

View file

View file

@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Optional
from sqlmodel import Session, select
from domain.baseline.baseline_performance import BaselinePerformance
from infrastructure.postgres.baseline_performance_table import (
BaselinePerformanceModel,
)
from repositories.baseline.baseline_repository import BaselineRepository
class BaselinePostgresRepository(BaselineRepository):
"""Maps BaselinePerformance to/from the ``baseline_performance`` table."""
def __init__(self, session: Session) -> None:
self._session = session
def save(self, baseline: BaselinePerformance, property_id: int) -> int:
row = BaselinePerformanceModel.from_domain(baseline, property_id)
self._session.add(row)
self._session.flush()
if row.id is None:
raise ValueError("baseline_performance row did not receive an id")
return row.id
def get_for_property(
self, property_id: int
) -> Optional[BaselinePerformance]:
row = self._session.exec(
select(BaselinePerformanceModel).where(
BaselinePerformanceModel.property_id == property_id
)
).first()
return row.to_domain() if row is not None else None

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional
from domain.baseline.baseline_performance import BaselinePerformance
class BaselineRepository(ABC):
"""Persists and loads a Property's Baseline Performance.
One Baseline Performance per Property (ADR-0004: persisted as one row). The
Postgres adapter writes the standalone ``baseline_performance`` table not
columns on the retiring ``property_details_epc``.
"""
@abstractmethod
def save(self, baseline: BaselinePerformance, property_id: int) -> int: ...
@abstractmethod
def get_for_property(
self, property_id: int
) -> Optional[BaselinePerformance]: ...

View file

View file

@ -0,0 +1,34 @@
from __future__ import annotations
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.baseline.performance import Performance, lodged_performance
def _epc_with_recorded_performance(
*, sap: int, band: Epc, co2: float, peui: int
) -> EpcPropertyData:
# A bare instance with only the recorded-performance fields the reader
# touches — mirrors the opaque-EPC idiom used in the ingestion tests.
epc = object.__new__(EpcPropertyData)
epc.energy_rating_current = sap
epc.current_energy_efficiency_band = band
epc.co2_emissions_current = co2
epc.energy_consumption_current = peui
return epc
def test_lodged_performance_reads_the_four_recorded_quantities_off_the_epc() -> None:
# Arrange
epc = _epc_with_recorded_performance(sap=72, band=Epc.C, co2=1.8, peui=180)
# Act
performance = lodged_performance(epc)
# Assert
assert performance == Performance(
sap_score=72,
epc_band=Epc.C,
co2_emissions=1.8,
primary_energy_intensity=180,
)

View file

@ -0,0 +1,48 @@
from __future__ import annotations
from typing import Optional
import pytest
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.baseline.performance import Performance
from domain.baseline.rebaseliner import RebaselineNotImplemented, StubRebaseliner
def _epc(*, sap_version: Optional[float]) -> EpcPropertyData:
epc = object.__new__(EpcPropertyData)
epc.sap_version = sap_version
return epc
def _lodged() -> Performance:
return Performance(
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
)
def test_sap10_epc_is_not_rebaselined_so_effective_equals_lodged() -> None:
# Arrange — a SAP 10.2 cert: no rebaselining trigger fires.
epc = _epc(sap_version=10.2)
lodged = _lodged()
rebaseliner = StubRebaseliner()
# Act
effective, reason = rebaseliner.rebaseline(epc, lodged)
# Assert — Effective Performance equals Lodged, reason "none".
assert effective == lodged
assert reason == "none"
def test_pre_sap10_epc_raises_because_rebaselining_is_not_implemented() -> None:
# Arrange — a cert lodged under a pre-SAP10 schema genuinely needs ML
# rebaselining, which does not exist yet; the stub must not fabricate a
# "none" answer for it.
epc = _epc(sap_version=9.94)
rebaseliner = StubRebaseliner()
# Act / Assert
with pytest.raises(RebaselineNotImplemented):
rebaseliner.rebaseline(epc, _lodged())

View file

@ -0,0 +1,110 @@
from __future__ import annotations
from typing import Optional
import pytest
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
RenewableHeatIncentive,
)
from domain.baseline.baseline_performance import BaselinePerformance
from domain.baseline.performance import Performance
from domain.baseline.rebaseliner import RebaselineNotImplemented, StubRebaseliner
from domain.property.property import Property, PropertyIdentity
from orchestration.baseline_orchestrator import BaselineOrchestrator
from repositories.baseline.baseline_repository import BaselineRepository
from repositories.property.property_repository import PropertyRepository
class _FakePropertyRepo(PropertyRepository):
def __init__(self, by_id: dict[int, Property]) -> None:
self._by_id = by_id
def get(self, property_id: int) -> Property:
return self._by_id[property_id]
class _FakeBaselineRepo(BaselineRepository):
def __init__(self) -> None:
self.saved: list[tuple[BaselinePerformance, int]] = []
def save(self, baseline: BaselinePerformance, property_id: int) -> int:
self.saved.append((baseline, property_id))
return len(self.saved)
def get_for_property(
self, property_id: int
) -> Optional[BaselinePerformance]: # pragma: no cover
raise NotImplementedError
def _property(*, sap_version: float) -> Property:
epc = object.__new__(EpcPropertyData)
epc.energy_rating_current = 72
epc.current_energy_efficiency_band = Epc.C
epc.co2_emissions_current = 1.8
epc.energy_consumption_current = 180
epc.sap_version = sap_version
epc.renewable_heat_incentive = RenewableHeatIncentive(
space_heating_kwh=5000.0, water_heating_kwh=2000.0
)
return Property(
identity=PropertyIdentity(
portfolio_id=1, postcode="A0 0AA", address="1 Some Street", uprn=123
),
epc=epc,
)
def _sap10_property() -> Property:
return _property(sap_version=10.2)
def test_run_establishes_and_persists_baseline_performance() -> None:
# Arrange
property_repo = _FakePropertyRepo({10: _sap10_property()})
baseline_repo = _FakeBaselineRepo()
orchestrator = BaselineOrchestrator(
property_repo=property_repo,
rebaseliner=StubRebaseliner(),
baseline_repo=baseline_repo,
)
# Act
orchestrator.run([10])
# Assert — one Baseline Performance persisted for property 10, both halves
# equal (no rebaselining), kWh read off the RHI.
lodged = Performance(
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
)
assert baseline_repo.saved == [
(
BaselinePerformance(
lodged=lodged,
effective=lodged,
rebaseline_reason="none",
space_heating_kwh=5000.0,
water_heating_kwh=2000.0,
),
10,
)
]
def test_run_raises_on_a_pre_sap10_property_and_persists_nothing() -> None:
# Arrange — a pre-SAP10 cert needs ML rebaselining, which is not wired yet.
property_repo = _FakePropertyRepo({10: _property(sap_version=9.94)})
baseline_repo = _FakeBaselineRepo()
orchestrator = BaselineOrchestrator(
property_repo=property_repo,
rebaseliner=StubRebaseliner(),
baseline_repo=baseline_repo,
)
# Act / Assert — the raise propagates; no half-baked baseline is written.
with pytest.raises(RebaselineNotImplemented):
orchestrator.run([10])
assert baseline_repo.saved == []

View file

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from sqlalchemy import Engine
from sqlmodel import Session
from datatypes.epc.domain.epc import Epc
from domain.baseline.baseline_performance import BaselinePerformance
from domain.baseline.performance import Performance
from repositories.baseline.baseline_postgres_repository import (
BaselinePostgresRepository,
)
def _baseline() -> BaselinePerformance:
lodged = Performance(
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
)
# A rebaselined property — distinct halves so the round-trip proves both are
# persisted independently (not collapsed to one set).
effective = Performance(
sap_score=64, epc_band=Epc.D, co2_emissions=2.4, primary_energy_intensity=210
)
return BaselinePerformance(
lodged=lodged,
effective=effective,
rebaseline_reason="pre_sap10",
space_heating_kwh=5000.0,
water_heating_kwh=2000.0,
)
def test_baseline_performance_round_trips(db_engine: Engine) -> None:
# Arrange
baseline = _baseline()
with Session(db_engine) as session:
BaselinePostgresRepository(session).save(baseline, property_id=10)
session.commit()
# Act
with Session(db_engine) as session:
loaded = BaselinePostgresRepository(session).get_for_property(10)
# Assert — the full aggregate reconstructs, both halves intact.
assert loaded == baseline
def test_get_for_property_returns_none_when_absent(db_engine: Engine) -> None:
# Arrange / Act
with Session(db_engine) as session:
loaded = BaselinePostgresRepository(session).get_for_property(999)
# Assert
assert loaded is None