diff --git a/CONTEXT.md b/CONTEXT.md index b99a1ac6..345e5ce1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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**: diff --git a/docs/adr/0004-baseline-performance-lodged-effective-pair.md b/docs/adr/0004-baseline-performance-lodged-effective-pair.md index 9cedcbc7..ba275473 100644 --- a/docs/adr/0004-baseline-performance-lodged-effective-pair.md +++ b/docs/adr/0004-baseline-performance-lodged-effective-pair.md @@ -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/`. diff --git a/docs/migrations/baseline-performance-table.md b/docs/migrations/baseline-performance-table.md new file mode 100644 index 00000000..24e06179 --- /dev/null +++ b/docs/migrations/baseline-performance-table.md @@ -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. diff --git a/domain/baseline/__init__.py b/domain/baseline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/domain/baseline/baseline_performance.py b/domain/baseline/baseline_performance.py new file mode 100644 index 00000000..8db6e05d --- /dev/null +++ b/domain/baseline/baseline_performance.py @@ -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 diff --git a/domain/baseline/performance.py b/domain/baseline/performance.py new file mode 100644 index 00000000..1db38846 --- /dev/null +++ b/domain/baseline/performance.py @@ -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" + ), + ) diff --git a/domain/baseline/rebaseliner.py b/domain/baseline/rebaseliner.py new file mode 100644 index 00000000..40034a58 --- /dev/null +++ b/domain/baseline/rebaseliner.py @@ -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" diff --git a/infrastructure/postgres/baseline_performance_table.py b/infrastructure/postgres/baseline_performance_table.py new file mode 100644 index 00000000..fad4be9d --- /dev/null +++ b/infrastructure/postgres/baseline_performance_table.py @@ -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, + ) diff --git a/orchestration/baseline_orchestrator.py b/orchestration/baseline_orchestrator.py new file mode 100644 index 00000000..298e3683 --- /dev/null +++ b/orchestration/baseline_orchestrator.py @@ -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 diff --git a/repositories/baseline/__init__.py b/repositories/baseline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/repositories/baseline/baseline_postgres_repository.py b/repositories/baseline/baseline_postgres_repository.py new file mode 100644 index 00000000..5a2c7bb8 --- /dev/null +++ b/repositories/baseline/baseline_postgres_repository.py @@ -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 diff --git a/repositories/baseline/baseline_repository.py b/repositories/baseline/baseline_repository.py new file mode 100644 index 00000000..67e430f5 --- /dev/null +++ b/repositories/baseline/baseline_repository.py @@ -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]: ... diff --git a/tests/domain/baseline/__init__.py b/tests/domain/baseline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/domain/baseline/test_performance.py b/tests/domain/baseline/test_performance.py new file mode 100644 index 00000000..6e8f080e --- /dev/null +++ b/tests/domain/baseline/test_performance.py @@ -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, + ) diff --git a/tests/domain/baseline/test_rebaseliner.py b/tests/domain/baseline/test_rebaseliner.py new file mode 100644 index 00000000..f4ceee70 --- /dev/null +++ b/tests/domain/baseline/test_rebaseliner.py @@ -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()) diff --git a/tests/orchestration/test_baseline_orchestrator.py b/tests/orchestration/test_baseline_orchestrator.py new file mode 100644 index 00000000..3958b9b4 --- /dev/null +++ b/tests/orchestration/test_baseline_orchestrator.py @@ -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 == [] diff --git a/tests/repositories/baseline/__init__.py b/tests/repositories/baseline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/repositories/baseline/test_baseline_postgres_repository.py b/tests/repositories/baseline/test_baseline_postgres_repository.py new file mode 100644 index 00000000..eaa20003 --- /dev/null +++ b/tests/repositories/baseline/test_baseline_postgres_repository.py @@ -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