From 05977ee3ce0578046815b298c7e010e1a77671fa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 24 Jun 2026 17:38:37 +0000 Subject: [PATCH] =?UTF-8?q?Route=20an=20off-peak=20meter's=20electric=20en?= =?UTF-8?q?d=20uses=20to=20the=20day/night=20carrier=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/billing/bill.py | 104 ++++++++++++++++-- domain/sap10_calculator/calculator.py | 18 +++ tests/domain/billing/test_energy_breakdown.py | 38 +++++++ 3 files changed, 148 insertions(+), 12 deletions(-) diff --git a/domain/billing/bill.py b/domain/billing/bill.py index 104bece2..a524e145 100644 --- a/domain/billing/bill.py +++ b/domain/billing/bill.py @@ -57,35 +57,74 @@ class EnergyBreakdown: are billed at their resolved fuel (`sap_code_to_fuel`); lighting / pumps- fans / appliances / cooking / cooling are electricity by construction. A line is emitted only when its kWh is positive; PV export carries to - `exported_kwh` for the SEG credit. The `from_*` factory mirrors - `Performance.from_sap_result`; living on the target keeps the calculator - free of any `property_baseline` dependency.""" + `exported_kwh` for the SEG credit. + + On an **Off-Peak Meter** (`result.is_off_peak_meter`) the whole meter is + off-peak: every electric end use bills on `ELECTRICITY_OFF_PEAK`, each + carrying its calculator **High-Rate Fraction** for the day/night split + (appliances / cooking / cooling inherit the `ALL_OTHER_USES` fraction). + The `from_*` factory mirrors `Performance.from_sap_result`; living on the + target keeps the calculator free of any `property_baseline` dependency.""" + off_peak = result.is_off_peak_meter candidates = [ _fuelled_line( BillSection.HEATING, result.main_heating_fuel_code, result.main_heating_fuel_kwh_per_yr, + high_rate_fraction=result.main_heating_high_rate_fraction, + off_peak_meter=off_peak, ), _fuelled_line( BillSection.HEATING, result.main_2_heating_fuel_code, result.main_2_heating_fuel_kwh_per_yr, + high_rate_fraction=result.main_2_heating_high_rate_fraction, + off_peak_meter=off_peak, ), _fuelled_line( BillSection.HEATING, result.secondary_heating_fuel_code, result.secondary_heating_fuel_kwh_per_yr, + high_rate_fraction=result.secondary_heating_high_rate_fraction, + off_peak_meter=off_peak, ), _fuelled_line( BillSection.HOT_WATER, result.hot_water_fuel_code, result.hot_water_kwh_per_yr, + high_rate_fraction=result.hot_water_high_rate_fraction, + off_peak_meter=off_peak, + ), + _electric_line( + BillSection.LIGHTING, + result.lighting_kwh_per_yr, + high_rate_fraction=result.other_electricity_high_rate_fraction, + off_peak_meter=off_peak, + ), + _electric_line( + BillSection.PUMPS_FANS, + result.pumps_fans_kwh_per_yr, + high_rate_fraction=result.pumps_fans_high_rate_fraction, + off_peak_meter=off_peak, + ), + _electric_line( + BillSection.APPLIANCES, + result.appliances_kwh_per_yr, + high_rate_fraction=result.other_electricity_high_rate_fraction, + off_peak_meter=off_peak, + ), + _electric_line( + BillSection.COOKING, + result.cooking_kwh_per_yr, + high_rate_fraction=result.other_electricity_high_rate_fraction, + off_peak_meter=off_peak, + ), + _electric_line( + BillSection.COOLING, + result.space_cooling_fuel_kwh_per_yr, + high_rate_fraction=result.other_electricity_high_rate_fraction, + off_peak_meter=off_peak, ), - _electric_line(BillSection.LIGHTING, result.lighting_kwh_per_yr), - _electric_line(BillSection.PUMPS_FANS, result.pumps_fans_kwh_per_yr), - _electric_line(BillSection.APPLIANCES, result.appliances_kwh_per_yr), - _electric_line(BillSection.COOKING, result.cooking_kwh_per_yr), - _electric_line(BillSection.COOLING, result.space_cooling_fuel_kwh_per_yr), ] return cls( lines=[line for line in candidates if line is not None], @@ -94,7 +133,12 @@ class EnergyBreakdown: def _fuelled_line( - section: BillSection, fuel_code: Optional[int], kwh: float + section: BillSection, + fuel_code: Optional[int], + kwh: float, + *, + high_rate_fraction: float, + off_peak_meter: bool, ) -> Optional[EnergyLine]: """An `EnergyLine` for a fuelled end use, or None when it has no energy. A positive kWh with no resolved fuel code is a data gap — raise rather than @@ -106,14 +150,50 @@ def _fuelled_line( f"{section.value} has {kwh} kWh but no fuel code on the SapResult; " "cannot attribute a billing fuel" ) - return EnergyLine(section=section, fuel=sap_code_to_fuel(fuel_code), kwh=kwh) + return _line( + section, sap_code_to_fuel(fuel_code), kwh, + high_rate_fraction=high_rate_fraction, off_peak_meter=off_peak_meter, + ) -def _electric_line(section: BillSection, kwh: float) -> Optional[EnergyLine]: +def _electric_line( + section: BillSection, + kwh: float, + *, + high_rate_fraction: float, + off_peak_meter: bool, +) -> Optional[EnergyLine]: """An electricity `EnergyLine` for an electric end use, or None when zero.""" if kwh <= 0: return None - return EnergyLine(section=section, fuel=Fuel.ELECTRICITY, kwh=kwh) + return _line( + section, Fuel.ELECTRICITY, kwh, + high_rate_fraction=high_rate_fraction, off_peak_meter=off_peak_meter, + ) + + +def _line( + section: BillSection, + fuel: Fuel, + kwh: float, + *, + high_rate_fraction: float, + off_peak_meter: bool, +) -> EnergyLine: + """Build a line, routing electricity to the Off-Peak Meter carrier. On an + off-peak meter every electric end use (and any end use whose own fuel code + already resolved to off-peak electricity) bills on `ELECTRICITY_OFF_PEAK`, + carrying its High-Rate Fraction for the day/night split. Non-electric fuels + and standard-meter electricity bill flat (no fraction).""" + is_electric = fuel in (Fuel.ELECTRICITY, Fuel.ELECTRICITY_OFF_PEAK) + if is_electric and (off_peak_meter or fuel is Fuel.ELECTRICITY_OFF_PEAK): + return EnergyLine( + section=section, + fuel=Fuel.ELECTRICITY_OFF_PEAK, + kwh=kwh, + high_rate_fraction=high_rate_fraction, + ) + return EnergyLine(section=section, fuel=fuel, kwh=kwh) @dataclass(frozen=True) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 74b038e0..0b406d2a 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -422,6 +422,24 @@ class SapResult: primary_energy_kwh_per_m2: float monthly: tuple[MonthlyEntry, ...] intermediate: dict[str, float] + # Off-Peak Meter day/night billing metadata for ADR-0014 Bill Derivation + # (output-only — NOT fed into ecf / cost / CO2 / PE / sap_score, which the + # rating cascade already prices via the per-end-use cost factors). When the + # dwelling is on an off-peak meter, EVERY electric end use bills on it, each + # split day/night by its own **High-Rate Fraction** (SAP 10.2 Table 12a — the + # share billed at the day/high rate). The fractions are the calculator's own + # Table-12a values, surfaced so the bill reuses them rather than re-deriving + # a second day/night model. Defaults (`False` + 1.0) make a standard-tariff + # dwelling a no-op: the carrier stays `ELECTRICITY` and nothing splits. + is_off_peak_meter: bool = False + main_heating_high_rate_fraction: float = 1.0 + main_2_heating_high_rate_fraction: float = 1.0 + secondary_heating_high_rate_fraction: float = 1.0 + hot_water_high_rate_fraction: float = 1.0 + pumps_fans_high_rate_fraction: float = 1.0 + # Lighting / appliances / cooking / cooling — the Grid 2 ALL_OTHER_USES split + # (appliances + cooking inherit it; SAP does not rate those unregulated loads). + other_electricity_high_rate_fraction: float = 1.0 def _time_constant_h(*, tmp_kj_per_m2_k: float, tfa_m2: float, hlc_w_per_k: float) -> float: diff --git a/tests/domain/billing/test_energy_breakdown.py b/tests/domain/billing/test_energy_breakdown.py index 4c64da29..d7fdb7bc 100644 --- a/tests/domain/billing/test_energy_breakdown.py +++ b/tests/domain/billing/test_energy_breakdown.py @@ -23,6 +23,13 @@ def _sap_result( appliances_kwh_per_yr: float = 0.0, cooking_kwh_per_yr: float = 0.0, pv_exported_kwh_per_yr: float = 0.0, + is_off_peak_meter: bool = False, + main_heating_high_rate_fraction: float = 1.0, + main_2_heating_high_rate_fraction: float = 1.0, + secondary_heating_high_rate_fraction: float = 1.0, + hot_water_high_rate_fraction: float = 1.0, + pumps_fans_high_rate_fraction: float = 1.0, + other_electricity_high_rate_fraction: float = 1.0, ) -> SapResult: return SapResult( sap_score=72, @@ -51,6 +58,13 @@ def _sap_result( primary_energy_kwh_per_m2=0.0, monthly=(), intermediate={}, + is_off_peak_meter=is_off_peak_meter, + main_heating_high_rate_fraction=main_heating_high_rate_fraction, + main_2_heating_high_rate_fraction=main_2_heating_high_rate_fraction, + secondary_heating_high_rate_fraction=secondary_heating_high_rate_fraction, + hot_water_high_rate_fraction=hot_water_high_rate_fraction, + pumps_fans_high_rate_fraction=pumps_fans_high_rate_fraction, + other_electricity_high_rate_fraction=other_electricity_high_rate_fraction, ) @@ -146,3 +160,27 @@ def test_positive_heating_kwh_with_no_fuel_code_raises() -> None: # Act / Assert with pytest.raises(ValueError, match="no fuel code"): EnergyBreakdown.from_sap_result(result) + + +def test_off_peak_heating_line_carries_its_carrier_and_high_rate_fraction() -> None: + # Arrange — electric storage heating on an Off-Peak Meter (7-hour low-rate + # code 31), charged wholly overnight (main heating high-rate fraction 0.0). + result = _sap_result( + main_heating_fuel_kwh_per_yr=9000.0, + main_heating_fuel_code=31, + is_off_peak_meter=True, + main_heating_high_rate_fraction=0.0, + ) + + # Act + breakdown = EnergyBreakdown.from_sap_result(result) + + # Assert — the heating line bills on the off-peak carrier with the + # calculator's high-rate fraction attached for the day/night split. + line = breakdown.lines[0] + assert (line.section, line.fuel, line.kwh, line.high_rate_fraction) == ( + BillSection.HEATING, + Fuel.ELECTRICITY_OFF_PEAK, + 9000.0, + 0.0, + )