feat(application): integrate measured-load ingestion training and planner source
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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
|
||||
Reference in New Issue
Block a user