diff --git a/applications/ara_first_run/handler.py b/applications/ara_first_run/handler.py index e82da40f..a546d0f4 100644 --- a/applications/ara_first_run/handler.py +++ b/applications/ara_first_run/handler.py @@ -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(), diff --git a/docs/migrations/property-baseline-performance-table.md b/docs/migrations/property-baseline-performance-table.md index d4846843..0aebba83 100644 --- a/docs/migrations/property-baseline-performance-table.md +++ b/docs/migrations/property-baseline-performance-table.md @@ -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. diff --git a/domain/property_baseline/property_baseline_performance.py b/domain/property_baseline/property_baseline_performance.py index 8da9bbf2..3951611d 100644 --- a/domain/property_baseline/property_baseline_performance.py +++ b/domain/property_baseline/property_baseline_performance.py @@ -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 diff --git a/infrastructure/postgres/property_baseline_performance_table.py b/infrastructure/postgres/property_baseline_performance_table.py index 0e5e1792..908534c0 100644 --- a/infrastructure/postgres/property_baseline_performance_table.py +++ b/infrastructure/postgres/property_baseline_performance_table.py @@ -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, ) diff --git a/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index 3eb55e54..faeaad92 100644 --- a/orchestration/property_baseline_orchestrator.py +++ b/orchestration/property_baseline_orchestrator.py @@ -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() diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index e60ac716..3d6aeb4a 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -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(), diff --git a/tests/orchestration/test_property_baseline_orchestrator.py b/tests/orchestration/test_property_baseline_orchestrator.py index 12c3d660..1e0f5ec2 100644 --- a/tests/orchestration/test_property_baseline_orchestrator.py +++ b/tests/orchestration/test_property_baseline_orchestrator.py @@ -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 diff --git a/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py b/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py index 6395d0f9..a46a65f9 100644 --- a/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py +++ b/tests/repositories/property_baseline/test_property_baseline_postgres_repository.py @@ -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