Route an off-peak meter's electric end uses to the day/night carrier 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-24 17:38:37 +00:00
parent dc55d3b899
commit 05977ee3ce
3 changed files with 148 additions and 12 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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,
)