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, physical_state_changed=prop.physical_state_changed, ) # 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" )