mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
The PropertyBaselineOrchestrator now reads no Lodged Performance off a predicted Property's neighbour-synthesised EPC: lodged is None when source_path is 'predicted', so no phantom lodged figure is manufactured. The Effective half is unchanged. Rebaseliner port + PropertyBaselinePerformance.lodged widen to Optional; the pristine-cert paths assert non-None (lodged is None only for a predicted Property, which is always physical_state_changed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
113 lines
5.2 KiB
Python
113 lines
5.2 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 Performance, 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
|
|
# A predicted Property has no lodged cert — its Effective EPC is a
|
|
# neighbour-synthesised picture, so its recorded performance fields
|
|
# are a different dwelling's. There is no Lodged Performance to read
|
|
# (ADR-0004 amendment, #1361); only the Effective half is established.
|
|
lodged: Optional[Performance] = (
|
|
None
|
|
if prop.source_path == "predicted"
|
|
else 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"
|
|
)
|