171 lines
6.5 KiB
Python
171 lines
6.5 KiB
Python
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
|