feat(application): integrate measured-load ingestion training and planner source

This commit is contained in:
ENELIX Agent
2026-10-02 21:00:05 +00:00
parent 44362e4bf5
commit fc024fcfba
96 changed files with 8933 additions and 0 deletions
+170
View File
@@ -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