mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
dc55d3b899
commit
05977ee3ce
3 changed files with 148 additions and 12 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue