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)