from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from math import isfinite from typing import Mapping from zoneinfo import ZoneInfo UTC = timezone.utc ZURICH = ZoneInfo('Europe/Zurich') def utc(value): if isinstance(value, str): value = datetime.fromisoformat(value.replace('Z', '+00:00')) if value.tzinfo is None or value.utcoffset() is None: raise ValueError('Timezone required') return value.astimezone(UTC) def number(value, name, minimum=None, maximum=None): if isinstance(value, bool) or not isinstance(value, (float, int)): raise ValueError(f'{name}: finite number required') value = float(value) if not isfinite(value) or minimum is not None and value < minimum or maximum is not None and value > maximum: raise ValueError(f'{name}: outside permitted range') return value def quarter_start(value): value = utc(value) return value.replace(minute=value.minute // 15 * 15, second=0, microsecond=0) def month_key(value): return utc(value).astimezone(ZURICH).strftime('%Y-%m') @dataclass(frozen=True) class Family: key: str pv: str load: str schedule: str label: str class FamilyRegistry: def __init__(self, families=()): self._families = {} for family in families: self.register(family) def register(self, family): if family.key in self._families or any(f.schedule == family.schedule for f in self._families.values()): raise ValueError('Duplicate family or schedule identifier') self._families[family.key] = family def get(self, key): if key not in self._families: raise ValueError(f'Unknown model family: {key}') return self._families[key] def entries(self): return tuple(self._families.values()) def default_registry(): return FamilyRegistry([ Family('3','prog_var_1','prog_var_2','prog_var_3','Variante 1 (1 / 2 / 3)'), Family('13','prog_var_10','prog_var_11','prog_var_13','Variante 2 (10 / 11 / 13)'), Family('23','prog_var_21','prog_var_22','prog_var_23','Variante 3 (21 / 22 / 23)'), ]) @dataclass(frozen=True) class Price: chf_kwh: float published_at: datetime | None = None mode: str = 'static' def known_at(self, at): number(self.chf_kwh, 'energy price') if self.mode not in ('static','dynamic'): raise ValueError('Explicit static/dynamic price mode required') return self.mode == 'static' or self.published_at is not None and utc(self.published_at) <= utc(at) @dataclass(frozen=True) class Step: start: datetime base_load_w: float pv_w: float import_price: Price | None export_price: Price | None external_w: float = 0.0 seconds: int = 300 @property def end(self): return utc(self.start) + timedelta(seconds=self.seconds) @property def residual_w(self): return self.base_load_w + self.external_w - self.pv_w def split_base_load(measured_house_w, flexible_w, external_w=0.0, external_already_removed=False): measured = number(measured_house_w, 'measured_house_w') flex = sum(number(v, 'flexible measurement') for v in flexible_w) external = number(external_w, 'external measurement') base = measured - flex - (0.0 if external_already_removed else external) if base < -1.0: raise ValueError('Negative base load: inconsistent measurement boundary or double subtraction') return max(0.0, base) def priced_prefix(steps, at): result = [] for step in steps: if not step.import_price or not step.export_price: break if not step.import_price.known_at(at) or not step.export_price.known_at(at): break result.append(step) while result and result[-1].end != quarter_start(result[-1].end): result.pop() return result @dataclass(frozen=True) class Battery: asset_id: str capacity_kwh: float soc_percent: float min_soc_percent: float max_soc_percent: float max_charge_w: float max_discharge_w: float measured_at: datetime roundtrip_efficiency: float = 0.90 grid_charging: bool = False throughput_chf_kwh: float = 0.0 terminal_soc_min_percent: float | None = None terminal_value_chf_kwh: float = 0.0 recovery_allowed: bool = False physical_min_soc_percent: float = 0.0 discharge_blocked: bool = False rearm_soc_percent: float | None = None def validate(self, at): if not self.asset_id: raise ValueError('Battery asset_id required') number(self.capacity_kwh, 'capacity_kwh', 0.001) lo = number(self.min_soc_percent, 'min_soc_percent', 0, 100) hi = number(self.max_soc_percent, 'max_soc_percent', lo, 100) physical=number(self.physical_min_soc_percent,'physical minimum SOC',0,lo) if type(self.recovery_allowed) is not bool or type(self.discharge_blocked) is not bool: raise ValueError('Explicit battery recovery and hysteresis flags required') number(self.soc_percent, 'SOC', physical if self.recovery_allowed else lo, hi) if self.rearm_soc_percent is not None:number(self.rearm_soc_percent,'hysteresis rearm SOC',lo,hi) number(self.max_charge_w, 'max_charge_w', 0) number(self.max_discharge_w, 'max_discharge_w', 0) number(self.roundtrip_efficiency, 'roundtrip_efficiency', 0.01, 1) number(self.throughput_chf_kwh, 'throughput_chf_kwh', 0) number(self.terminal_value_chf_kwh, 'terminal_value_chf_kwh', 0) if self.terminal_soc_min_percent is not None: number(self.terminal_soc_min_percent, 'terminal_soc_min_percent', lo, hi) age = (utc(at) - utc(self.measured_at)).total_seconds() if age < -30 or age > 1800: raise ValueError('SOC is stale or from the future') @dataclass(frozen=True) class QuarterPast: import_kwh: float measured_seconds: int @dataclass(frozen=True) class Limits: export_w: float | None = None import_w: float | None = None manager_month_limits_w: Mapping[int,float] = field(default_factory=dict) def import_limit(self, timestamp): values = [] if self.import_w is not None: values.append(number(self.import_w, 'import limit', 0)) m = utc(timestamp).astimezone(ZURICH).month if m in self.manager_month_limits_w: values.append(number(self.manager_month_limits_w[m], 'monthly manager limit', 0)) return min(values) if values else None