Merge branch 'main' into audit/schema-mapping-gaps

This commit is contained in:
Daniel Roth 2026-06-30 10:00:33 +00:00
commit d623a29c2d
18 changed files with 1027 additions and 195 deletions

View file

@ -114,9 +114,27 @@ durable, compounding output of every audit.
## Notes
- Read-only on the DB. `run_modelling_e2e` is a dry run.
- **Audit the default plan only** — every check, and every characterisation
query, must filter `plan.is_default = TRUE` (the FE-shown plan). The runner's
`_QUERY`/`_ROLLUP_QUERY` already do; keep ad-hoc SQL consistent or the counts
mix superseded plans into the picture.
- **The stored default plan can be STALE vs the live model** — the persisted plan
is the output of an *earlier* modelling run; `run_modelling_e2e` re-models
against current logic + live EPC/solar. A stored-vs-live gap is the single
biggest driver of MEDIUM/HIGH anomalies on a not-yet-re-modelled portfolio, and
it spans more checks than the one below names. The fix is operational
(**re-model the portfolio, then re-audit**), not a code change — confirm a
sample with `run_modelling_e2e` before debugging the calculator.
- **Expected, not bugs** (until the override-aware-rebaseline + persistence-fidelity
PR deploys and the portfolio is re-modelled): much of `zero-works-post-differs`
and `plan-score-below-baseline` is the pre-fix baseline-vs-plan divergence and
should shrink after re-model — note it, don't re-debug it.
work — see `docs/adr/` — is re-modelled into the portfolio): much of
`zero-works-post-differs`, `plan-score-below-baseline`, `already-meets-goal-with-works`,
and the non-fuel-switch slice of `negative-bill-savings` is the same stale
stored-plan-vs-live divergence and should shrink after re-model — note it,
don't re-debug it. Confirm a sample's stored plan differs from `run_modelling_e2e`
(stored `air_source_heat_pump` for tens of £k where the live plan is a cheap
profitable `solar_pv`; stored works on a property the live model leaves at £0
because it already meets goal). The `negative-bill` certs that DO carry an
`air_source_heat_pump` are a genuine gas→electric fuel-switch (bills rise at
current price ratios) — expected by design.
- Adding a check is one decorated `(PropertyAudit) -> Optional[str]` function in
`scripts/audit/anomalies.py`; see its module docstring.

View file

@ -29,3 +29,8 @@ moto[s3,sqs]==5.0.28 # mock_aws (moto 5.x) for S3/SQS in orchestration tests
black==26.1.0
boto3-stubs
openai
# Type checking — strict pyright gate (CLAUDE.md). The pip `pyright` wrapper uses
# the container's Node. pandas-stubs lets pandas-typed modules check cleanly
# (CLAUDE.md: add pandas-stubs when introducing pandas to a module).
pyright==1.1.411
pandas-stubs

View file

@ -92,7 +92,8 @@ from repositories.comparable_properties.epc_comparable_properties_repository imp
EpcComparablePropertiesRepository,
SkippedCohortCert,
)
from repositories.epc.epc_postgres_repository import EpcPostgresRepository
from repositories.epc.epc_postgres_repository import EpcPostgresRepository, EpcSaveRequest
from repositories.plan.plan_repository import PlanSaveRequest
from repositories.geospatial.geospatial_s3_repository import (
GeospatialS3Repository,
ParquetReader,
@ -178,31 +179,41 @@ class _PropertyWrite:
def _flush_writes(engine: Engine, writes: list[_PropertyWrite]) -> None:
"""Persist a whole batch of modelled Properties in one Unit of Work.
Replays each Property's saves in dependency order (EPC → spatial → solar →
Plan mark-modelled) and commits once. All-or-nothing per batch: a failed
save rolls the whole transaction back and propagates, so the SQS message is
retried every save is an idempotent upsert, so a retry is safe. This mirrors
the PropertyBaselineOrchestrator's existing one-UoW-per-batch contract
(ADR-0012); per-property failures are isolated earlier, in the modelling loop,
EPC writes are batched by source (lodged group first, predicted group second)
so each source emits one DELETE pass + one INSERT pass regardless of batch
size, rather than N×per-property round-trips (ADR-0012). All other writes
(spatial, solar, plan, mark-modelled) remain per-property inside the same
transaction. All-or-nothing per batch: a failed save rolls the whole
transaction back so the SQS message is retried every save is an idempotent
upsert. Per-property failures are isolated earlier, in the modelling loop,
before a write is ever queued."""
lodged_requests = [
EpcSaveRequest(w.lodged_epc, property_id=w.property_id, portfolio_id=w.portfolio_id, source="lodged")
for w in writes
if w.lodged_epc is not None and w.lodged_epc_is_new
]
predicted_requests = [
EpcSaveRequest(w.predicted_epc, property_id=w.property_id, portfolio_id=w.portfolio_id, source="predicted")
for w in writes
if w.predicted_epc is not None and w.predicted_epc_is_new
]
with PostgresUnitOfWork(lambda: Session(engine)) as uow:
if lodged_requests:
uow.epc.save_batch(lodged_requests)
if predicted_requests:
uow.epc.save_batch(predicted_requests)
plan_requests = [
PlanSaveRequest(
w.plan,
property_id=w.property_id,
scenario_id=w.scenario_id,
portfolio_id=w.portfolio_id,
is_default=w.is_default,
)
for w in writes
]
uow.plan.save_batch(plan_requests)
for w in writes:
if w.lodged_epc is not None and w.lodged_epc_is_new:
uow.epc.save(
w.lodged_epc,
property_id=w.property_id,
portfolio_id=w.portfolio_id,
)
elif w.predicted_epc is not None and w.predicted_epc_is_new:
# Persist the synthesised EPC in the predicted slot (ADR-0031), so
# the Baseline stage can re-hydrate it and downstream sees the
# picture the Plan was modelled from.
uow.epc.save(
w.predicted_epc,
property_id=w.property_id,
portfolio_id=w.portfolio_id,
source="predicted",
)
if w.spatial is not None:
uow.spatial.save(w.uprn, w.spatial)
if w.solar is not None:
@ -212,13 +223,6 @@ def _flush_writes(engine: Engine, writes: list[_PropertyWrite]) -> None:
latitude=w.solar.latitude,
insights=w.solar.insights,
)
uow.plan.save(
w.plan,
property_id=w.property_id,
scenario_id=w.scenario_id,
portfolio_id=w.portfolio_id,
is_default=w.is_default,
)
uow.property.mark_modelled(
w.property_id, has_recommendations=w.has_recommendations
)

View file

