Model/orchestration/property_baseline_orchestrator.py
Jun-te Kim 6679d8c621 Fall back to computed kWh for predicted-EPC baselines (no lodged RHI)
16 modelling_e2e properties failed with "Effective EPC is missing
renewable_heat_incentive; cannot read baseline space-heating / hot-water kWh".

Baseline runs for predicted properties too (ADR-0031), reading space/water-
heating kWh off the EPC's lodged RHI block. Predicted EPCs deep-copy a neighbour
template that may carry no RHI, so `_require_rhi` hard-failed the whole subtask.

Fix: when the EPC has no RHI, fall back to the property's OWN computed figures
from the scored SapResult (space_heating_kwh_per_yr / hot_water_kwh_per_yr) —
more representative than a neighbour's lodged numbers. Only when there is also no
SapResult (the rebaseliner scored nothing) is there genuinely no demand to
record, and we still fail noisily. Lodged certs are unchanged (RHI still wins).

Regression tests: fallback-to-computed, and the no-RHI/no-result raise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:58:24 +00:00

102 lines
4.6 KiB
Python

from __future__ import annotations
from collections.abc import Callable
from typing import Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.billing.bill import EnergyBreakdown
from domain.sap10_calculator.calculator import SapResult
from domain.billing.bill_derivation import BillDerivation
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
from domain.property_baseline.performance import lodged_performance
from domain.property_baseline.rebaseliner import Rebaseliner
from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository
from repositories.unit_of_work import UnitOfWork
class PropertyBaselineOrchestrator:
"""Stage 2: establish each Property's Baseline Performance and persist it.
Runs the whole batch in **one** Unit of Work and commits once (ADR-0012):
for each property it hydrates the Property via the unit's PropertyRepo,
resolves the Effective EPC, reads Lodged Performance off it, runs the
Rebaseliner to produce Effective Performance, and persists the pair plus the
deterministic kWh. Any property raising aborts the batch — the unit is left
uncommitted, so nothing persists and the subtask fails noisily.
Reads only from repos — never a Fetcher or HTTP (ADR-0003) — so it is
byte-identical whether Ingestion ran milliseconds ago (First Run) or last
week. The injected Rebaseliner is the re-score-on-override seam (ADR-0011).
"""
def __init__(
self,
*,
unit_of_work: Callable[[], UnitOfWork],
rebaseliner: Rebaseliner,
fuel_rates: FuelRatesRepository,
) -> None:
self._unit_of_work = unit_of_work
self._rebaseliner = rebaseliner
self._fuel_rates = fuel_rates
def run(self, property_ids: list[int]) -> None:
# The Fuel Rates snapshot is a committed static file (no DB), so read it
# once before the unit opens and reuse the BillDerivation across the
# batch — every property prices against the same snapshot.
fuel_rates = self._fuel_rates.get_current()
bill_derivation = BillDerivation(fuel_rates)
with self._unit_of_work() as uow:
properties = uow.property.get_many(property_ids)
for property_id, prop in zip(property_ids, properties, strict=True):
effective_epc = prop.effective_epc
lodged = lodged_performance(effective_epc)
rebaselined = self._rebaseliner.rebaseline(
property_id, effective_epc, lodged
)
# No SapResult (the stub path) means no scored picture to price,
# so the bill stays None.
bill = (
bill_derivation.derive(
EnergyBreakdown.from_sap_result(rebaselined.sap_result)
)
if rebaselined.sap_result is not None
else None
)
space_heating_kwh, water_heating_kwh = _baseline_kwh(
effective_epc, rebaselined.sap_result
)
baseline = PropertyBaselinePerformance(
lodged=lodged,
effective=rebaselined.effective,
rebaseline_reason=rebaselined.reason,
space_heating_kwh=space_heating_kwh,
water_heating_kwh=water_heating_kwh,
bill=bill,
)
uow.property_baseline.save(baseline, property_id)
uow.commit()
def _baseline_kwh(
epc: EpcPropertyData, sap_result: Optional[SapResult]
) -> tuple[float, float]:
"""Baseline space- and water-heating kWh for the Property.
Lodged certs carry these in the EPC's RHI block (the ADR-0007 ML target).
Predicted EPCs (ADR-0031) deep-copy a neighbour template that may lack an RHI
block, so fall back to the property's OWN computed figures from the scored
SapResult — a better baseline than a neighbour's lodged numbers anyway. When
neither source exists (no RHI and the rebaseliner scored nothing), there is
genuinely no demand to record, so fail noisily.
"""
rhi = epc.renewable_heat_incentive
if rhi is not None:
return rhi.space_heating_kwh, rhi.water_heating_kwh
if sap_result is not None:
return sap_result.space_heating_kwh_per_yr, sap_result.hot_water_kwh_per_yr
raise ValueError(
"Effective EPC has no renewable_heat_incentive and the rebaseliner "
"produced no SapResult; cannot establish baseline space-heating / "
"hot-water kWh"
)