mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
221 lines
8 KiB
Python
221 lines
8 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import Optional, TYPE_CHECKING
|
|
|
|
from domain.fuel_rates.fuel import Fuel
|
|
from domain.billing.sap_fuel import sap_code_to_fuel
|
|
|
|
if TYPE_CHECKING:
|
|
from domain.sap10_calculator.calculator import SapResult
|
|
|
|
|
|
class BillSection(Enum):
|
|
"""A user-meaningful slice of the annual energy bill — the calculator's raw
|
|
end uses folded into the sections the UI shows (ADR-0014)."""
|
|
|
|
HEATING = "HEATING"
|
|
HOT_WATER = "HOT_WATER"
|
|
LIGHTING = "LIGHTING"
|
|
APPLIANCES = "APPLIANCES"
|
|
COOKING = "COOKING"
|
|
PUMPS_FANS = "PUMPS_FANS"
|
|
COOLING = "COOLING"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EnergyLine:
|
|
"""One section's delivered energy on one fuel. A section may have more than
|
|
one line (e.g. gas main heating + electric secondary heating).
|
|
|
|
``high_rate_fraction`` is the calculator's High-Rate Fraction for this end
|
|
use — the share of its kWh billed at the day (high) rate on an Off-Peak
|
|
Meter — set only on ``ELECTRICITY_OFF_PEAK`` lines; ``None`` for single-rate
|
|
fuels, which bill at their flat unit rate."""
|
|
|
|
section: BillSection
|
|
fuel: Fuel
|
|
kwh: float
|
|
high_rate_fraction: Optional[float] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EnergyBreakdown:
|
|
"""A Property's delivered energy per end use, the input to Bill Derivation.
|
|
``exported_kwh`` is PV generation exported to the grid, credited at the SEG
|
|
rate."""
|
|
|
|
lines: Sequence[EnergyLine]
|
|
exported_kwh: float = 0.0
|
|
|
|
@classmethod
|
|
def from_sap_result(cls, result: "SapResult") -> "EnergyBreakdown":
|
|
"""Fold a calculator `SapResult`'s per-end-use delivered kWh into billable
|
|
`EnergyLine`s (ADR-0014). Heating (main / main-2 / secondary) and hot water
|
|
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.
|
|
|
|
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,
|
|
),
|
|
]
|
|
return cls(
|
|
lines=[line for line in candidates if line is not None],
|
|
exported_kwh=result.pv_exported_kwh_per_yr,
|
|
)
|
|
|
|
|
|
def _fuelled_line(
|
|
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
|
|
bill it at a default (mirrors the calculator's strict-raise discipline)."""
|
|
if kwh <= 0:
|
|
return None
|
|
if fuel_code is None:
|
|
raise ValueError(
|
|
f"{section.value} has {kwh} kWh but no fuel code on the SapResult; "
|
|
"cannot attribute a billing fuel"
|
|
)
|
|
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,
|
|
*,
|
|
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 _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)
|
|
class BillSectionCost:
|
|
"""One section's rolled-up delivered kWh and annual cost (£)."""
|
|
|
|
kwh: float
|
|
cost_gbp: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Bill:
|
|
"""A Property's annual energy bill, composed per section plus the per-meter
|
|
standing charges and the SEG export credit, and the total (ADR-0014)."""
|
|
|
|
sections: Mapping[BillSection, BillSectionCost]
|
|
standing_charges_gbp: float
|
|
seg_credit_gbp: float
|
|
total_gbp: float
|
|
|
|
@property
|
|
def total_consumption_kwh(self) -> float:
|
|
"""Total delivered energy (kWh) across the billed sections. Standing
|
|
charges and the SEG credit are £, not energy, so they don't count."""
|
|
return sum((section.kwh for section in self.sections.values()), 0.0)
|