@ -6234,6 +6234,10 @@ _SAP_DESIGN_HEAT_LOSS_DELTA_T_K: Final[float] = 24.2
_HP_SPACE_HEATING_IN_USE_FACTOR_N3_6: Final[float] = 0.95
_HP_IN_USE_FACTOR_CRITERIA_MET: Final[float] = 0.95
_HP_IN_USE_FACTOR_CRITERIA_FAIL: Final[float] = 0.60
# SAP 10.2 Appendix N3.7 (PDF p.109): the heat-pump water-heating efficiency
# (in-use factor × η_water) is "subject to a minimum efficiency of 100%" —
# below that the direct-electric backup governs.
_HP_WATER_HEATING_MIN_EFFICIENCY: Final[float] = 1.0
def _heat_pump_cylinder_meets_pcdb_criteria(
@ -6325,7 +6329,12 @@ def _heat_pump_apm_efficiencies(
main_heating_efficiency = (
_HP_SPACE_HEATING_IN_USE_FACTOR_N3_6 * eta_space_1_pct / 100.0
)
water_efficiency_pct = in_use_water * eta_water_3_pct / 100.0
# N3.7: in-use factor × η_water, subject to a minimum efficiency of 100%
# (the direct-electric backup floors the heat pump's water heating).
water_efficiency_pct = max(
in_use_water * eta_water_3_pct / 100.0,
_HP_WATER_HEATING_MIN_EFFICIENCY,
)
return (main_heating_efficiency, water_efficiency_pct)

View file

@ -263,6 +263,14 @@ _HP_PSR_GROUP_OFFSET_PSR: Final[int] = 0
_HP_PSR_GROUP_OFFSET_ETA_SPACE_1: Final[int] = 2
_HP_PSR_GROUP_OFFSET_ETA_WATER_3: Final[int] = 6
# SAP 10.2 Appendix N2 (PDF p.101, footnotes 44/45): out-of-range PSR
# extension for air/ground/water source heat pumps. Above the record's
# largest PSR the efficiency is reciprocal-interpolated toward 100% at
# `_EXTENSION_PSR_MULTIPLE` × the largest PSR; below the smallest PSR, and
# beyond that multiple, the efficiency is the terminal 100%.
_EXTENSION_TERMINAL_EFFICIENCY_PCT: Final[float] = 100.0
_EXTENSION_PSR_MULTIPLE: Final[float] = 2.0
def _parse_psr_groups(raw: tuple[str, ...]) -> tuple[PsrEfficiencyGroup, ...]:
"""Decode the variable-length PSR-dependent block of a format-465
@ -317,28 +325,60 @@ def interpolate_heat_pump_efficiency_at_psr(
(not their reciprocals taken from PCDB), so the η_*_pct values must
be strictly positive every PCDB row in the cohort satisfies this.
Per spec PDF p.100 lines 7039-7072: clamp to the smallest PSR in
the record when `target_psr` is below it, and to the largest when
above ("if the PSR is greater than the largest PSR in the database
record then the heat pump space and water heating fractions for the
largest PSR should be used, and if the PSR is less than the
smallest PSR in the database record then the heat pump space and
water heating fractions for the smallest PSR should be used").
Out-of-range PSR (spec PDF p.101, footnotes 44/45 air/ground/water
source heat pumps):
- Below the smallest PSR in the record: "an efficiency of 100%
should be used if the PSR is less than the smallest value in the
database record."
- Above the largest PSR in the record: "an efficiency may be
obtained from linear interpolation between that at the largest
PSR in the data record and efficiency 100% at PSR two times the
largest PSR in the data record. If the PSR is greater than two
times the largest PSR in the data record an efficiency of 100%
should be used." The interpolation is reciprocal-linear too
(footnote 43), with 100% as the upper anchor.
Both space- and water-heating PSR-dependent efficiencies extend the
same way. (Exhaust-air heat pumps and combined heat-pump-and-boiler
packages instead use 100% directly above the largest PSR, and combined
packages clamp to the edge rows; neither is distinguished by the
current PCDB parse, so the air/ground/water rule is applied uniformly
a documented limitation. The dominant RdSAP cohort is air source.)
Cohort fixture: cert 3336-2825-9400-0512-8292 (Mitsubishi PUZ-WM50VHA,
PCDB 104568) PSR 1.40151 brackets PCDB rows PSR 1.2 (η_space_1
= 253.9) and PSR 1.5 (η_space_1 = 229.2). Linear (pre-slice):
237.31; reciprocal (spec-faithful): 236.74 matches worksheet
(206)/(210) at 1e-4 once the 0.95 in-use factor is applied.
Out-of-range anchor: PCDB 100061 (golden fixture case 56), largest PSR
2.0 (η_space_1=352.0). At dwelling PSR 3.10665 the extension to 100%
at PSR 4.0 gives η_space_1 = 147.011 (206) = 139.660, matching the
accredited Elmhurst worksheet (vs the old clamp's 352.0 → 334.4%).
"""
if not psr_groups:
raise ValueError("PSR groups required for interpolation")
if target_psr <= psr_groups[0].psr:
first = psr_groups[0]
return (first.eta_space_1_pct, first.eta_water_3_pct)
if target_psr >= psr_groups[-1].psr:
if target_psr < psr_groups[0].psr:
return (_EXTENSION_TERMINAL_EFFICIENCY_PCT, _EXTENSION_TERMINAL_EFFICIENCY_PCT)
if target_psr > psr_groups[-1].psr:
last = psr_groups[-1]
return (last.eta_space_1_pct, last.eta_water_3_pct)
upper_psr = _EXTENSION_PSR_MULTIPLE * last.psr
if target_psr >= upper_psr:
return (
_EXTENSION_TERMINAL_EFFICIENCY_PCT,
_EXTENSION_TERMINAL_EFFICIENCY_PCT,
)
t = (target_psr - last.psr) / (upper_psr - last.psr)
eta_space_1 = 1.0 / (
(1.0 - t) / last.eta_space_1_pct
+ t / _EXTENSION_TERMINAL_EFFICIENCY_PCT
)
eta_water_3 = 1.0 / (
(1.0 - t) / last.eta_water_3_pct
+ t / _EXTENSION_TERMINAL_EFFICIENCY_PCT
)
return (eta_space_1, eta_water_3)
for low_group, high_group in zip(psr_groups, psr_groups[1:]):
if low_group.psr <= target_psr <= high_group.psr:
span = high_group.psr - low_group.psr

View file

@ -1,10 +1,12 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Optional, Protocol, TypeVar
from typing import Any, Optional, Protocol, TypeVar
from sqlmodel import Session, col, delete, select
from sqlalchemy import insert as _sa_insert
from sqlmodel import Session, SQLModel, col, delete, select
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import (
@ -54,6 +56,23 @@ from utilities.private import private
_T = TypeVar("_T")
@dataclass(frozen=True)
class EpcSaveRequest:
data: EpcPropertyData
property_id: Optional[int] = None
portfolio_id: Optional[int] = None
source: EpcSource = field(default="lodged")
def _col_values(model: SQLModel, exclude: frozenset[str] = frozenset()) -> dict[str, Any]:
"""Extract column-keyed values from a SQLModel instance for Core INSERT."""
return {
c.name: getattr(model, c.name) # type: ignore[union-attr]
for c in model.__table__.c # type: ignore[attr-defined]
if c.name not in exclude # type: ignore[union-attr]
}
def _require(value: Optional[_T], field: str) -> _T:
if value is None:
raise ValueError(f"epc_property row is missing required field {field!r}")
@ -111,75 +130,195 @@ class EpcPostgresRepository(EpcRepository):
portfolio_id: Optional[int] = None,
source: EpcSource = "lodged",
) -> int:
# Idempotent on (property_id, source): a re-run replaces the property's
# EPC graph for THAT source rather than duplicating it (ADR-0012), and a
# predicted save leaves the lodged one intact, and vice versa (ADR-0031).
# Anonymous saves (no property_id) always insert.
if property_id is not None:
self._delete_for_property(property_id, source)
parent = EpcPropertyModel.from_epc_property_data(
data, property_id=property_id, portfolio_id=portfolio_id, source=source
)
self._session.add(parent)
self._session.flush()
epc_property_id = _require(parent.id, "id")
return self.save_batch([EpcSaveRequest(data, property_id, portfolio_id, source)])[0]
self._session.add(
EpcPropertyEnergyPerformanceModel.from_epc_property_data(
data, epc_property_id=epc_property_id
)
)
for detail in data.sap_heating.main_heating_details:
self._session.add(
EpcMainHeatingDetailModel.from_domain(detail, epc_property_id)
)
for part in data.sap_building_parts:
bp = EpcBuildingPartModel.from_domain(part, epc_property_id)
self._session.add(bp)
self._session.flush()
bp_id = _require(bp.id, "epc_building_part.id")
for dim in part.sap_floor_dimensions:
self._session.add(EpcFloorDimensionModel.from_domain(dim, bp_id))
for window in data.sap_windows:
self._session.add(EpcWindowModel.from_domain(window, epc_property_id))
for index, array in enumerate(data.sap_energy_source.photovoltaic_arrays or []):
self._session.add(
EpcPhotovoltaicArrayModel.from_domain(array, index, epc_property_id)
)
def save_batch(self, requests: list[EpcSaveRequest]) -> list[int]:
"""Insert all EPCs in `requests` in one pass per table, returning one
epc_property_id per request in the same order as the input.
for element_type, elements in (
("roof", data.roofs),
("wall", data.walls),
("floor", data.floors),
("main_heating", data.main_heating),
Deletes are batched first (one IN-query per child table per source),
then all parent rows are inserted with a single RETURNING statement so
positional ordering maps each returned id to its request. Building-part
ids are captured the same way so floor-dimension FKs are resolved without
any per-property flush round-trips (ADR-0012).
"""
if not requests:
return []
# Batch-delete existing rows grouped by source so the lodged and predicted
# slots remain independent (ADR-0031).
pids_by_source: dict[EpcSource, list[int]] = {}
for r in requests:
if r.property_id is not None:
pids_by_source.setdefault(r.source, []).append(r.property_id)
for src, pids in pids_by_source.items():
self._delete_for_properties(pids, src)
# Insert all parent (epc_property) rows; capture returned ids positionally.
parent_rows = [
_col_values(
EpcPropertyModel.from_epc_property_data(
r.data, property_id=r.property_id, portfolio_id=r.portfolio_id, source=r.source
),
exclude=frozenset({"id"}),
)
for r in requests
]
returned_parents = self._session.execute( # type: ignore[deprecated]
_sa_insert(EpcPropertyModel).returning(EpcPropertyModel.__table__.c["id"]), # type: ignore[attr-defined]
parent_rows,
).all()
epc_property_ids = [row[0] for row in returned_parents]
# Collect child rows, accumulating building parts in an ordered list so
# the positional RETURNING trick can map part objects to their new ids.
perf_rows: list[dict[str, Any]] = []
heating_rows: list[dict[str, Any]] = []
parts_ordered: list[tuple[Any, int]] = [] # (SapBuildingPart, epc_property_id)
window_rows: list[dict[str, Any]] = []
pv_rows: list[dict[str, Any]] = []
element_rows: list[dict[str, Any]] = []
flat_rows: list[dict[str, Any]] = []
rhi_rows: list[dict[str, Any]] = []
for r, epc_pid in zip(requests, epc_property_ids):
d = r.data
perf_rows.append(
_col_values(
EpcPropertyEnergyPerformanceModel.from_epc_property_data(d, epc_pid),
exclude=frozenset({"id"}),
)
)
for detail in d.sap_heating.main_heating_details:
heating_rows.append(
_col_values(EpcMainHeatingDetailModel.from_domain(detail, epc_pid), frozenset({"id"}))
)
for part in d.sap_building_parts:
parts_ordered.append((part, epc_pid))
for window in d.sap_windows:
window_rows.append(
_col_values(EpcWindowModel.from_domain(window, epc_pid), frozenset({"id"}))
)
for idx, array in enumerate(d.sap_energy_source.photovoltaic_arrays or []):
pv_rows.append(
_col_values(EpcPhotovoltaicArrayModel.from_domain(array, idx, epc_pid), frozenset({"id"}))
)
for etype, els in (
("roof", d.roofs),
("wall", d.walls),
("floor", d.floors),
("main_heating", d.main_heating),
):
for el in els:
element_rows.append(
_col_values(EpcEnergyElementModel.from_domain(el, etype, epc_pid), frozenset({"id"}))
)
for el, etype in (
(d.window, "window"),
(d.lighting, "lighting"),
(d.hot_water, "hot_water"),
(d.secondary_heating, "secondary_heating"),
(d.main_heating_controls, "main_heating_controls"),
):
if el is not None:
element_rows.append(
_col_values(EpcEnergyElementModel.from_domain(el, etype, epc_pid), frozenset({"id"}))
)
if d.sap_flat_details is not None:
flat_rows.append(
_col_values(EpcFlatDetailsModel.from_domain(d.sap_flat_details, epc_pid), frozenset({"id"}))
)
if d.renewable_heat_incentive is not None:
rhi_rows.append(
_col_values(EpcRenewableHeatIncentiveModel.from_domain(d.renewable_heat_incentive, epc_pid), frozenset({"id"}))
)
# Bulk-insert all simple child tables (no downstream FK dependency).
if perf_rows:
self._session.execute(_sa_insert(EpcPropertyEnergyPerformanceModel), perf_rows) # type: ignore[deprecated]
if heating_rows:
self._session.execute(_sa_insert(EpcMainHeatingDetailModel), heating_rows) # type: ignore[deprecated]
if window_rows:
self._session.execute(_sa_insert(EpcWindowModel), window_rows) # type: ignore[deprecated]
if pv_rows:
self._session.execute(_sa_insert(EpcPhotovoltaicArrayModel), pv_rows) # type: ignore[deprecated]
if element_rows:
self._session.execute(_sa_insert(EpcEnergyElementModel), element_rows) # type: ignore[deprecated]
if flat_rows:
self._session.execute(_sa_insert(EpcFlatDetailsModel), flat_rows) # type: ignore[deprecated]
if rhi_rows:
self._session.execute(_sa_insert(EpcRenewableHeatIncentiveModel), rhi_rows) # type: ignore[deprecated]
# Building parts: insert with RETURNING and zip positionally to resolve
# floor-dimension FKs. Do NOT key by id(part) — the same EpcPropertyData
# object can appear in multiple requests (same epc, different property_ids),
# giving identical object ids that collapse the dict and mis-wire FKs.
# Positional zip is safe because PostgreSQL preserves VALUES order in RETURNING.
if parts_ordered:
bp_rows = [
_col_values(EpcBuildingPartModel.from_domain(part, epc_pid), frozenset({"id"}))
for part, epc_pid in parts_ordered
]
returned_bps = self._session.execute( # type: ignore[deprecated]
_sa_insert(EpcBuildingPartModel).returning(EpcBuildingPartModel.__table__.c["id"]), # type: ignore[attr-defined]
bp_rows,
).all()
floor_rows: list[dict[str, Any]] = [
_col_values(EpcFloorDimensionModel.from_domain(dim, bp_row[0]), frozenset({"id"}))
for (part, _), bp_row in zip(parts_ordered, returned_bps)
for dim in part.sap_floor_dimensions
]
if floor_rows:
self._session.execute(_sa_insert(EpcFloorDimensionModel), floor_rows) # type: ignore[deprecated]
return epc_property_ids
def _delete_for_properties(self, property_ids: list[int], source: EpcSource) -> None:
"""Batch-delete every EPC graph for the given property_ids and source in
one pass per child table (IN queries), replacing the per-property flush
loop that drove RDS CPU to saturation during bulk modelling runs."""
epc_ids = [
i
for i in self._session.exec(
select(EpcPropertyModel.id)
.where(col(EpcPropertyModel.property_id).in_(property_ids))
.where(EpcPropertyModel.source == source)
).all()
if i is not None
]
if not epc_ids:
return
part_ids = [
i
for i in self._session.exec(
select(EpcBuildingPartModel.id).where(
col(EpcBuildingPartModel.epc_property_id).in_(epc_ids)
)
).all()
if i is not None
]
if part_ids:
self._session.exec( # type: ignore[call-overload]
delete(EpcFloorDimensionModel).where(
col(EpcFloorDimensionModel.epc_building_part_id).in_(part_ids)
)
)
for child in (
EpcPropertyEnergyPerformanceModel,
EpcEnergyElementModel,
EpcMainHeatingDetailModel,
EpcBuildingPartModel,
EpcWindowModel,
EpcPhotovoltaicArrayModel,
EpcFlatDetailsModel,
EpcRenewableHeatIncentiveModel,
):
for el in elements:
self._session.add(
EpcEnergyElementModel.from_domain(el, element_type, epc_property_id)
)
for el, element_type in (
(data.window, "window"),
(data.lighting, "lighting"),
(data.hot_water, "hot_water"),
(data.secondary_heating, "secondary_heating"),
(data.main_heating_controls, "main_heating_controls"),
):
if el is not None:
self._session.add(
EpcEnergyElementModel.from_domain(el, element_type, epc_property_id)
)
if data.sap_flat_details is not None:
self._session.add(
EpcFlatDetailsModel.from_domain(data.sap_flat_details, epc_property_id)
self._session.exec( # type: ignore[call-overload]
delete(child).where(col(child.epc_property_id).in_(epc_ids))
)
if data.renewable_heat_incentive is not None:
self._session.add(
EpcRenewableHeatIncentiveModel.from_domain(
data.renewable_heat_incentive, epc_property_id
)
)
return epc_property_id
self._session.exec( # type: ignore[call-overload]
delete(EpcPropertyModel).where(col(EpcPropertyModel.id).in_(epc_ids))
)
def _delete_for_property(self, property_id: int, source: EpcSource) -> None:
"""Remove the property's existing EPC graph for `source` (parent + child

View file

@ -1,10 +1,13 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Literal, Optional
from typing import TYPE_CHECKING, Literal, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
if TYPE_CHECKING:
from repositories.epc.epc_postgres_repository import EpcSaveRequest
# Provenance of a persisted EPC picture (ADR-0031): a real "lodged" EPC, or a
# "predicted" one synthesised by EPC Prediction. A property can hold one of each.
EpcSource = Literal["lodged", "predicted"]
@ -29,6 +32,9 @@ class EpcRepository(ABC):
source: EpcSource = "lodged",
) -> int: ...
@abstractmethod
def save_batch(self, requests: "list[EpcSaveRequest]") -> list[int]: ...
@abstractmethod
def get(self, epc_property_id: int) -> EpcPropertyData: ...

View file

@ -1,10 +1,22 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import insert as _sa_insert
from sqlmodel import Session, col, update
from domain.modelling.plan import Plan
from infrastructure.postgres.modelling import PlanModel, RecommendationModel
from repositories.plan.plan_repository import PlanRepository
from repositories.plan.plan_repository import PlanRepository, PlanSaveRequest
def _col_values(model: Any, exclude: frozenset[str] = frozenset()) -> dict[str, Any]:
"""Extract column-keyed values from a SQLModel instance for Core INSERT."""
return {
c.name: getattr(model, c.name)
for c in model.__table__.c
if c.name not in exclude
}
class PlanPostgresRepository(PlanRepository):
@ -29,37 +41,70 @@ class PlanPostgresRepository(PlanRepository):
portfolio_id: int,
is_default: bool,
) -> int:
# Soft-replace (ADR-0012): keep prior Plans as history rather than DELETEing
# them — the cascade delete of recommendation rows was the slow part. When
# this Plan is the default, demote every prior Plan for the same
# (property_id, scenario_id) to is_default=False, so exactly one Plan for
# the pair stays default (the one just inserted).
if is_default:
return self.save_batch(
[PlanSaveRequest(plan, property_id=property_id, scenario_id=scenario_id, portfolio_id=portfolio_id, is_default=is_default)]
)[0]
def save_batch(self, requests: list[PlanSaveRequest]) -> list[int]:
"""Persist all Plans in three statements regardless of batch size.
1. One demote UPDATE (only when any request has ``is_default=True``).
2. One bulk plan INSERT with RETURNING to capture ids positionally.
3. One bulk recommendation INSERT (skipped when no measures exist).
"""
if not requests:
return []
# Demote prior default Plans for every property in the batch that is
# receiving a new default Plan — one UPDATE for the whole batch.
default_pids = [r.property_id for r in requests if r.is_default]
if default_pids:
# scenario_id is uniform per batch (one scenario per SQS message).
scenario_id = requests[0].scenario_id
self._session.exec( # type: ignore[call-overload]
update(PlanModel)
.where(
col(PlanModel.property_id) == property_id,
col(PlanModel.property_id).in_(default_pids),
col(PlanModel.scenario_id) == scenario_id,
)
.values(is_default=False)
)
plan_row = PlanModel.from_domain(
plan,
property_id=property_id,
scenario_id=scenario_id,
portfolio_id=portfolio_id,
is_default=is_default,
)
self._session.add(plan_row)
self._session.flush()
if plan_row.id is None:
raise ValueError("plan row did not receive an id")
for measure in plan.measures:
self._session.add(
RecommendationModel.from_domain(
measure, property_id=property_id, plan_id=plan_row.id
)
# Bulk INSERT all plan rows; capture returned ids positionally.
plan_rows = [
_col_values(
PlanModel.from_domain(
r.plan,
property_id=r.property_id,
scenario_id=r.scenario_id,
portfolio_id=r.portfolio_id,
is_default=r.is_default,
),
exclude=frozenset({"id"}),
)
return plan_row.id
for r in requests
]
returned = self._session.execute( # type: ignore[deprecated]
_sa_insert(PlanModel).returning(PlanModel.__table__.c["id"]), # type: ignore[attr-defined]
plan_rows,
).all()
plan_ids = [row[0] for row in returned]
# Accumulate recommendation rows across all requests; properties with
# zero measures contribute nothing (no special-casing needed).
rec_rows = [
_col_values(
RecommendationModel.from_domain(
measure, property_id=r.property_id, plan_id=plan_id
),
exclude=frozenset({"id"}),
)
for r, plan_id in zip(requests, plan_ids)
for measure in r.plan.measures
]
if rec_rows:
self._session.execute( # type: ignore[deprecated]
_sa_insert(RecommendationModel), rec_rows
)
return plan_ids

View file

@ -1,10 +1,25 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from domain.modelling.plan import Plan
@dataclass(frozen=True)
class PlanSaveRequest:
"""Bundles the five fields the plan repository needs to persist one Plan.
Mirrors ``EpcSaveRequest`` in shape used by ``PlanRepository.save_batch()``
to accumulate write intent before the batch is flushed in one Unit of Work."""
plan: Plan
property_id: int
scenario_id: int
portfolio_id: int
is_default: bool
class PlanRepository(ABC):
"""Persists a Plan (and its Plan Measures) for a Property + Scenario.
@ -30,3 +45,12 @@ class PlanRepository(ABC):
``(property_id, scenario_id)`` as history; when ``is_default`` is True,
demotes those prior Plans to ``is_default=False``."""
...
@abstractmethod
def save_batch(self, requests: list[PlanSaveRequest]) -> list[int]:
"""Persist a batch of Plans in three statements regardless of batch size.
Returns one plan id per request in input order. Fires a single demote
UPDATE only when at least one request has ``is_default=True``. Keeps
prior Plans as history (ADR-0017)."""
...

View file

@ -19,6 +19,7 @@ from applications.modelling_e2e.modelling_e2e_trigger_body import (
ModellingE2ETriggerBody,
)
from domain.tasks.subtasks import SubTask
from repositories.epc.epc_postgres_repository import EpcSaveRequest
PROPERTY_ID = 12345
UPRN = 987654321
@ -355,10 +356,10 @@ def test_lodged_epc_path_saves_epc_plan_and_marks_modelled(
_call_handler(_BODY)
# Assert — EPC saved (lodged path), plan saved, property marked modelled
mock_uow.epc.save.assert_called_once_with(
mock_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID
mock_uow.epc.save_batch.assert_called_once_with(
[EpcSaveRequest(mock_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="lodged")]
)
mock_uow.plan.save.assert_called_once()
mock_uow.plan.save_batch.assert_called_once()
mock_uow.property.mark_modelled.assert_called_once_with(
PROPERTY_ID, has_recommendations=False
)
@ -447,7 +448,7 @@ def test_skipped_cohort_certs_do_not_prevent_plan_being_saved() -> None:
_call_handler(_BODY)
# Assert — plan committed despite the skipped cert
mock_uow.plan.save.assert_called_once()
mock_uow.plan.save_batch.assert_called_once()
mock_uow.commit.assert_called_once()
@ -512,7 +513,7 @@ def test_skipped_cohort_certs_are_logged_and_handler_does_not_raise() -> None:
_call_handler(_BODY)
# Assert — plan committed; skipped cert number surfaced in a log call
mock_uow.plan.save.assert_called_once()
mock_uow.plan.save_batch.assert_called_once()
mock_uow.commit.assert_called_once()
logged_messages = " ".join(
str(c.args) + str(c.kwargs) for c in mock_logger.info.call_args_list
@ -631,13 +632,10 @@ def test_prediction_path_saves_predicted_epc_plan_and_baseline(
_call_handler(_BODY)
# Assert — predicted EPC persisted in the predicted slot, plan saved, baseline run
mock_uow.epc.save.assert_called_once_with(
mock_predicted_epc,
property_id=PROPERTY_ID,
portfolio_id=PORTFOLIO_ID,
source="predicted",
mock_uow.epc.save_batch.assert_called_once_with(
[EpcSaveRequest(mock_predicted_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="predicted")]
)
mock_uow.plan.save.assert_called_once()
mock_uow.plan.save_batch.assert_called_once()
mock_uow.commit.assert_called_once()
_baseline_orchestrator.return_value.run.assert_called_once_with([PROPERTY_ID])
@ -841,13 +839,10 @@ def test_empty_own_postcode_broadens_to_nearby_and_predicts() -> None:
# Assert — broadening fired, and the broadened cohort produced a saved plan
# with its predicted EPC persisted in the predicted slot.
MockRepo.return_value.candidates_near.assert_called_once()
mock_uow.epc.save.assert_called_once_with(
mock_predicted_epc,
property_id=PROPERTY_ID,
portfolio_id=PORTFOLIO_ID,
source="predicted",
mock_uow.epc.save_batch.assert_called_once_with(
[EpcSaveRequest(mock_predicted_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="predicted")]
)
mock_uow.plan.save.assert_called_once()
mock_uow.plan.save_batch.assert_called_once()
mock_uow.commit.assert_called_once()
@ -984,8 +979,9 @@ def test_batch_persists_in_one_transaction_and_one_baseline_run(
"scenario_id": SCENARIO_ID, "refetch_solar": False, "dry_run": False}
)
# Assert — all three Plans saved, but a single shared transaction:
assert mock_uow.plan.save.call_count == 3
# Assert — all three Plans saved in one batch call, but a single shared transaction:
mock_uow.plan.save_batch.assert_called_once()
assert len(mock_uow.plan.save_batch.call_args[0][0]) == 3
assert mock_uow.property.mark_modelled.call_count == 3
mock_uow.commit.assert_called_once()
# One write Unit of Work opened for the whole batch, not one per property.
@ -1170,10 +1166,8 @@ def test_refetch_epc_false_with_stored_epc_skips_api_call() -> None:
# Assert — API not called; stored EPC flows into run_modelling
mock_epc_client.get_by_uprn.assert_not_called()
mock_run_modelling.assert_called_once()
# Stored lodged EPC is persisted in the lodged slot
mock_uow.epc.save.assert_called_once_with(
stored_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID
)
# Stored EPC is NOT re-saved — it was read from DB unchanged (PR #1353)
mock_uow.epc.save_batch.assert_not_called()
def test_refetch_epc_false_without_stored_epc_skips_api_and_goes_to_prediction() -> None:
@ -1258,11 +1252,8 @@ def test_refetch_epc_false_without_stored_epc_skips_api_and_goes_to_prediction()
# Assert — API was NOT called; prediction ran and its output was persisted
mock_epc_client.get_by_uprn.assert_not_called()
mock_uow.epc.save.assert_called_once_with(
mock_predicted_epc,
property_id=PROPERTY_ID,
portfolio_id=PORTFOLIO_ID,
source="predicted",
mock_uow.epc.save_batch.assert_called_once_with(
[EpcSaveRequest(mock_predicted_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="predicted")]
)
@ -1396,14 +1387,9 @@ def test_repredict_epc_false_with_stored_predicted_epc_skips_prediction() -> Non
# Act
_call_handler({**_BODY, "repredict_epc": False})
# Assert — EpcPrediction.predict never called; stored EPC persisted in predicted slot
# Assert — EpcPrediction.predict never called; stored predicted EPC NOT re-saved (PR #1353)
mock_predictor.predict.assert_not_called()
mock_uow.epc.save.assert_called_once_with(
stored_predicted,
property_id=PROPERTY_ID,
portfolio_id=PORTFOLIO_ID,
source="predicted",
)
mock_uow.epc.save_batch.assert_not_called()
def test_repredict_epc_false_without_stored_predicted_epc_falls_back_to_live_prediction() -> None:
@ -1488,11 +1474,8 @@ def test_repredict_epc_false_without_stored_predicted_epc_falls_back_to_live_pre
# Assert — live prediction was used as fallback
mock_predictor.predict.assert_called_once()
mock_uow.epc.save.assert_called_once_with(
mock_predicted_epc,
property_id=PROPERTY_ID,
portfolio_id=PORTFOLIO_ID,
source="predicted",
mock_uow.epc.save_batch.assert_called_once_with(
[EpcSaveRequest(mock_predicted_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID, source="predicted")]
)

View file

@ -56,6 +56,15 @@ _FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "epc_prediction"
# new-build-vs-old-stock service mismatch on 1-2 targets each (heating_main_fuel
# 0.9722->0.9394, water_heating_fuel ->0.9495, cylinder_insulation_type 0.6667->
# 0.3333) plus floor_area (+0.31 MAE). Tighten-only resumes from these values.
#
# has_pv re-baselined 0.9798->0.9697 when full-SAP lodged PV mapping landed
# (datatypes/epc/domain/mapper.py `_sap_17_1_pv_arrays`): full-SAP certs lodge
# their measured array under `sap_energy_source.pv_arrays`, which the schema
# dropped at parse, so the leave-one-out scorer's *actual* has_pv read False for
# every full-SAP PV dwelling. Carrying the array now reads the true has_pv=True,
# and one full-SAP target the similarity-weighted donors don't predict as PV
# tips the agreement 32/33 (the held-out actual is now correct — a ground-truth-
# method change, not a prediction-logic loosening). Tighten-only resumes here.
_RATE_FLOORS: dict[str, float] = {
"wall_construction": 0.9091,
"wall_insulation_type": 0.8687,
@ -76,7 +85,7 @@ _RATE_FLOORS: dict[str, float] = {
"floor_insulation": 0.9375,
"has_room_in_roof": 0.9495,
"modal_glazing_type": 0.8384,
"has_pv": 0.9798,
"has_pv": 0.9697,
"solar_water_heating": 1.0000,
}

View file

@ -54,6 +54,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_apply_heat_network_hiu_default_store, # pyright: ignore[reportPrivateUsage]
_cylinder_thermostat_present, # pyright: ignore[reportPrivateUsage]
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_heat_pump_apm_efficiencies, # pyright: ignore[reportPrivateUsage]
_heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage]
_heat_network_community_fuel_code, # pyright: ignore[reportPrivateUsage]
_heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage]
@ -4841,6 +4842,57 @@ def test_hot_water_from_pcdb_heat_pump_bills_at_app_n_wh_high_rate() -> None:
assert abs(rate_immersion - 0.0750) <= 1e-6
def test_heat_pump_water_efficiency_is_floored_at_100pct_per_app_n3_7() -> None:
# Arrange — SAP 10.2 Appendix N3.7 ("Thermal efficiency for water
# heating heat pumps", PDF p.109): "multiply the thermal efficiency
# (ηwater) for water heating by the in-use factor in Table N8; subject
# to a MINIMUM EFFICIENCY OF 100%." Our `_heat_pump_apm_efficiencies`
# applied the in-use factor but omitted the floor, so an oversized heat
# pump whose PSR-extended ηwater × 0.60 in-use fell below 100% billed
# water heating at that sub-100% efficiency (over-counting HW fuel).
#
# Accredited anchor: golden fixture case 56 (PCDB 100061, the config of
# cert 100110101713). At HLC 107.82 W/K the PSR is 3.107, above the
# record's largest PSR 2.0, so the Appendix N2 extension takes ηwater,3
# from 198.9% toward 100% at 2 x 2.0 = 4.0 → 128.55%; × the 0.60 in-use
# factor (Open-EPC certs never lodge cylinder HX area → criteria fail)
# = 77.13% < 100% → the worksheet (216) reads 100.0000. In-range PSR
# (case 54, HLC large) keeps 0.60 × 198.9 = 119.34% (worksheet case 54
# (216) = 112.5% for its 187.5% record — both above the floor, unchanged).
from domain.sap10_calculator.tables.pcdb import heat_pump_record
record = heat_pump_record(100061)
assert record is not None
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity (heat pump)
heat_emitter_type=1, # radiators
emitter_temperature=0,
main_heating_control=2210,
main_heating_category=4,
sap_main_heating_code=None,
main_heating_index_number=100061,
)
epc = _typical_semi_detached_epc() # no specified cylinder → in-use 0.60
# Act — oversized PSR (extension region) vs an in-range PSR.
_space_ext, water_ext = _heat_pump_apm_efficiencies(
main=hp_main, hp_record=record,
hlc_annual_avg_w_per_k=107.82, # PSR 3.107 > largest 2.0
epc=epc,
) or (None, None)
_space_in, water_in = _heat_pump_apm_efficiencies(
main=hp_main, hp_record=record,
hlc_annual_avg_w_per_k=400.0, # PSR 0.837, in range
epc=epc,
) or (None, None)
# Assert — extended HP water efficiency is floored at 100% (1.0); the
# in-range PSR keeps the un-floored 0.60 × 198.9% = 119.34%.
assert water_ext is not None and abs(water_ext - 1.0) < 1e-9
assert water_in is not None and abs(water_in - 0.60 * 198.9 / 100.0) < 1e-9
def test_hot_water_immersion_off_peak_bills_at_table_13_blend() -> None:
# Arrange — SAP 10.2 Table 12a (PDF p.191) "Immersion water heater"
# row routes the WH column to Table 13 (PDF p.197). For an electric

View file

@ -180,3 +180,88 @@ def test_interpolate_heat_pump_efficiency_at_cert_0380_psr_per_sap_app_n() -> No
# ≈ 0.0035077 → eta_water_3 ≈ 285.0861
assert abs(eta_space_1 - 234.5235) < 1e-3
assert abs(eta_water_3 - 285.0861) < 1e-3
def test_interpolate_extends_above_largest_psr_toward_100pct_per_app_n() -> None:
"""SAP 10.2 Appendix N2 (PDF p.101, footnote 44/45) — PSR above the
record's largest value extends the efficiency toward 100%, it is NOT
clamped to the top-of-table value.
"in the case of a heat pump (ground, water or air source), where
the PSR is greater than the largest value in the data record, an
efficiency may be obtained from linear interpolation between that
at the largest PSR in the data record and efficiency 100% at PSR
two times the largest PSR in the data record. If the PSR is
greater than two times the largest PSR in the data record an
efficiency of 100% should be used."
Interpolation is reciprocal-linear (footnote 43). Accredited anchor:
Elmhurst worksheet for cert 100110101713 / "golden fixture debugging"
case 56 (PCDB 100061, ECODAN 8.5 kW, largest PSR row η_space,1=352.0).
The dwelling HLC (39) = 107.8199 W/K and max output 8.106 kW give
PSR = 8.106 × 1000 / (107.8199 × 24.2) = 3.106650
which exceeds the record's largest PSR (2.0). The spec extension to
100% at 2 × 2.0 = 4.0 yields, at t = (3.106650 2.0)/(4.0 2.0):
1/η = (1 t)/352.0 + t/100.0 η_space,1 = 147.011
so that (206) = 0.95 × 147.011 = 139.660 matching the accredited
worksheet exactly. The previous top-of-table clamp returned 352.0
( 334.4%), over-rating the dwelling by +18 SAP.
"""
from domain.sap10_calculator.tables.pcdb.parser import (
interpolate_heat_pump_efficiency_at_psr,
)
record = heat_pump_record(100061)
assert record is not None
assert record.psr_groups[-1].psr == 2.0
assert record.psr_groups[-1].eta_space_1_pct == 352.0
eta_space_1, _eta_water_3 = interpolate_heat_pump_efficiency_at_psr(
record.psr_groups, target_psr=3.106649864134083,
)
assert abs(eta_space_1 - 147.011) < 1e-2
assert abs(0.95 * eta_space_1 - 139.6604) < 1e-2
def test_interpolate_above_twice_largest_psr_is_100pct_per_app_n() -> None:
"""SAP 10.2 Appendix N2 — beyond twice the largest PSR the efficiency
is exactly 100% (the upper terminus of the extension), for both space
and water heating PSR-dependent results."""
from domain.sap10_calculator.tables.pcdb.parser import (
interpolate_heat_pump_efficiency_at_psr,
)
record = heat_pump_record(100061)
assert record is not None
eta_space_1, eta_water_3 = interpolate_heat_pump_efficiency_at_psr(
record.psr_groups, target_psr=9.0, # > 2 × 2.0
)
assert eta_space_1 == 100.0
assert eta_water_3 == 100.0
def test_interpolate_below_smallest_psr_is_100pct_per_app_n() -> None:
"""SAP 10.2 Appendix N2 (PDF p.101) — "For all heat pumps, an
efficiency of 100% should be used if the PSR is less than the smallest
value in the database record." (Not clamped to the smallest row.)"""
from domain.sap10_calculator.tables.pcdb.parser import (
interpolate_heat_pump_efficiency_at_psr,
)
record = heat_pump_record(100061)
assert record is not None
assert record.psr_groups[0].psr == 0.2
eta_space_1, eta_water_3 = interpolate_heat_pump_efficiency_at_psr(
record.psr_groups, target_psr=0.1, # < 0.2
)
assert eta_space_1 == 100.0
assert eta_water_3 == 100.0

View file

@ -122,12 +122,18 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
# (engine uses the cert's measured 0.19/0.11/0.11 U-values; Elmhurst uses
# age-band L proxies + party-wall default) plus FGHRS (cert idx 60031) omitted
# on BOTH sides (the engine can't yet model full-SAP FGHRS). PINNED TO THE
# OBSERVED 82, not lodged 84 — mapping deliberately untuned.
# OBSERVED 83 (was 82), not lodged 84 — mapping deliberately untuned.
# WAS 82 until the full-SAP electricity-tariff → RdSAP meter_type fix: this
# cert lodges energy_tariff=1 (standard), which the mapper previously passed
# through untranslated as RdSAP meter_type "1" — wrongly read as dual/Economy 7
# and priced on the off-peak high/low split. Translating it to "single" (the
# correct standard tariff) re-prices its electricity at the flat rate, lifting
# this gas semi 82→83. No PV (sap_energy_source.pv_arrays absent).
RealCertExpectation(
schema="SAP-Schema-17.1",
sample="uprn_10093116528",
cert_num="8000-8495-2839-2607-9683",
sap_score=82,
sap_score=83,
),
# UPRN 10093116543 → cert 8358-7436-5620-6889-0906. SAP-Schema-17.1 — a
# FULL-SAP cert (2017 mains-gas COMBI semi, Emsworth), forced through the
@ -290,13 +296,18 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
# control 2106 (CBE); water from primary (combi); MEV on; AP50 Blower Door 3.5.
# The 3 vs lodged 85 is the documented full-SAP→RdSAP gap: the engine uses the
# cert's MEASURED U (wall 0.24 / floor 0.13, WORSE than RdSAP band-M defaults)
# + MEV priced as extract loss not heat recovery. PINNED to the observed 82 —
# mapping untuned; engine == Elmhurst.
# + MEV priced as extract loss not heat recovery. PINNED to the observed 84
# (was 82), still 1 vs lodged 85 — mapping untuned.
# WAS 82 until full-SAP lodged PV mapping landed: this cert lodges a 0.38 kWp
# array under sap_energy_source.pv_arrays (SE-facing, pitch 30°, unshaded) that
# the schema dropped at parse, so the Appendix-M generation credit was lost.
# Carrying it (mapper `_sap_17_1_pv_arrays`) credits the generation and lifts
# this flat 82→84, closing most of the gap to the lodged 85 the array explains.
RealCertExpectation(
schema="SAP-Schema-19.1.0",
sample="uprn_10096028301",
cert_num="0390-3321-6060-2405-7985",
sap_score=82,
sap_score=84,
),
# UPRN 44012843 → cert 0775-2898-6628-9594-8005. SAP-Schema-16.3 — a
# reduced-field (RdSAP-shaped) ground-floor FLAT, band K (2007-2011), cavity
@ -326,14 +337,20 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
# worksheet SAP 80 — engine EXACTLY matches (80.13 vs 80); engine-on-Elmhurst's-
# own-parsed-inputs 81.03 ≈ 80 → calculator faithful. Boiler set to the cert's
# exact PCDB 16211 via the search dialog; control 2106 (CBE); water from primary
# (combi); MEV on; AP50 Blower Door 3.2; party wall 6.43 m entered. The 2 vs
# lodged 82 is the documented full-SAP→RdSAP gap (measured U 0.2/0.1 + MEV
# extract loss). PINNED to the observed 80 — mapping untuned; engine == Elmhurst.
# (combi); MEV on; AP50 Blower Door 3.2; party wall 6.43 m entered.
# WAS 80 (engine == Elmhurst, both built WITHOUT PV) until full-SAP lodged PV
# mapping landed: this cert lodges a 0.48 kWp array under
# sap_energy_source.pv_arrays (SE-facing, pitch 30°, unshaded) the schema
# dropped at parse. Crediting it (mapper `_sap_17_1_pv_arrays`) closes the
# 2 gap exactly — the engine now reproduces the accredited lodged 82. The
# Elmhurst worksheet (80) omitted the PV (not entered in the RdSAP build), so
# the +2 over Elmhurst is the now-credited array, not a calculator drift.
# PINNED to the observed 82 == lodged 82 — mapping untuned.
RealCertExpectation(
schema="SAP-Schema-17.0",
sample="uprn_10023444324",
cert_num="8501-5064-6739-1407-0163",
sap_score=80,
sap_score=82,
),
# UPRN 10023444320 → cert 0868-6045-7331-4376-0914. SAP-Schema-17.0 — FULL-SAP
# MID-FLOOR FLAT (sibling of 10023444324, same block / combi PCDB 16211 / MEV),
@ -342,12 +359,24 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
# worksheet 82 — engine within ~1 (81.38 vs 82); engine-on-Elmhurst-inputs 82.46
# ≈ 82 → calculator faithful. Boiler PCDB 16211 via search; control 2106 (CBE);
# water from primary (combi); MEV on; AP50 Blower Door 3.09; mid-floor (floor =
# another dwelling below). PINNED to the observed 81 — mapping untuned.
# another dwelling below).
# WAS 81 until full-SAP lodged PV mapping landed: this cert lodges the SAME
# 0.48 kWp array as its ground-floor sibling 10023444324 under
# sap_energy_source.pv_arrays (the block's roof PV apportioned to the flat on
# the lodged cert). Crediting it faithfully (mapper `_sap_17_1_pv_arrays`)
# lifts this flat 81→83. NOTE this lands +2 OVER the lodged 81 (and +1 over the
# Elmhurst worksheet 82) — unlike the ground-floor sibling whose pre-PV engine
# was 2 UNDER lodged so the same array closed the gap exactly. The mid-floor's
# pre-PV engine already matched lodged, so the credited array now overshoots:
# the lodged 81 does not appear to carry the array's full generation credit
# that SAP Appendix-M awards it. This is the documented full-SAP→RdSAP residual
# (faithful to the cert's lodged PV, not tuned to the lodged integer). PINNED
# to the observed 83 — mapping untuned.
RealCertExpectation(
schema="SAP-Schema-17.0",
sample="uprn_10023444320",
cert_num="0868-6045-7331-4376-0914",
sap_score=81,
sap_score=83,
),
# UPRN 10090844932 → cert 0646-3008-6208-0619-6204. RdSAP-Schema-20.0.0 —
# END-TERRACE HOUSE, 2-storey, band L (2012-2022), cavity insulated, pitched

View file

@ -193,7 +193,10 @@ _CORPUS = Path(
# within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815. Surfaced by Khalim's Elmhurst
# stress worksheet (simulated case 46): closed its last ventilation residual
# (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst).
_MIN_WITHIN_HALF_SAP = 0.74
# 0.74 -> 0.742 via the heat-pump water-heating 100% floor (App N3.7): cert
# 100110101713 moves inside +-0.5 (|err| 4.97 -> 0.49). See the _MAX_SAP_MAE
# log below for the paired space-heating PSR-extension + water-floor slices.
_MIN_WITHIN_HALF_SAP = 0.742
# 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak
# trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion)
# -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57,
@ -248,7 +251,22 @@ _MIN_WITHIN_HALF_SAP = 0.74
# an identical dwelling rates SAP 87 with "Connected to Dwelling = Yes" (credit
# -£167) vs SAP 74 with "No" (credit £0). Enum decoded empirically: 0 = no PV,
# 1 = not connected, 2 = connected (the gov-API does not expose it elsewhere).
_MAX_SAP_MAE = 0.740
# Then 0.740 -> 0.726 via the heat-pump PSR-extension fix (SAP 10.2 Appendix N2,
# PDF p.101 footnotes 44/45): an air/ground/water source heat pump whose plant
# size ratio exceeds the PCDB record's largest PSR is no longer clamped to the
# top-of-table COP — its efficiency is reciprocal-interpolated toward 100% at
# twice the largest PSR (and 100% below the smallest PSR). Accredited Elmhurst
# worksheet for cert 100110101713 (golden fixture case 56, PCDB 100061, PSR
# 3.107 over largest 2.0): (206) 334.4% -> 139.66% = Elmhurst exact. Only two
# certs move (both oversized-PSR heat pumps): 100110101713 +18.32 -> -4.97 and
# 4510053280 -0.61; within-0.5 holds at 74.1%.
# Then 0.726 -> 0.722 (within-0.5 74.1% -> 74.2%) via the heat-pump water-
# heating 100% floor (SAP 10.2 Appendix N3.7, PDF p.109: in-use x eta_water
# subject to a minimum efficiency of 100%). Only 100110101713 moves: its
# oversized-PSR water eff 0.60 x 128.55% = 77.13% is floored to 100% (=
# accredited Elmhurst (216)), taking the cert 68.03 -> 72.51 (|err| 4.97 ->
# 0.49, now inside +-0.5). In-range heat pumps keep their > 100% water COP.
_MAX_SAP_MAE = 0.722
_MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 3.5 # kWh / m2 / yr vs energy_consumption_current

View file

@ -17,7 +17,8 @@ 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.epc.epc_postgres_repository import EpcSaveRequest
from repositories.plan.plan_repository import PlanRepository, PlanSaveRequest
from repositories.product.product_repository import ProductRepository
from repositories.property_baseline.property_baseline_repository import PropertyBaselineRepository
from repositories.epc.epc_repository import EpcRepository, EpcSource
@ -130,6 +131,9 @@ class FakeEpcRepo(EpcRepository):
if property_id in self._predicted_by_property
}
def save_batch(self, requests: list[EpcSaveRequest]) -> list[int]:
return [self.save(r.data, r.property_id, r.portfolio_id, r.source) for r in requests]
class FakeSolarRepo(SolarRepository):
"""In-memory Google Solar insights store keyed by UPRN. Seed `by_uprn` to
@ -218,6 +222,18 @@ class FakePlanRepository(PlanRepository):
self._next_id += 1
return plan_id
def save_batch(self, requests: list[PlanSaveRequest]) -> list[int]:
return [
self.save(
r.plan,
property_id=r.property_id,
scenario_id=r.scenario_id,
portfolio_id=r.portfolio_id,
is_default=r.is_default,
)
for r in requests
]
class _UnsetProductRepo(ProductRepository):
"""Default for a `FakeUnitOfWork` built without a catalogue — raises if a

View file

@ -0,0 +1,167 @@
"""Batch EPC write path — save_batch() correctness and safety tests.
Guards the four user stories from #1348:
1. FK mis-wiring regression: building-part IDs must not be crossed between
properties in the same save_batch() call.
2. save()/save_batch() parity: the single-property delegation path is loss-free.
3. Batch idempotency: a second save_batch() with the same requests replaces,
not duplicates.
4. Source isolation: lodged and predicted slots coexist after separate
save_batch() calls on the same property IDs.
"""
from __future__ import annotations
import json
from dataclasses import replace
from pathlib import Path
from typing import Any
from sqlalchemy import Engine
from sqlmodel import Session
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from repositories.epc.epc_postgres_repository import EpcPostgresRepository, EpcSaveRequest
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
def _load_epc(schema_dir: str = "RdSAP-Schema-21.0.0") -> EpcPropertyData:
raw: dict[str, Any] = json.loads(
(_JSON_SAMPLES / schema_dir / "epc.json").read_text()
)
return EpcPropertyDataMapper.from_api_response(raw)
def _with_floor_areas(epc: EpcPropertyData, areas_m2: list[float]) -> EpcPropertyData:
"""Replace the building parts with variants that have a single floor dimension
carrying the given total_floor_area_m2 making them easy to distinguish after
a round-trip without changing anything else about the EPC."""
template_bp = epc.sap_building_parts[0]
template_dim = template_bp.sap_floor_dimensions[0]
new_parts = [
replace(template_bp, sap_floor_dimensions=[replace(template_dim, total_floor_area_m2=a)])
for a in areas_m2
]
return replace(epc, sap_building_parts=new_parts)
# ---------------------------------------------------------------------------
# Tracer bullet: single-request save_batch() is loss-free vs save()
# ---------------------------------------------------------------------------
def test_single_request_save_batch_matches_save(db_engine: Engine) -> None:
# Arrange
epc = _load_epc()
with Session(db_engine) as session:
repo = EpcPostgresRepository(session)
epc_id_via_save = repo.save(epc, property_id=1001)
epc_id_via_batch = repo.save_batch([EpcSaveRequest(epc, property_id=1002)])[0]
session.commit()
# Act
with Session(db_engine) as session:
repo = EpcPostgresRepository(session)
via_save = repo.get(epc_id_via_save)
via_batch = repo.get(epc_id_via_batch)
# Assert — both paths reconstruct the original exactly.
assert via_save == epc
assert via_batch == epc
# ---------------------------------------------------------------------------
# FK mis-wiring regression: building-part IDs must not be crossed
# ---------------------------------------------------------------------------
def test_multi_property_building_part_ids_are_not_crossed(db_engine: Engine) -> None:
# Arrange — property A has 2 parts with distinctive areas; B has 1 with a
# third distinctive area. If part IDs are mis-wired the floor-dimension FK
# rows end up under the wrong property.
base = _load_epc()
epc_a = _with_floor_areas(base, [10.0, 20.0])
epc_b = _with_floor_areas(base, [99.0])
with Session(db_engine) as session:
repo = EpcPostgresRepository(session)
repo.save_batch([
EpcSaveRequest(epc_a, property_id=2001),
EpcSaveRequest(epc_b, property_id=2002),
])
session.commit()
# Act
with Session(db_engine) as session:
repo = EpcPostgresRepository(session)
reloaded_a = repo.get_for_property(2001)
reloaded_b = repo.get_for_property(2002)
# Assert — each property's building parts carry its own floor areas.
assert reloaded_a is not None
assert reloaded_b is not None
areas_a = sorted(
dim.total_floor_area_m2
for part in reloaded_a.sap_building_parts
for dim in part.sap_floor_dimensions
)
areas_b = sorted(
dim.total_floor_area_m2
for part in reloaded_b.sap_building_parts
for dim in part.sap_floor_dimensions
)
assert areas_a == [10.0, 20.0]
assert areas_b == [99.0]
# ---------------------------------------------------------------------------
# Idempotency: second save_batch() replaces, not duplicates
# ---------------------------------------------------------------------------
def test_save_batch_is_idempotent(db_engine: Engine) -> None:
# Arrange
epc = _load_epc()
requests = [EpcSaveRequest(epc, property_id=3001)]
with Session(db_engine) as session:
EpcPostgresRepository(session).save_batch(requests)
session.commit()
# Act — re-save the same batch.
with Session(db_engine) as session:
EpcPostgresRepository(session).save_batch(requests)
session.commit()
# Assert — exactly one EPC survives (no duplicate rows).
with Session(db_engine) as session:
result = EpcPostgresRepository(session).get_for_property(3001)
assert result == epc
# ---------------------------------------------------------------------------
# Source isolation: lodged and predicted slots survive separate batch saves
# ---------------------------------------------------------------------------
def test_lodged_and_predicted_batch_slots_are_independent(db_engine: Engine) -> None:
# Arrange — two properties each get a lodged EPC and then a predicted EPC
# via separate save_batch() calls.
epc = _load_epc()
property_ids = [4001, 4002]
with Session(db_engine) as session:
repo = EpcPostgresRepository(session)
repo.save_batch([EpcSaveRequest(epc, property_id=pid, source="lodged") for pid in property_ids])
repo.save_batch([EpcSaveRequest(epc, property_id=pid, source="predicted") for pid in property_ids])
session.commit()
# Act
with Session(db_engine) as session:
repo = EpcPostgresRepository(session)
lodged = repo.get_for_properties(property_ids)
predicted = repo.get_predicted_for_properties(property_ids)
# Assert — both slots are populated for both properties.
assert lodged == {4001: epc, 4002: epc}
assert predicted == {4001: epc, 4002: epc}

View file

@ -0,0 +1,183 @@
"""Batch plan write path — save_batch() correctness and safety tests.
Guards the four user stories from #1355:
1. save()/save_batch() parity: a single-element save_batch() produces
identical DB state (plan row + recommendation rows) as the equivalent
save() call.
2. Recommendation FK isolation: two properties in the same save_batch() each
get their own recommendation rows; no FK cross-wiring between properties.
3. Demote correctness: a second save_batch() for the same properties demotes
the prior default Plans and inserts fresh ones (history preserved).
4. Non-default batch: a save_batch() where all writes have is_default=False
leaves any pre-existing default Plan untouched.
"""
from __future__ import annotations
from sqlalchemy import Engine
from sqlmodel import Session, col, select
from domain.modelling.measure_type import MeasureType
from domain.modelling.plan import Plan, PlanMeasure
from domain.modelling.recommendation import Cost
from domain.modelling.scoring.package_scorer import Score
from domain.modelling.scoring.scoring import MeasureImpact
from infrastructure.postgres.modelling import PlanModel, RecommendationModel
from repositories.plan.plan_postgres_repository import PlanPostgresRepository
from repositories.plan.plan_repository import PlanSaveRequest
def _plan(*, sap: float = 70.0, measures: int = 1) -> Plan:
ms: tuple[PlanMeasure, ...] = tuple(
PlanMeasure(
measure_type=MeasureType.CAVITY_WALL_INSULATION,
description="Cavity wall insulation",
cost=Cost(total=1000.0, contingency_rate=0.10),
impact=MeasureImpact(
sap_points=8.0,
co2_savings_kg_per_yr=500.0,
energy_savings_kwh_per_yr=2000.0,
),
kwh_savings=1500.0,
energy_cost_savings=300.0,
)
for _ in range(measures)
)
return Plan(
measures=ms,
baseline=Score(sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0),
post_retrofit=Score(sap_continuous=sap, co2_kg_per_yr=3500.0, primary_energy_kwh_per_yr=18000.0),
)
# ---------------------------------------------------------------------------
# Tracer bullet: single-element save_batch() is loss-free vs save()
# ---------------------------------------------------------------------------
def test_single_request_save_batch_matches_save(db_engine: Engine) -> None:
# Arrange
plan = _plan()
scenario_id = 7
with Session(db_engine) as session:
repo = PlanPostgresRepository(session)
save_id = repo.save(plan, property_id=5001, scenario_id=scenario_id, portfolio_id=1, is_default=True)
batch_id = repo.save_batch([PlanSaveRequest(plan, property_id=5002, scenario_id=scenario_id, portfolio_id=1, is_default=True)])[0]
session.commit()
# Act
with Session(db_engine) as session:
via_save = session.get(PlanModel, save_id)
via_batch = session.get(PlanModel, batch_id)
recs_save = session.exec(select(RecommendationModel).where(col(RecommendationModel.plan_id) == save_id)).all()
recs_batch = session.exec(select(RecommendationModel).where(col(RecommendationModel.plan_id) == batch_id)).all()
# Assert — both paths produce one plan row + one recommendation row with the
# same field values (modulo property_id which differs by design).
assert via_save is not None
assert via_batch is not None
assert via_save.is_default is True
assert via_batch.is_default is True
assert via_save.post_sap_points == via_batch.post_sap_points
assert via_save.post_co2_emissions == via_batch.post_co2_emissions
assert via_save.co2_savings == via_batch.co2_savings
assert len(recs_save) == 1
assert len(recs_batch) == 1
assert recs_save[0].type == recs_batch[0].type
assert recs_save[0].estimated_cost == recs_batch[0].estimated_cost
assert recs_save[0].sap_points == recs_batch[0].sap_points
assert recs_batch[0].plan_id == batch_id
# ---------------------------------------------------------------------------
# FK isolation: recommendation rows must not be crossed between properties
# ---------------------------------------------------------------------------
def test_multi_property_recommendation_fks_are_not_crossed(db_engine: Engine) -> None:
# Arrange — property A gets 2 measures, property B gets 1 measure.
plan_a = _plan(measures=2)
plan_b = _plan(measures=1)
with Session(db_engine) as session:
[id_a, id_b] = PlanPostgresRepository(session).save_batch([
PlanSaveRequest(plan_a, property_id=6001, scenario_id=7, portfolio_id=1, is_default=True),
PlanSaveRequest(plan_b, property_id=6002, scenario_id=7, portfolio_id=1, is_default=True),
])
session.commit()
# Act
with Session(db_engine) as session:
recs_a = session.exec(select(RecommendationModel).where(col(RecommendationModel.plan_id) == id_a)).all()
recs_b = session.exec(select(RecommendationModel).where(col(RecommendationModel.plan_id) == id_b)).all()
# Assert — A has 2 rows, B has 1; none cross-wired.
assert len(recs_a) == 2
assert len(recs_b) == 1
assert all(r.plan_id == id_a and r.property_id == 6001 for r in recs_a)
assert all(r.plan_id == id_b and r.property_id == 6002 for r in recs_b)
# ---------------------------------------------------------------------------
# Demote correctness: second save_batch() demotes prior defaults
# ---------------------------------------------------------------------------
def test_second_save_batch_demotes_prior_default_plans(db_engine: Engine) -> None:
# Arrange — first batch creates default Plans for two properties.
plan = _plan()
requests = [
PlanSaveRequest(plan, property_id=7001, scenario_id=7, portfolio_id=1, is_default=True),
PlanSaveRequest(plan, property_id=7002, scenario_id=7, portfolio_id=1, is_default=True),
]
with Session(db_engine) as session:
first_ids = PlanPostgresRepository(session).save_batch(requests)
session.commit()
# Act — re-run the same batch; new Plans should become default, old ones demoted.
with Session(db_engine) as session:
second_ids = PlanPostgresRepository(session).save_batch(requests)
session.commit()
# Assert — history is preserved (4 plan rows total); exactly one default per property.
with Session(db_engine) as session:
rows_7001 = session.exec(select(PlanModel).where(col(PlanModel.property_id) == 7001)).all()
rows_7002 = session.exec(select(PlanModel).where(col(PlanModel.property_id) == 7002)).all()
by_id_7001 = {p.id: p for p in rows_7001}
by_id_7002 = {p.id: p for p in rows_7002}
assert len(rows_7001) == 2
assert len(rows_7002) == 2
assert by_id_7001[first_ids[0]].is_default is False
assert by_id_7001[second_ids[0]].is_default is True
assert by_id_7002[first_ids[1]].is_default is False
assert by_id_7002[second_ids[1]].is_default is True
# ---------------------------------------------------------------------------
# Non-default batch: existing default Plan is untouched
# ---------------------------------------------------------------------------
def test_non_default_save_batch_does_not_demote_existing_default(db_engine: Engine) -> None:
# Arrange — a default Plan already exists for the property.
plan = _plan()
with Session(db_engine) as session:
default_id = PlanPostgresRepository(session).save(
plan, property_id=8001, scenario_id=7, portfolio_id=1, is_default=True
)
session.commit()
# Act — save a non-default Plan via save_batch(); no demote UPDATE should fire.
with Session(db_engine) as session:
PlanPostgresRepository(session).save_batch([
PlanSaveRequest(plan, property_id=8001, scenario_id=7, portfolio_id=1, is_default=False),
])
session.commit()
# Assert — the original default Plan is still the default.
with Session(db_engine) as session:
rows = session.exec(select(PlanModel).where(col(PlanModel.property_id) == 8001)).all()
by_id = {p.id: p for p in rows}
assert len(rows) == 2
assert by_id[default_id].is_default is True
assert sum(1 for p in rows if p.is_default) == 1