mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(baseline): wire BillDerivation into the orchestrator and persist the Bill (ADR-0014)
The PropertyBaselineOrchestrator now reads the current Fuel Rates snapshot once per batch, builds a BillDerivation, and prices each scored property's SapResult -> EnergyBreakdown into a Bill carried on PropertyBaselinePerformance (None only on the stub no-calculator path). The Bill is flattened onto nullable bill_* flat columns (per-section kwh+cost, standing charges, SEG credit, total) on the postgres table, with bill_total_annual_bill_gbp as the not-null discriminator on read-back. Section absent from the bill stays None, not 0. Updated all four orchestrator construction sites to inject the FuelRatesRepository port (handler + three test sites), and the FE migration doc to reflect the prefixed columns and that they are now populated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f7dc9dbccb
commit
f179950519
8 changed files with 307 additions and 27 deletions
|
|
@ -23,6 +23,9 @@ from orchestration.ingestion_orchestrator import (
|
|||
)
|
||||
from orchestration.modelling_orchestrator import ModellingOrchestrator
|
||||
from orchestration.task_orchestrator import TaskOrchestrator
|
||||
from repositories.fuel_rates.fuel_rates_static_file_repository import (
|
||||
FuelRatesStaticFileRepository,
|
||||
)
|
||||
from repositories.geospatial.geospatial_repository import GeospatialRepository
|
||||
from repositories.materials.materials_repository import MaterialsRepository
|
||||
from repositories.postgres_unit_of_work import PostgresUnitOfWork
|
||||
|
|
@ -85,6 +88,7 @@ def build_first_run_pipeline(
|
|||
# certs, lodged + divergence-logged at/above 10.2; a raise aborts the
|
||||
# batch (ADR-0013 amendment).
|
||||
rebaseliner=CalculatorRebaseliner(Sap10Calculator()),
|
||||
fuel_rates=FuelRatesStaticFileRepository(),
|
||||
),
|
||||
modelling=ModellingOrchestrator(
|
||||
scenario_repo=ScenarioRepository(),
|
||||
|
|
|
|||
|
|
@ -37,26 +37,32 @@ Produced by **Bill Derivation**: the calculator's **delivered** kWh per end use
|
|||
Per-section kWh is *delivered fuel* (demand ÷ efficiency — what the household pays for), distinct
|
||||
from the recorded-demand `space_heating_kwh`/`water_heating_kwh` above which it supersedes.
|
||||
|
||||
All columns below are **nullable** (every one is `Optional[float]`, default `None`) and **FE-owned
|
||||
(Drizzle)**. The `bill_` prefix is deliberate: it keeps the per-section columns from clashing with
|
||||
the recorded-demand `space_heating_kwh` / `water_heating_kwh` above. The whole block is `None` for
|
||||
one row together when no calculator ran (the stub path produced no `SapResult` to price); a section
|
||||
absent from the bill leaves its two columns `None` (not `0` — it was not billed). `to_domain` uses
|
||||
`bill_total_annual_bill_gbp IS NOT NULL` as the discriminator for "a bill was persisted".
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `fuel_rates_period` | text | which Fuel Rates snapshot priced this bill (e.g. `"2026-04 to 2026-06"`) — provenance |
|
||||
| `heating_kwh` | float | delivered fuel kWh (main + secondary heating) |
|
||||
| `heating_cost_gbp` | float | priced at the heating fuel's current rate |
|
||||
| `hot_water_kwh` | float | |
|
||||
| `hot_water_cost_gbp` | float | |
|
||||
| `lighting_kwh` | float | |
|
||||
| `lighting_cost_gbp` | float | |
|
||||
| `appliances_kwh` | float | unregulated load — **0 until the appliances/cooking fields land on `SapResult`** (ADR-0014 TODO) |
|
||||
| `appliances_cost_gbp` | float | |
|
||||
| `cooking_kwh` | float | unregulated load — 0 until `SapResult` carries it |
|
||||
| `cooking_cost_gbp` | float | |
|
||||
| `pumps_fans_kwh` | float | |
|
||||
| `pumps_fans_cost_gbp` | float | |
|
||||
| `cooling_kwh` | float | mostly 0 in UK homes; carried for completeness as it affects the bill |
|
||||
| `cooling_cost_gbp` | float | |
|
||||
| `standing_charges_gbp` | float | daily standing charge × 365, once per distinct metered fuel (off-gas fuels have none) |
|
||||
| `seg_credit_gbp` | float | SEG export credit on PV (subtracted) |
|
||||
| `total_annual_bill_gbp` | float | Σ section costs + standing charges − SEG |
|
||||
| `bill_heating_kwh` | float, nullable | delivered fuel kWh (main + main-2 + secondary heating) |
|
||||
| `bill_heating_cost_gbp` | float, nullable | priced at the heating fuel's current rate |
|
||||
| `bill_hot_water_kwh` | float, nullable | |
|
||||
| `bill_hot_water_cost_gbp` | float, nullable | |
|
||||
| `bill_lighting_kwh` | float, nullable | |
|
||||
| `bill_lighting_cost_gbp` | float, nullable | |
|
||||
| `bill_appliances_kwh` | float, nullable | unregulated load — `None` until the appliances field lands on `SapResult` |
|
||||
| `bill_appliances_cost_gbp` | float, nullable | |
|
||||
| `bill_cooking_kwh` | float, nullable | unregulated load — `None` until `SapResult` carries it |
|
||||
| `bill_cooking_cost_gbp` | float, nullable | |
|
||||
| `bill_pumps_fans_kwh` | float, nullable | |
|
||||
| `bill_pumps_fans_cost_gbp` | float, nullable | |
|
||||
| `bill_cooling_kwh` | float, nullable | mostly absent in UK homes; carried for completeness as it affects the bill |
|
||||
| `bill_cooling_cost_gbp` | float, nullable | |
|
||||
| `bill_standing_charges_gbp` | float, nullable | daily standing charge × 365, once per distinct metered fuel (off-gas fuels have none) |
|
||||
| `bill_seg_credit_gbp` | float, nullable | SEG export credit on PV (subtracted) |
|
||||
| `bill_total_annual_bill_gbp` | float, nullable | Σ section costs + standing charges − SEG; the not-null discriminator for a persisted bill |
|
||||
|
||||
The calculator is **load-bearing** (ADR-0013 amendment): for `sap_version < 10.2` the `effective_*`
|
||||
columns hold the calculator's output (so `effective_* != lodged_*` legitimately); at/above 10.2 they
|
||||
|
|
@ -65,7 +71,8 @@ batch rather than persisting a wrong row.
|
|||
|
||||
### Population timing
|
||||
|
||||
The bill columns are **defined now so the FE can create them**, but are populated only once the
|
||||
`SapResult` → `EnergyBreakdown` adapter + `BillDerivation` wiring land (gated on the appliances /
|
||||
cooking `SapResult` fields). Until then the SQLModel mirror in `infrastructure/postgres/` adds these
|
||||
columns as nullable; the Drizzle migration can create them nullable in parallel.
|
||||
The bill columns are now **populated**: the `PropertyBaselineOrchestrator` reads the current Fuel
|
||||
Rates snapshot, builds a `BillDerivation`, and prices every scored property's `SapResult` →
|
||||
`EnergyBreakdown` into a `Bill` that `from_domain` flattens onto these columns. They stay `None`
|
||||
together only on the stub (no-calculator) path. The appliances / cooking sections remain `None`
|
||||
until those fields land on `SapResult`. The Drizzle migration creates all `bill_*` columns nullable.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from domain.property_baseline.bill import Bill
|
||||
from domain.property_baseline.performance import Performance
|
||||
from domain.property_baseline.rebaseliner import RebaselineReason
|
||||
|
||||
|
|
@ -17,8 +19,10 @@ class PropertyBaselinePerformance:
|
|||
|
||||
Carries the part of the energy block that needs no derivation: annual
|
||||
``space_heating_kwh`` / ``water_heating_kwh`` read off the EPC's RHI.
|
||||
Fuel split and bills (the rest of EPC Energy Derivation) land in a
|
||||
follow-up once a Fuel Rates source exists.
|
||||
|
||||
Carries the derived ``bill`` (ADR-0014): the calculator's delivered kWh per
|
||||
end use priced at current Fuel Rates. It is ``None`` only when no calculator
|
||||
ran (the stub path produced no ``SapResult`` to price).
|
||||
"""
|
||||
|
||||
lodged: Performance
|
||||
|
|
@ -26,3 +30,4 @@ class PropertyBaselinePerformance:
|
|||
rebaseline_reason: RebaselineReason
|
||||
space_heating_kwh: float
|
||||
water_heating_kwh: float
|
||||
bill: Optional[Bill] = None
|
||||
|
|
|
|||
|
|
@ -5,10 +5,22 @@ from typing import ClassVar, Optional, cast
|
|||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
from domain.property_baseline.bill import Bill, BillSection, BillSectionCost
|
||||
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
|
||||
from domain.property_baseline.performance import Performance
|
||||
from domain.property_baseline.rebaseliner import RebaselineReason
|
||||
|
||||
# Each Bill section's flat-column stem (``bill_{stem}_kwh`` / ``bill_{stem}_cost_gbp``).
|
||||
_SECTION_COLUMN_STEM: dict[BillSection, str] = {
|
||||
BillSection.HEATING: "heating",
|
||||
BillSection.HOT_WATER: "hot_water",
|
||||
BillSection.LIGHTING: "lighting",
|
||||
BillSection.APPLIANCES: "appliances",
|
||||
BillSection.COOKING: "cooking",
|
||||
BillSection.PUMPS_FANS: "pumps_fans",
|
||||
BillSection.COOLING: "cooling",
|
||||
}
|
||||
|
||||
|
||||
class PropertyBaselinePerformanceModel(SQLModel, table=True):
|
||||
"""The ``property_baseline_performance`` row — one per Property (ADR-0004).
|
||||
|
|
@ -38,11 +50,32 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True):
|
|||
space_heating_kwh: float
|
||||
water_heating_kwh: float
|
||||
|
||||
# Bill Derivation block (ADR-0014 §6). Nullable: all None when no calculator
|
||||
# ran (stub path). The ``bill_`` prefix avoids clashing with the
|
||||
# recorded-demand ``space_heating_kwh`` / ``water_heating_kwh`` above.
|
||||
bill_heating_kwh: Optional[float] = Field(default=None)
|
||||
bill_heating_cost_gbp: Optional[float] = Field(default=None)
|
||||
bill_hot_water_kwh: Optional[float] = Field(default=None)
|
||||
bill_hot_water_cost_gbp: Optional[float] = Field(default=None)
|
||||
bill_lighting_kwh: Optional[float] = Field(default=None)
|
||||
bill_lighting_cost_gbp: Optional[float] = Field(default=None)
|
||||
bill_appliances_kwh: Optional[float] = Field(default=None)
|
||||
bill_appliances_cost_gbp: Optional[float] = Field(default=None)
|
||||
bill_cooking_kwh: Optional[float] = Field(default=None)
|
||||
bill_cooking_cost_gbp: Optional[float] = Field(default=None)
|
||||
bill_pumps_fans_kwh: Optional[float] = Field(default=None)
|
||||
bill_pumps_fans_cost_gbp: Optional[float] = Field(default=None)
|
||||
bill_cooling_kwh: Optional[float] = Field(default=None)
|
||||
bill_cooling_cost_gbp: Optional[float] = Field(default=None)
|
||||
bill_standing_charges_gbp: Optional[float] = Field(default=None)
|
||||
bill_seg_credit_gbp: Optional[float] = Field(default=None)
|
||||
bill_total_annual_bill_gbp: Optional[float] = Field(default=None)
|
||||
|
||||
@classmethod
|
||||
def from_domain(
|
||||
cls, baseline: PropertyBaselinePerformance, property_id: int
|
||||
) -> "PropertyBaselinePerformanceModel":
|
||||
return cls(
|
||||
model = cls(
|
||||
property_id=property_id,
|
||||
lodged_sap_score=baseline.lodged.sap_score,
|
||||
lodged_epc_band=baseline.lodged.epc_band.value,
|
||||
|
|
@ -56,6 +89,26 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True):
|
|||
space_heating_kwh=baseline.space_heating_kwh,
|
||||
water_heating_kwh=baseline.water_heating_kwh,
|
||||
)
|
||||
model._write_bill(baseline.bill)
|
||||
return model
|
||||
|
||||
def _write_bill(self, bill: Optional[Bill]) -> None:
|
||||
"""Flatten the Bill onto the ``bill_*`` columns. When ``bill`` is None
|
||||
(no calculator ran) every bill column is left None; a section absent from
|
||||
the mapping leaves its two columns None (None != 0 — it was not billed)."""
|
||||
if bill is None:
|
||||
return
|
||||
for section, stem in _SECTION_COLUMN_STEM.items():
|
||||
cost = bill.sections.get(section)
|
||||
setattr(self, f"bill_{stem}_kwh", cost.kwh if cost is not None else None)
|
||||
setattr(
|
||||
self,
|
||||
f"bill_{stem}_cost_gbp",
|
||||
cost.cost_gbp if cost is not None else None,
|
||||
)
|
||||
self.bill_standing_charges_gbp = bill.standing_charges_gbp
|
||||
self.bill_seg_credit_gbp = bill.seg_credit_gbp
|
||||
self.bill_total_annual_bill_gbp = bill.total_gbp
|
||||
|
||||
def to_domain(self) -> PropertyBaselinePerformance:
|
||||
return PropertyBaselinePerformance(
|
||||
|
|
@ -74,4 +127,26 @@ class PropertyBaselinePerformanceModel(SQLModel, table=True):
|
|||
rebaseline_reason=cast(RebaselineReason, self.rebaseline_reason),
|
||||
space_heating_kwh=self.space_heating_kwh,
|
||||
water_heating_kwh=self.water_heating_kwh,
|
||||
bill=self._read_bill(),
|
||||
)
|
||||
|
||||
def _read_bill(self) -> Optional[Bill]:
|
||||
"""Reconstruct the Bill from the ``bill_*`` columns. The total is the
|
||||
not-None discriminator: a persisted bill always sets it, so its absence
|
||||
means no calculator ran and the bill was None. A section is rebuilt only
|
||||
when its kWh column is not None (paired with its cost)."""
|
||||
if self.bill_total_annual_bill_gbp is None:
|
||||
return None
|
||||
sections: dict[BillSection, BillSectionCost] = {}
|
||||
for section, stem in _SECTION_COLUMN_STEM.items():
|
||||
kwh = cast(Optional[float], getattr(self, f"bill_{stem}_kwh"))
|
||||
if kwh is None:
|
||||
continue
|
||||
cost_gbp = cast(float, getattr(self, f"bill_{stem}_cost_gbp"))
|
||||
sections[section] = BillSectionCost(kwh=kwh, cost_gbp=cost_gbp)
|
||||
return Bill(
|
||||
sections=sections,
|
||||
standing_charges_gbp=cast(float, self.bill_standing_charges_gbp),
|
||||
seg_credit_gbp=cast(float, self.bill_seg_credit_gbp),
|
||||
total_gbp=self.bill_total_annual_bill_gbp,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
EpcPropertyData,
|
||||
RenewableHeatIncentive,
|
||||
)
|
||||
from domain.property_baseline.bill import EnergyBreakdown
|
||||
from domain.property_baseline.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
|
||||
|
||||
|
||||
|
|
@ -32,11 +35,18 @@ class PropertyBaselineOrchestrator:
|
|||
*,
|
||||
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):
|
||||
|
|
@ -45,6 +55,15 @@ class PropertyBaselineOrchestrator:
|
|||
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
|
||||
)
|
||||
rhi = _require_rhi(effective_epc)
|
||||
baseline = PropertyBaselinePerformance(
|
||||
lodged=lodged,
|
||||
|
|
@ -52,6 +71,7 @@ class PropertyBaselineOrchestrator:
|
|||
rebaseline_reason=rebaselined.reason,
|
||||
space_heating_kwh=rhi.space_heating_kwh,
|
||||
water_heating_kwh=rhi.water_heating_kwh,
|
||||
bill=bill,
|
||||
)
|
||||
uow.property_baseline.save(baseline, property_id)
|
||||
uow.commit()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ from orchestration.modelling_orchestrator import ModellingOrchestrator
|
|||
from repositories.property_baseline.property_baseline_postgres_repository import (
|
||||
PropertyBaselinePostgresRepository,
|
||||
)
|
||||
from repositories.fuel_rates.fuel_rates_static_file_repository import (
|
||||
FuelRatesStaticFileRepository,
|
||||
)
|
||||
from repositories.geospatial.geospatial_repository import GeospatialRepository
|
||||
from repositories.materials.materials_repository import MaterialsRepository
|
||||
from repositories.postgres_unit_of_work import PostgresUnitOfWork
|
||||
|
|
@ -113,6 +116,7 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
|
|||
baseline=PropertyBaselineOrchestrator(
|
||||
unit_of_work=unit_of_work,
|
||||
rebaseliner=StubRebaseliner(),
|
||||
fuel_rates=FuelRatesStaticFileRepository(),
|
||||
),
|
||||
modelling=ModellingOrchestrator(
|
||||
scenario_repo=ScenarioRepository(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
|
|
@ -7,17 +9,31 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
EpcPropertyData,
|
||||
RenewableHeatIncentive,
|
||||
)
|
||||
from domain.fuel_rates.fuel import Fuel
|
||||
from domain.property_baseline.bill import BillSection
|
||||
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
|
||||
from domain.property_baseline.performance import Performance
|
||||
from domain.property_baseline.rebaseliner import RebaselineNotImplemented, StubRebaseliner
|
||||
from domain.property_baseline.rebaseliner import (
|
||||
RebaselineNotImplemented,
|
||||
RebaselineResult,
|
||||
Rebaseliner,
|
||||
StubRebaseliner,
|
||||
)
|
||||
from domain.property.property import Property, PropertyIdentity
|
||||
from domain.sap10_calculator.calculator import SapResult
|
||||
from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator
|
||||
from repositories.fuel_rates.fuel_rates_static_file_repository import (
|
||||
FuelRatesStaticFileRepository,
|
||||
)
|
||||
from tests.orchestration.fakes import (
|
||||
FakePropertyBaselineRepo,
|
||||
FakePropertyRepo,
|
||||
FakeUnitOfWork,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
|
||||
|
||||
def _property(*, sap_version: float) -> Property:
|
||||
epc = object.__new__(EpcPropertyData)
|
||||
|
|
@ -47,13 +63,15 @@ def test_run_establishes_persists_and_commits_the_batch_once() -> None:
|
|||
orchestrator = PropertyBaselineOrchestrator(
|
||||
unit_of_work=lambda: uow,
|
||||
rebaseliner=StubRebaseliner(),
|
||||
fuel_rates=FuelRatesStaticFileRepository(),
|
||||
)
|
||||
|
||||
# Act
|
||||
orchestrator.run([10])
|
||||
|
||||
# Assert — one Baseline Performance persisted (both halves equal, kWh off the
|
||||
# RHI), and the batch committed exactly once.
|
||||
# RHI, no bill because the stub ran no calculator), and the batch committed
|
||||
# exactly once.
|
||||
lodged = Performance(
|
||||
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
|
||||
)
|
||||
|
|
@ -65,6 +83,7 @@ def test_run_establishes_persists_and_commits_the_batch_once() -> None:
|
|||
rebaseline_reason="none",
|
||||
space_heating_kwh=5000.0,
|
||||
water_heating_kwh=2000.0,
|
||||
bill=None,
|
||||
),
|
||||
10,
|
||||
)
|
||||
|
|
@ -82,6 +101,7 @@ def test_run_raises_on_a_pre_sap10_property_and_does_not_commit() -> None:
|
|||
orchestrator = PropertyBaselineOrchestrator(
|
||||
unit_of_work=lambda: uow,
|
||||
rebaseliner=StubRebaseliner(),
|
||||
fuel_rates=FuelRatesStaticFileRepository(),
|
||||
)
|
||||
|
||||
# Act / Assert — the raise propagates; the batch is neither persisted nor
|
||||
|
|
@ -90,3 +110,85 @@ def test_run_raises_on_a_pre_sap10_property_and_does_not_commit() -> None:
|
|||
orchestrator.run([10])
|
||||
assert property_baseline_repo.saved == []
|
||||
assert uow.commits == 0
|
||||
|
||||
|
||||
_LIGHTING_KWH = 400.0
|
||||
|
||||
|
||||
def _sap_result_with_lighting() -> SapResult:
|
||||
"""A minimal scored picture carrying only lighting energy — enough for Bill
|
||||
Derivation to produce one electric section. Mirrors the constructor shape in
|
||||
tests/domain/property_baseline/test_energy_breakdown.py::_sap_result."""
|
||||
return SapResult(
|
||||
sap_score=72,
|
||||
sap_score_continuous=72.0,
|
||||
ecf=0.0,
|
||||
total_fuel_cost_gbp=0.0,
|
||||
co2_kg_per_yr=0.0,
|
||||
space_heating_kwh_per_yr=0.0,
|
||||
space_cooling_kwh_per_yr=0.0,
|
||||
fabric_energy_efficiency_kwh_per_m2_yr=0.0,
|
||||
main_heating_fuel_kwh_per_yr=0.0,
|
||||
main_2_heating_fuel_kwh_per_yr=0.0,
|
||||
secondary_heating_fuel_kwh_per_yr=0.0,
|
||||
space_cooling_fuel_kwh_per_yr=0.0,
|
||||
hot_water_kwh_per_yr=0.0,
|
||||
pumps_fans_kwh_per_yr=0.0,
|
||||
lighting_kwh_per_yr=_LIGHTING_KWH,
|
||||
appliances_kwh_per_yr=0.0,
|
||||
cooking_kwh_per_yr=0.0,
|
||||
main_heating_fuel_code=None,
|
||||
main_2_heating_fuel_code=None,
|
||||
secondary_heating_fuel_code=None,
|
||||
hot_water_fuel_code=None,
|
||||
pv_exported_kwh_per_yr=0.0,
|
||||
primary_energy_kwh_per_yr=0.0,
|
||||
primary_energy_kwh_per_m2=0.0,
|
||||
monthly=(),
|
||||
intermediate={},
|
||||
)
|
||||
|
||||
|
||||
class _ScoringRebaseliner(Rebaseliner):
|
||||
"""A rebaseliner that returns a fixed scored picture (a SapResult) so the
|
||||
orchestrator's Bill Derivation wiring exercises (StubRebaseliner returns
|
||||
sap_result=None, which never bills)."""
|
||||
|
||||
def __init__(self, result: SapResult) -> None:
|
||||
self._result = result
|
||||
|
||||
def rebaseline(
|
||||
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
|
||||
) -> RebaselineResult:
|
||||
return RebaselineResult(
|
||||
effective=lodged, reason="none", sap_result=self._result
|
||||
)
|
||||
|
||||
|
||||
def test_run_derives_and_persists_a_bill_when_the_rebaseliner_scores() -> None:
|
||||
# Arrange — a rebaseliner that hands back a SapResult with lighting energy,
|
||||
# so the orchestrator prices it into a Bill at the committed snapshot.
|
||||
property_baseline_repo = FakePropertyBaselineRepo()
|
||||
uow = FakeUnitOfWork(
|
||||
property=FakePropertyRepo({10: _property(sap_version=10.2)}),
|
||||
property_baseline=property_baseline_repo,
|
||||
)
|
||||
orchestrator = PropertyBaselineOrchestrator(
|
||||
unit_of_work=lambda: uow,
|
||||
rebaseliner=_ScoringRebaseliner(_sap_result_with_lighting()),
|
||||
fuel_rates=FuelRatesStaticFileRepository(),
|
||||
)
|
||||
|
||||
# Act
|
||||
orchestrator.run([10])
|
||||
|
||||
# Assert — the persisted baseline carries a populated bill; the LIGHTING
|
||||
# section is the lighting kWh priced at the snapshot's electricity rate
|
||||
# (read from the snapshot, not hard-coded).
|
||||
rates = FuelRatesStaticFileRepository().get_current()
|
||||
expected_cost = _LIGHTING_KWH * rates.unit_rate_p_per_kwh(Fuel.ELECTRICITY) / 100.0
|
||||
(baseline, _) = property_baseline_repo.saved[0]
|
||||
assert baseline.bill is not None
|
||||
lighting = baseline.bill.sections[BillSection.LIGHTING]
|
||||
assert lighting.kwh == _LIGHTING_KWH
|
||||
assert abs(lighting.cost_gbp - expected_cost) <= 1e-9
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from sqlalchemy import Engine
|
|||
from sqlmodel import Session
|
||||
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
from domain.property_baseline.bill import Bill, BillSection, BillSectionCost
|
||||
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
|
||||
from domain.property_baseline.performance import Performance
|
||||
from repositories.property_baseline.property_baseline_postgres_repository import (
|
||||
|
|
@ -89,3 +90,65 @@ def test_get_for_property_returns_none_when_absent(db_engine: Engine) -> None:
|
|||
|
||||
# Assert
|
||||
assert loaded is None
|
||||
|
||||
|
||||
def _baseline_with_bill() -> PropertyBaselinePerformance:
|
||||
lodged = Performance(
|
||||
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
|
||||
)
|
||||
# A bill with two sections present (HEATING + LIGHTING) and the rest absent —
|
||||
# proves the per-section flattening and the absent-section None round-trip.
|
||||
bill = Bill(
|
||||
sections={
|
||||
BillSection.HEATING: BillSectionCost(kwh=8000.0, cost_gbp=459.2),
|
||||
BillSection.LIGHTING: BillSectionCost(kwh=400.0, cost_gbp=98.68),
|
||||
},
|
||||
standing_charges_gbp=314.18,
|
||||
seg_credit_gbp=12.5,
|
||||
total_gbp=859.56,
|
||||
)
|
||||
return PropertyBaselinePerformance(
|
||||
lodged=lodged,
|
||||
effective=lodged,
|
||||
rebaseline_reason="none",
|
||||
space_heating_kwh=5000.0,
|
||||
water_heating_kwh=2000.0,
|
||||
bill=bill,
|
||||
)
|
||||
|
||||
|
||||
def test_baseline_with_a_bill_round_trips(db_engine: Engine) -> None:
|
||||
# Arrange
|
||||
baseline = _baseline_with_bill()
|
||||
with Session(db_engine) as session:
|
||||
PropertyBaselinePostgresRepository(session).save(baseline, property_id=11)
|
||||
session.commit()
|
||||
|
||||
# Act
|
||||
with Session(db_engine) as session:
|
||||
loaded = PropertyBaselinePostgresRepository(session).get_for_property(11)
|
||||
|
||||
# Assert — the bill survives with its section costs intact; absent sections
|
||||
# stay absent (not zero).
|
||||
assert loaded == baseline
|
||||
assert loaded is not None
|
||||
assert loaded.bill is not None
|
||||
assert set(loaded.bill.sections) == {BillSection.HEATING, BillSection.LIGHTING}
|
||||
|
||||
|
||||
def test_baseline_without_a_bill_round_trips_as_none(db_engine: Engine) -> None:
|
||||
# Arrange — the stub path persists no bill.
|
||||
baseline = _baseline()
|
||||
assert baseline.bill is None
|
||||
with Session(db_engine) as session:
|
||||
PropertyBaselinePostgresRepository(session).save(baseline, property_id=12)
|
||||
session.commit()
|
||||
|
||||
# Act
|
||||
with Session(db_engine) as session:
|
||||
loaded = PropertyBaselinePostgresRepository(session).get_for_property(12)
|
||||
|
||||
# Assert
|
||||
assert loaded == baseline
|
||||
assert loaded is not None
|
||||
assert loaded.bill is None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue