mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
102 lines
4.6 KiB
Python
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"
|
|
)
|