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
@@ -0,0 +1,7 @@
FROM php:8.3-cli
WORKDIR /check
COPY php-src/ ./
RUN find /check -type d -exec chmod 0755 {} + \
&& find /check -type f -exec chmod 0644 {} +
USER 1000:1000
CMD ["php", "/check/check.php"]
@@ -0,0 +1,36 @@
"""Offline candidate-forecast tests: no telemetry, model loading or publication.
Run in an unprivileged, networkless test container with no live data volumes.
"""
from pathlib import Path
import ast
import hashlib
import json
import sys
import unittest
def main():
root = Path('/app/forecast')
sys.path.insert(0, str(root))
manifest = json.loads((root / 'SOURCE_MANIFEST.json').read_text())
for relative, expected in manifest.items():
p = root / relative
if Path(relative).is_absolute() or '..' in Path(relative).parts or not p.resolve().is_relative_to(root):
raise ValueError('Unsafe manifest path')
raw = p.read_bytes()
if hashlib.sha256(raw).hexdigest() != expected:
raise ValueError('Test image source checksum mismatch')
if p.suffix == '.py':
ast.parse(raw, filename=str(p))
import pandas, numpy, scipy, sklearn
versions = {'python': sys.version.split()[0], 'pandas': pandas.__version__,
'numpy': numpy.__version__, 'scipy': scipy.__version__, 'sklearn': sklearn.__version__}
print('Candidate forecast runtime:', json.dumps(versions), flush=True)
suite = unittest.defaultTestLoader.discover(str(root / 'tests'))
result = unittest.TextTestRunner(verbosity=2).run(suite)
print('Forecast tests only; no live data, no publication, no training job.', flush=True)
return 0 if result.wasSuccessful() else 1
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,25 @@
{
"main.py": "4060564a4a33400ef6b8547633fc4c97caa3f674494d8cfc53a7aae0ed011b6c",
"methods/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"methods/battery_optimizer.py": "27e7404d3bf4a2511e022232c2f6877adc0db014bdeb132a2a13cd4949d4d5e6",
"methods/common.py": "0fe5c9bc3fa8b6d40f0f9db36843623c6e469c150ed25899e3176355d5bb1db8",
"methods/var_1.py": "6a7fc3aaf904442aba44bd211d89e4ba54f10485f54441a1239de19e91fa5fe4",
"methods/var_10.py": "a3caf21387620687646775e6b0bf85c97af8bc6d0a48e3d779e9684d310333b7",
"methods/var_11.py": "1f783e57fee22761e8e5439caef48e275876a7ebf8380467d9b71ad2aa1b8edc",
"methods/var_13.py": "f12407cd056a1f28f47ab93b1262c62627c80d4765e75dd495e2c19e8f8e2ad9",
"methods/var_2.py": "5bdfd61bb108368890ff1b920f60499eabf6363c6380637100380eb0e28f0c6a",
"methods/var_21.py": "e2e3361d57fae8379dcce89cd98595a0d56d23decd1073d94474302b53ff8e15",
"methods/var_22.py": "cd51104bf98c686360c037cb74ff0a40bb748f24e52575eb3a3ef9932fac8cd6",
"methods/var_23.py": "d356d4078723a59e6cfad5ade631883cf22e7abd9caf38cec046e26b0580c678",
"methods/var_3.py": "87662e491ba33e170f3bcfdbd8dd2e54630073fcf4cb6e2e8ab768f9a6d95984",
"model_isolation.py": "db33ee8e9583cf006a5224a9f9efeed874ce04144d74f1b1bb25852c614468c5",
"netplan_v4_publisher.py": "cb8efe10d2799215b347985c53c96a6420e5461fff4c4e232b6f918d8bff2161",
"requirements.txt": "1f4731380246b4b08e5666ce736d9f24063128d2fc0737a061b36ac93952c083",
"shared_utils.py": "271489e491305d97706e0a4b5c8745bcb01e19628a0cee71da15512ee2d85e57",
"soc_diagnostics.py": "06ce7c55aa69875df94471a9fe47ff3d95da372192b737b9a0ff6493503ac15a",
"telemetry_quality.py": "bb959d08d2d50d5597dc10b47a35d510e43ba8bfa755db236652071b57ad1810",
"tests/test_battery_optimizer.py": "5b8fa3185672530c072a8cfe506ac8d4878846efbb2ee818b93ee04d57ca7cc8",
"tests/test_load_forecast.py": "000903a3691297dd7cfc160b3702825a6f04d53ae9d745dc08f7bfadec06465c",
"tests/test_model_isolation.py": "4dadcc7541181a57badc337fa33100c0ba2b9fd20b7dd36b87326eeeb285cf30",
"tests/test_telemetry_integrity.py": "8e6a200d6a108d309b8c5ceba15fdb1653644789875648c4284b75170d56b5ce"
}
@@ -0,0 +1,908 @@
from netplan_v4_publisher import publish_forecasts as _v4_publish_forecasts
from model_isolation import collect_predictions
import os
import sqlite3
import json
import time
import datetime
import traceback
import warnings
import subprocess
import sys
import threading
import numpy as np
import pandas as pd
import pytz
from influxdb_client import InfluxDBClient, Point, WritePrecision
from influxdb_client.client.write_api import SYNCHRONOUS
from soc_diagnostics import battery_soc_points
from telemetry_quality import require_recent_telemetry, sanitize_measured_frame
try:
from influxdb_client.client.warnings import MissingPivotFunction
warnings.simplefilter("ignore", MissingPivotFunction)
except Exception:
pass
import methods.var_1 as v1
import methods.var_2 as v2
import methods.var_3 as v3
import methods.var_10 as v10
import methods.var_11 as v11
import methods.var_13 as v13
import methods.var_21 as v21
import methods.var_22 as v22
import methods.var_23 as v23
INFLUX_URL = os.getenv("INFLUX_URL", "http://influxdb:8086")
INFLUX_TOKEN = os.environ["INFLUX_TOKEN"]
INFLUX_ORG = os.getenv("INFLUX_ORG", "belevo")
INFLUX_BUCKET = os.getenv("INFLUX_BUCKET", "energy_data")
SQLITE_DB_PATH = os.getenv("SQLITE_DB_PATH", "/app/data/users.db")
HISTORY_START = os.getenv("FORECAST_HISTORY_START", "1970-01-01T00:00:00Z")
QUALITY_LOOKBACK_DAYS = int(os.getenv("FORECAST_QUALITY_LOOKBACK_DAYS", "14"))
LOCAL_TZ = pytz.timezone(os.getenv("TZ", "Europe/Zurich"))
INFLUX_TIMEOUT_MS = int(os.getenv("FORECAST_INFLUX_TIMEOUT_MS", "120000"))
FORECAST_TIMEOUT_SECONDS = int(os.getenv("FORECAST_RUN_TIMEOUT_SECONDS", "900"))
FORECAST_HORIZON_HOURS = max(24, min(72, int(os.getenv("FORECAST_HORIZON_HOURS", "48"))))
TRAINING_TIMEOUT_SECONDS = int(os.getenv("FORECAST_TRAINING_TIMEOUT_SECONDS", "3600"))
FORECAST_STALE_SECONDS = int(os.getenv("FORECAST_STALE_SECONDS", "5400"))
WATCHDOG_INTERVAL_SECONDS = int(os.getenv("FORECAST_WATCHDOG_INTERVAL_SECONDS", "300"))
TRAINING_HOUR = int(os.getenv("FORECAST_TRAINING_HOUR", "2"))
LAST_SUCCESS_PATH = os.getenv("FORECAST_LAST_SUCCESS_PATH", "/tmp/forecast_engine_last_success.json")
LAST_TRAINING_PATH = os.getenv("FORECAST_LAST_TRAINING_PATH", "/app/data/forecast_training_status.json")
last_trained_day = None
_FORECAST_PROCESS_LOCK = threading.Lock()
_TRAINING_THREAD = None
MODEL_MODULES = {1: v1, 2: v2, 3: v3, 10: v10, 11: v11, 13: v13, 21: v21, 22: v22, 23: v23}
QUALITY_TARGETS = {1: "PV", 10: "PV", 21: "PV", 2: "Hausverbrauch", 11: "Hausverbrauch", 22: "Hausverbrauch"}
def active(config, n):
return bool(int(config.get(f"prog_var_{n}", config.get(f"var_{n}", 0)) or 0))
def get_configs():
conn = sqlite3.connect(SQLITE_DB_PATH)
conn.row_factory = sqlite3.Row
columns = {row["name"] for row in conn.execute("PRAGMA table_info(anlagen_meta)")}
if "batt_grid_charging_enabled" not in columns:
conn.execute(
"ALTER TABLE anlagen_meta ADD COLUMN batt_grid_charging_enabled INTEGER NOT NULL DEFAULT 0"
)
conn.commit()
rows = conn.execute("SELECT * FROM anlagen_meta").fetchall()
conn.close()
configs = []
for r in rows:
d = dict(r)
try:
d["daecher"] = json.loads(d.get("daecher") or "[]")
except Exception:
d["daecher"] = []
for key, default in [
("ac_leistung", 10.0),
("batt_capacity_kwh", 0.0),
("batt_power_kw", 0.0),
("tarif_bezug_fest", 0.30),
("tarif_einspeisung_fest", 0.10),
("tarif_peak_fest", 5.0),
]:
raw_value = d.get(key)
d[key] = float(default if raw_value is None or raw_value == "" else raw_value)
configs.append(d)
return configs
def _time_literal(dt):
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def _query_df(query):
client = InfluxDBClient(
url=INFLUX_URL,
token=INFLUX_TOKEN,
org=INFLUX_ORG,
timeout=INFLUX_TIMEOUT_MS,
)
try:
df = client.query_api().query_data_frame(org=INFLUX_ORG, query=query)
finally:
client.close()
if isinstance(df, list):
df = pd.concat(df, ignore_index=True) if df else pd.DataFrame()
if df is None or df.empty or "_time" not in df.columns:
return pd.DataFrame()
df["_time"] = pd.to_datetime(df["_time"], utc=True).dt.tz_localize(None)
return df
def _pivot_frame(df, fields):
if df.empty:
return pd.DataFrame()
out = df.set_index("_time")
keep = [c for c in fields if c in out.columns]
out = out[keep] if keep else pd.DataFrame(index=out.index)
out = out.apply(pd.to_numeric, errors="coerce")
out = out[~out.index.duplicated(keep="last")]
return out.sort_index().resample("5min").mean(numeric_only=True)
def _tariff_frame(df, config=None):
if df is None or df.empty:
return pd.DataFrame()
df = df.copy()
if "_time" in df.columns:
df["_time"] = pd.to_datetime(df["_time"], errors="coerce")
df = df.dropna(subset=["_time"]).set_index("_time")
if df.empty:
return pd.DataFrame()
price = df["price_chf_kwh"] if "price_chf_kwh" in df.columns else df.get("_value")
if price is None:
return pd.DataFrame()
price = pd.to_numeric(price, errors="coerce")
model = df.get("tariff_model", pd.Series("", index=df.index)).astype(str).str.lower()
typ = df.get("type", pd.Series("", index=df.index)).astype(str).str.lower()
tariff_name = df.get("tariff_name", pd.Series("", index=df.index)).astype(str).str.lower()
provider = df.get("provider", pd.Series("", index=df.index)).astype(str).str.lower()
key = (model + " " + tariff_name + " " + provider).str.lower()
rows = pd.DataFrame({"price": price, "key": key, "type": typ}, index=df.index)
rows = rows[pd.notna(rows["price"])].sort_index()
if rows.empty:
return pd.DataFrame()
cfg = config or {}
import_choice = str(cfg.get("tarif_bezug", "") or "").lower()
export_choice = str(cfg.get("tarif_einspeisung", "") or "").lower()
out = pd.DataFrame(index=rows.index.unique().sort_values())
import_base = (
rows["type"].str.contains("consumption|import|bezug", regex=True, na=False)
| rows["key"].str.contains("dynamic|dynamisch|home|business", regex=True, na=False)
)
if "business" in import_choice:
import_mask = import_base & rows["key"].str.contains("business|gewerbe|commercial", regex=True, na=False)
elif "home" in import_choice or "privat" in import_choice:
import_mask = import_base & rows["key"].str.contains("home|privat|private", regex=True, na=False)
elif "dynam" in import_choice:
import_mask = import_base
else:
import_mask = pd.Series(False, index=rows.index)
if not import_mask.any() and "dynam" in import_choice:
import_mask = import_base
export_base = (
rows["type"].str.contains("feed|einspeis|export", regex=True, na=False)
| rows["key"].str.contains("referenzmarktpreis|marktpreis|reference|feed", regex=True, na=False)
)
if "referenz" in export_choice or "marktpreis" in export_choice or "market" in export_choice:
export_mask = export_base & rows["key"].str.contains("referenzmarktpreis|marktpreis|reference|belevo", regex=True, na=False)
else:
export_mask = rows["key"].str.contains("standard_feedin|ckw statisch", regex=True, na=False) & export_base
if not export_mask.any() and ("referenz" in export_choice or "marktpreis" in export_choice or "market" in export_choice):
export_mask = export_base
if import_mask.any():
out["import_price"] = rows.loc[import_mask, "price"].groupby(level=0).last()
if export_mask.any():
out["export_price"] = rows.loc[export_mask, "price"].groupby(level=0).last()
if out.empty:
return out
return out.sort_index().resample("5min").mean().ffill().bfill()
def fetch_influx_frames(config, training):
aid = config["anlagen_id"]
start = HISTORY_START if training else "-14d"
future_stop = _time_literal(datetime.datetime.utcnow() + datetime.timedelta(hours=FORECAST_HORIZON_HOURS))
q_tel = f'''
from(bucket: "{INFLUX_BUCKET}")
|> range(start: {start})
|> filter(fn: (r) => r["_measurement"] == "api_telemetry")
|> filter(fn: (r) => r["anlagen_id"] == "{aid}")
|> filter(fn: (r) => r["_field"] == "PV" or r["_field"] == "Hausverbrauch" or r["_field"] == "Netzleistung" or r["_field"] == "SOC")
|> filter(fn: (r) => not exists r["data_type"] or (r["data_type"] != "forecast" and r["data_type"] != "forecast_snapshot"))
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false, timeSrc: "_start")
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
'''
df_tel = _pivot_frame(_query_df(q_tel), ["PV", "Hausverbrauch", "Netzleistung", "SOC"])
q_wea = f'''
from(bucket: "{INFLUX_BUCKET}")
|> range(start: {start}, stop: {future_stop})
|> filter(fn: (r) => r["_measurement"] == "weather_forecast")
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
'''
df_wea = _pivot_frame(_query_df(q_wea), ["temp_c", "temperature", "cloud", "cloud_cover", "precip_mm", "wind_kph", "chance_of_snow"])
if "temperature" in df_wea.columns and "temp_c" not in df_wea.columns:
df_wea["temp_c"] = df_wea["temperature"]
if "cloud_cover" in df_wea.columns and "cloud" not in df_wea.columns:
df_wea["cloud"] = df_wea["cloud_cover"]
if "cloud" in df_wea.columns and "cloud_cover" not in df_wea.columns:
df_wea["cloud_cover"] = df_wea["cloud"]
q_tar = f'''
from(bucket: "{INFLUX_BUCKET}")
|> range(start: -7d, stop: {future_stop})
|> filter(fn: (r) => r["_measurement"] == "tariffs")
|> filter(fn: (r) => r["_field"] == "price_chf_kwh")
'''
df_tar = _tariff_frame(_query_df(q_tar), config)
return df_tel, df_wea, df_tar
def _consistent_tail(df):
if df.empty or "PV" not in df.columns or "Hausverbrauch" not in df.columns:
return df
probe = df[["PV", "Hausverbrauch"]].copy()
filled = probe.interpolate(limit=3, limit_direction="both")
valid = filled.notna().all(axis=1)
if not valid.any():
return df.iloc[0:0]
run = 0
last_break = -1
for i, ok in enumerate(valid.to_numpy()):
if ok:
run = 0
else:
run += 1
if run >= 4:
last_break = i
if last_break >= 0:
after = np.where(valid.iloc[last_break + 1:].to_numpy())[0]
if len(after):
return df.iloc[last_break + 1 + after[0]:]
return df.iloc[0:0]
return df.loc[valid[valid].index[0]:]
def _longest_consistent_segment(df, column):
if df.empty or column not in df.columns:
return pd.DataFrame()
series = pd.to_numeric(df[column], errors="coerce")
valid = series.notna().to_numpy()
if not valid.any():
return pd.DataFrame()
best_start = best_end = None
start = 0
gap = 0
for i, ok in enumerate(valid):
if ok:
gap = 0
else:
gap += 1
if gap >= 4:
end = i - gap
if end >= start and (best_start is None or end - start > best_end - best_start):
best_start, best_end = start, end
start = i + 1
gap = 0
end = len(valid) - 1
if end >= start and (best_start is None or end - start > best_end - best_start):
best_start, best_end = start, end
if best_start is None:
return pd.DataFrame()
segment = df.iloc[best_start:best_end + 1].copy()
first = pd.to_numeric(segment[column], errors="coerce").first_valid_index()
last = pd.to_numeric(segment[column], errors="coerce").last_valid_index()
if first is None or last is None:
return pd.DataFrame()
return segment.loc[first:last]
def _add_time_features(df):
hour = df.index.hour + df.index.minute / 60.0
doy = df.index.dayofyear
df["hour_float"] = hour
df["hour_sin"] = np.sin(2 * np.pi * hour / 24.0)
df["hour_cos"] = np.cos(2 * np.pi * hour / 24.0)
df["sin_year"] = np.sin(2 * np.pi * doy / 365.25)
df["cos_year"] = np.cos(2 * np.pi * doy / 365.25)
df["weekday"] = df.index.weekday
df["is_weekday"] = (df.index.weekday < 5).astype(int)
return df
def _fill_defaults(df, history):
defaults = {
"PV": np.nan,
"Hausverbrauch": np.nan,
"SOC": np.nan,
"Netzleistung": np.nan,
"temp_c": 15.0,
"cloud": 20.0,
"cloud_cover": 20.0,
"precip_mm": 0.0,
"wind_kph": 0.0,
"chance_of_snow": 0.0,
"import_price": np.nan,
"export_price": np.nan,
}
for col, default in defaults.items():
if col not in df.columns:
df[col] = default
df[col] = pd.to_numeric(df[col], errors="coerce")
# Measurements are never interpolated/filled here. Outages are not zero load,
# zero PV, a fresh SOC, or a measured grid peak. Weather defaults are separate.
df = sanitize_measured_frame(df)
for col, default in defaults.items():
if col not in ["PV", "Hausverbrauch", "SOC", "Netzleistung"]:
df[col] = df[col].ffill().bfill()
if np.isfinite(default):
df[col] = df[col].fillna(default)
if "cloud_cover" in df.columns:
df["cloud"] = df["cloud"].fillna(df["cloud_cover"])
df["import_price"] = df["import_price"].fillna(0.30)
df["export_price"] = df["export_price"].fillna(0.10)
return _add_time_features(df)
def build_data_object(config, training=False):
now = datetime.datetime.utcnow().replace(second=0, microsecond=0)
now = now - datetime.timedelta(minutes=now.minute % 5)
df_tel, df_wea, df_tar = fetch_influx_frames(config, training)
frames = [f for f in [df_tel, df_wea, df_tar] if not f.empty]
combined = frames[0] if frames else pd.DataFrame()
for f in frames[1:]:
combined = combined.join(f, how="outer")
combined = combined.sort_index()
full_hist_raw = combined.loc[:now - datetime.timedelta(minutes=5)] if not combined.empty else pd.DataFrame()
# A trailing telemetry gap must not discard all earlier valid observations.
hist_raw = full_hist_raw.copy()
# Freshness is determined from measurements, not the outer-joined weather grid.
recent_raw = sanitize_measured_frame(df_tel.loc[:now - datetime.timedelta(minutes=5)].copy()) if not df_tel.empty else pd.DataFrame()
start_hist = hist_raw.index.min().floor("5min") if training and not hist_raw.empty else now - datetime.timedelta(days=14)
idx_hist = pd.date_range(start=start_hist, end=now - datetime.timedelta(minutes=5), freq="5min")
df_hist = pd.DataFrame(index=idx_hist).join(hist_raw, how="left")
df_hist = _fill_defaults(df_hist, history=True)
df_load_training = pd.DataFrame()
if training and "Hausverbrauch" in full_hist_raw.columns:
load_segment = _longest_consistent_segment(full_hist_raw, "Hausverbrauch")
if not load_segment.empty:
idx_load = pd.date_range(
start=load_segment.index.min().floor("5min"),
end=load_segment.index.max().floor("5min"),
freq="5min",
)
df_load_training = pd.DataFrame(index=idx_load).join(load_segment, how="left")
df_load_training = _fill_defaults(df_load_training, history=True)
df_pv_training = pd.DataFrame()
if training and "PV" in full_hist_raw.columns:
pv_first = full_hist_raw["PV"].first_valid_index()
if pv_first is not None:
idx_pv = pd.date_range(
start=pv_first.floor("5min"),
end=now - datetime.timedelta(minutes=5),
freq="5min",
)
df_pv_training = pd.DataFrame(index=idx_pv).join(full_hist_raw, how="left")
df_pv_training = _fill_defaults(df_pv_training, history=True)
idx_fut = pd.date_range(start=now, periods=FORECAST_HORIZON_HOURS * 12, freq="5min")
fut_raw = combined.reindex(combined.index.union(idx_fut)).sort_index() if not combined.empty else pd.DataFrame(index=idx_fut)
df_fut = pd.DataFrame(index=idx_fut).join(fut_raw, how="left")
df_fut = _fill_defaults(df_fut, history=False)
month_hist = df_hist[(df_hist.index.year == now.year) & (df_hist.index.month == now.month)]
current_peak_kw = 0.0
if not month_hist.empty and "Netzleistung" in month_hist.columns:
measured_grid = pd.to_numeric(month_hist["Netzleistung"], errors="coerce").dropna()
measured_peak = measured_grid.resample(
"15min", origin="start_day", label="left", closed="left"
).mean()
if not measured_peak.empty:
current_peak_kw = max(0.0, float(measured_peak.max()) / 1000.0)
if current_peak_kw <= 0.0 and not month_hist.empty and {"Hausverbrauch", "PV"}.issubset(month_hist.columns):
fallback_residual = (month_hist["Hausverbrauch"] - month_hist["PV"]).resample(
"15min", origin="start_day", label="left", closed="left"
).mean()
if not fallback_residual.empty:
current_peak_kw = max(0.0, float(fallback_residual.max()) / 1000.0)
min_soc = float(
config.get("batt_min_soc", config.get("batt_min_soc_percent", 0.0)) or 0.0
)
current_soc = max(0.0, min(100.0, min_soc))
current_soc_source = "safe_minimum"
current_soc_age_minutes = None
if "SOC" in full_hist_raw.columns:
soc_values = pd.to_numeric(full_hist_raw["SOC"], errors="coerce").dropna()
if not soc_values.empty:
latest_soc_time = pd.Timestamp(soc_values.index[-1])
current_soc_age_minutes = max(
0.0,
(pd.Timestamp(now) - latest_soc_time).total_seconds() / 60.0,
)
try:
max_soc_age_minutes = max(
5.0,
float(config.get("batt_soc_max_age_minutes", 30.0) or 30.0),
)
except (TypeError, ValueError):
max_soc_age_minutes = 30.0
if current_soc_age_minutes <= max_soc_age_minutes:
current_soc = float(soc_values.iloc[-1])
current_soc_source = "telemetry"
current_soc = max(0.0, min(100.0, current_soc))
return {
"config": config,
"metadata": config,
"now": now,
"df_hist": df_hist,
"df_recent_raw": recent_raw,
"df_load_training": df_load_training,
"df_pv_training": df_pv_training,
"df_fut": df_fut,
"current_soc_perc": current_soc,
"current_soc_source": current_soc_source,
"current_soc_age_minutes": current_soc_age_minutes,
"current_month_peak_kw": current_peak_kw,
}
def run_training():
for config in get_configs():
if not any(active(config, n) for n in MODEL_MODULES):
continue
print(f"Training Anlage {config['anlagen_id']}...")
data_obj = build_data_object(config, training=True)
for n, module in MODEL_MODULES.items():
if active(config, n) and hasattr(module, "train"):
try:
target = QUALITY_TARGETS.get(n)
if target:
require_recent_telemetry(data_obj, [target])
print(f" var_{n}: {module.train(data_obj)}")
except Exception:
print(f" var_{n}: Training fehlgeschlagen")
traceback.print_exc()
def _forecast_point(aid, field, t, value):
return (
Point("api_telemetry")
.tag("anlagen_id", aid)
.tag("data_type", "forecast")
.field(field, float(value))
.time(t.to_pydatetime(), WritePrecision.S)
)
def _snapshot_point(aid, field, run_hour, t, value):
return (
Point("api_telemetry")
.tag("anlagen_id", aid)
.tag("data_type", "forecast_snapshot")
.tag("run_hour", run_hour)
.field(f"{field}_run_{run_hour}", float(value))
.time(t.to_pydatetime(), WritePrecision.S)
)
def _quality_query(aid, forecast_field, target_field):
stop = _time_literal(datetime.datetime.utcnow() - datetime.timedelta(minutes=10))
return f'''
from(bucket: "{INFLUX_BUCKET}")
|> range(start: -{QUALITY_LOOKBACK_DAYS}d, stop: {stop})
|> filter(fn: (r) => r["_measurement"] == "api_telemetry")
|> filter(fn: (r) => r["anlagen_id"] == "{aid}")
|> filter(fn: (r) => r["_field"] == "{target_field}" or r["_field"] == "{forecast_field}")
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
|> group()
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
'''
def _forecast_quality(aid, variant, target_field):
forecast_field = f"prog_var_{variant}"
df = _query_df(_quality_query(aid, forecast_field, target_field))
if df.empty or forecast_field not in df.columns or target_field not in df.columns:
return None
pair = df[[target_field, forecast_field]].apply(pd.to_numeric, errors="coerce").dropna()
if len(pair) < 3:
return None
actual = pair[target_field].to_numpy(dtype=float)
pred = pair[forecast_field].to_numpy(dtype=float)
ss_res = float(np.sum((actual - pred) ** 2))
ss_tot = float(np.sum((actual - np.mean(actual)) ** 2))
r2 = None if ss_tot <= 0 else 1.0 - (ss_res / ss_tot)
mae = float(np.mean(np.abs(actual - pred)))
rmse = float(np.sqrt(np.mean((actual - pred) ** 2)))
return {"r2": r2, "mae": mae, "rmse": rmse, "samples": int(len(pair))}
def _quality_point(aid, variant, target, metrics):
p = (
Point("forecast_metrics")
.tag("anlagen_id", aid)
.tag("forecast", f"prog_var_{variant}")
.tag("target", target)
.field("samples", int(metrics["samples"]))
.field("mae", float(metrics["mae"]))
.field("rmse", float(metrics["rmse"]))
.time(datetime.datetime.utcnow(), WritePrecision.S)
)
if metrics["r2"] is not None:
p.field("r2", float(metrics["r2"]))
return p
def write_quality_metrics(write_api, aid, active_variants):
points = []
for variant, target in QUALITY_TARGETS.items():
if variant not in active_variants:
continue
try:
metrics = _forecast_quality(aid, variant, target)
if metrics:
points.append(_quality_point(aid, variant, target, metrics))
r2_text = "nan" if metrics["r2"] is None else f"{metrics['r2']:.3f}"
print(f"R2 Anlage {aid} prog_var_{variant}: {r2_text} / samples={metrics['samples']}")
except Exception:
print(f"R2 Anlage {aid} prog_var_{variant}: Berechnung fehlgeschlagen")
traceback.print_exc()
if points:
try:
write_api.write(bucket=INFLUX_BUCKET, org=INFLUX_ORG, record=points)
except Exception:
print(f"R2 Anlage {aid}: Schreiben der Qualitaetswerte fehlgeschlagen")
traceback.print_exc()
def run_forecast(only_anlagen_id=None, manual=False):
configs = get_configs()
if only_anlagen_id:
configs = [c for c in configs if c.get("anlagen_id") == only_anlagen_id]
client = InfluxDBClient(
url=INFLUX_URL,
token=INFLUX_TOKEN,
org=INFLUX_ORG,
timeout=INFLUX_TIMEOUT_MS,
)
write_api = client.write_api(write_options=SYNCHRONOUS)
completed = []
failures = []
try:
for config in configs:
aid = config["anlagen_id"]
try:
data_obj = build_data_object(config, training=False)
targets = []
if any(active(config, n) for n in (1, 3, 10, 13, 21, 23)):
targets.append("PV")
if any(active(config, n) for n in (2, 3, 11, 13, 22, 23)):
targets.append("Hausverbrauch")
if float(config.get("batt_capacity_kwh", 0.0) or 0.0) > 0 and any(active(config, n) for n in (3, 13, 23)):
targets.append("SOC")
# Fail BEFORE model prediction, snapshot publication or V1/V4 plan writes.
data_obj["telemetry_quality"] = require_recent_telemetry(data_obj, targets)
soc_age = data_obj.get("current_soc_age_minutes")
soc_age_text = "keine Messung" if soc_age is None else f"{soc_age:.1f} min"
print(
f"Forecast Anlage {aid}: Batterie-SOC {data_obj['current_soc_perc']:.1f}% "
f"({data_obj['current_soc_source']}, Alter {soc_age_text})."
)
forecasts, model_errors = collect_predictions(
data_obj, config,
{1: v1, 2: v2, 10: v10, 11: v11, 21: v21, 22: v22}, active,
)
data_obj['forecast_model_status'] = model_errors
for variant, detail in model_errors.items():
print(f"Forecast Anlage {aid} prog_var_{variant}: unavailable ({detail['errorType']}); keine Nullwerte eingesetzt.")
p_1, p_2, p_10, p_11, p_21, p_22 = (
forecasts[n] for n in (1, 2, 10, 11, 21, 22)
)
# ENELIX_V4_SHADOW_BRIDGE
_v4_publish_forecasts(config, [(3,p_1,p_2),(13,p_10,p_11),(23,p_21,p_22)])
p_3 = v3.predict(data_obj, p_1, p_2) if active(config, 3) and p_1 and p_2 else {}
p_13 = v13.predict(data_obj, p_10, p_11) if active(config, 13) and p_10 and p_11 else {}
p_23 = v23.predict(data_obj, p_21, p_22) if active(config, 23) and p_21 and p_22 else {}
forecast_sets = [
(1, p_1), (2, p_2), (3, p_3),
(10, p_10), (11, p_11), (13, p_13),
(21, p_21), (22, p_22), (23, p_23),
]
run_hour = str(int(datetime.datetime.now(LOCAL_TZ).strftime("%H")))
points = []
for variant_id, pv_src, load_src, grid_src in [
(3, p_1, p_2, p_3),
(13, p_10, p_11, p_13),
(23, p_21, p_22, p_23),
]:
if grid_src and pv_src and load_src:
points.extend(battery_soc_points(data_obj, variant_id, pv_src, load_src, grid_src))
active_variants = set()
battery_plans = data_obj.get("battery_plans", {})
for t in data_obj["df_fut"].index:
for n, values in forecast_sets:
if t in values:
field = f"prog_var_{n}"
value = float(values[t])
points.append(_forecast_point(aid, field, t, value))
points.append(_snapshot_point(aid, field, run_hour, t, value))
if n in battery_plans and t in battery_plans[n].get("battery", {}):
points.append(_forecast_point(
aid,
f"prog_var_{n}_battery",
t,
float(battery_plans[n]["battery"][t]),
))
active_variants.add(n)
if points:
write_api.write(bucket=INFLUX_BUCKET, org=INFLUX_ORG, record=points)
print(f"Forecast Anlage {aid}: {len(points)} Punkte geschrieben inkl. run_{run_hour}.")
else:
print(f"Forecast Anlage {aid}: keine aktiven Prognosen oder keine Daten.")
write_quality_metrics(write_api, aid, active_variants)
completed.append(aid)
except Exception as exc:
failures.append({"anlagen_id": aid, "error": f"{type(exc).__name__}: {exc}"})
print(f"Forecast Anlage {aid}: Lauf fehlgeschlagen.")
traceback.print_exc()
finally:
client.close()
if failures:
raise RuntimeError(f"Forecast-Fehler: {failures}")
return {"completed": completed, "manual": bool(manual)}
def _write_json_atomic(path, payload):
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
tmp_path = f"{path}.tmp.{os.getpid()}"
try:
with open(tmp_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
os.replace(tmp_path, path)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def _read_json(path):
try:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
except Exception:
return {}
def _last_success_age_seconds():
stamp = _read_json(LAST_SUCCESS_PATH).get("timestamp")
if not stamp:
return None
try:
then = datetime.datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
now = datetime.datetime.now(datetime.timezone.utc)
return max(0.0, (now - then).total_seconds())
except Exception:
return None
def _child_environment(training=False):
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
if training:
max_threads = str(max(1, int(env.get("FORECAST_TRAINING_MAX_THREADS", "1"))))
for name in (
"OMP_NUM_THREADS",
"OPENBLAS_NUM_THREADS",
"MKL_NUM_THREADS",
"NUMEXPR_NUM_THREADS",
"LOKY_MAX_CPU_COUNT",
):
env[name] = max_threads
return env
def _run_child(mode, timeout_seconds, only_anlagen_id=None):
command = [sys.executable, "-u", os.path.abspath(__file__), mode]
if only_anlagen_id:
command.append(str(only_anlagen_id))
label = "Training" if mode == "--train-once" else "Forecast"
print(f"{label}-Kindprozess startet (Timeout {timeout_seconds}s).")
process = subprocess.Popen(command, env=_child_environment(training=mode == "--train-once"))
try:
return_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
print(f"{label}-Kindprozess hat das Zeitlimit erreicht und wird beendet.")
process.terminate()
try:
process.wait(timeout=15)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=15)
return 124
if return_code != 0:
print(f"{label}-Kindprozess beendet mit Status {return_code}.")
return return_code
def run_forecast_isolated(only_anlagen_id=None, source="scheduler"):
if not _FORECAST_PROCESS_LOCK.acquire(blocking=False):
print(f"Forecast-Aufruf ({source}) uebersprungen: bereits ein Lauf aktiv.")
return {"status": "busy", "source": source}
try:
return_code = _run_child("--forecast-once", FORECAST_TIMEOUT_SECONDS, only_anlagen_id)
return {
"status": "ok" if return_code == 0 else "error",
"source": source,
"return_code": return_code,
}
finally:
_FORECAST_PROCESS_LOCK.release()
def _training_worker(day_text):
global _TRAINING_THREAD
started = datetime.datetime.now(datetime.timezone.utc).isoformat()
_write_json_atomic(LAST_TRAINING_PATH, {
"day": day_text,
"status": "running",
"started_at": started,
})
return_code = 1
error = None
try:
return_code = _run_child("--train-once", TRAINING_TIMEOUT_SECONDS)
except Exception as exc:
error = f"{type(exc).__name__}: {exc}"
traceback.print_exc()
finally:
status = "ok" if return_code == 0 else ("timeout" if return_code == 124 else "error")
payload = {
"day": day_text,
"status": status,
"started_at": started,
"finished_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"return_code": return_code,
}
if error:
payload["error"] = error
_write_json_atomic(LAST_TRAINING_PATH, payload)
print(f"Nachttraining beendet: {status}.")
_TRAINING_THREAD = None
def start_training_async(day):
global _TRAINING_THREAD
if _TRAINING_THREAD is not None and _TRAINING_THREAD.is_alive():
print("Nachttraining laeuft bereits.")
return False
day_text = day.isoformat()
_TRAINING_THREAD = threading.Thread(target=_training_worker, args=(day_text,), daemon=True)
_TRAINING_THREAD.start()
return True
def _watchdog_loop():
while True:
time.sleep(max(60, WATCHDOG_INTERVAL_SECONDS))
try:
age = _last_success_age_seconds()
if age is None or age > FORECAST_STALE_SECONDS:
age_text = "unbekannt" if age is None else f"{age / 60.0:.1f} Minuten"
print(f"Forecast-Watchdog: letzter erfolgreicher Lauf {age_text}; neuer Lauf wird gestartet.")
run_forecast_isolated(source="watchdog")
except Exception:
print("Forecast-Watchdog: Pruefung fehlgeschlagen.")
traceback.print_exc()
# BEGIN EMS RESIMULATE HTTP SERVER
_RESIMULATE_SERVER_STARTED = False
def _start_resimulate_server():
global _RESIMULATE_SERVER_STARTED
if _RESIMULATE_SERVER_STARTED:
return
_RESIMULATE_SERVER_STARTED = True
import json as _json
import os as _os
import threading as _threading
import traceback as _traceback
import urllib.parse as _urlparse
from http.server import BaseHTTPRequestHandler as _BaseHTTPRequestHandler, ThreadingHTTPServer as _ThreadingHTTPServer
class _Handler(_BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def _send(self, code, body):
raw = _json.dumps(body).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
self._handle()
def do_POST(self):
self._handle()
def _handle(self):
try:
parsed = _urlparse.urlparse(self.path)
if parsed.path == "/health":
age = _last_success_age_seconds()
healthy = age is not None and age <= FORECAST_STALE_SECONDS
self._send(200 if healthy else 503, {
"status": "ok" if healthy else "stale",
"last_success_age_seconds": age,
"training": _read_json(LAST_TRAINING_PATH),
})
return
if parsed.path not in ("/run_now", "/resimulate"):
self._send(404, {"detail": "not found"})
return
params = _urlparse.parse_qs(parsed.query)
anlagen_id = (params.get("anlagen_id") or [None])[0]
result = run_forecast_isolated(only_anlagen_id=anlagen_id, source="http")
result["anlagen_id"] = anlagen_id
code = 200 if result["status"] == "ok" else (409 if result["status"] == "busy" else 500)
self._send(code, result)
except Exception as exc:
_traceback.print_exc()
self._send(500, {"detail": str(exc)})
port = int(_os.getenv("FORECAST_ENGINE_HTTP_PORT", "9000"))
server = _ThreadingHTTPServer(("0.0.0.0", port), _Handler)
thread = _threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
print(f"Forecast Resimulate HTTP Server startet auf Port {port}.")
# END EMS RESIMULATE HTTP SERVER
def main():
_start_resimulate_server()
global last_trained_day
print("Forecast Engine startet.")
threading.Thread(target=_watchdog_loop, daemon=True).start()
run_forecast_isolated(source="startup")
while True:
try:
now_local = datetime.datetime.now(LOCAL_TZ)
if now_local.hour == TRAINING_HOUR and last_trained_day != now_local.date():
if start_training_async(now_local.date()):
last_trained_day = now_local.date()
next_run = (now_local + datetime.timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
time.sleep(max(60.0, (next_run - now_local).total_seconds()))
run_forecast_isolated(source="scheduler")
except Exception:
traceback.print_exc()
time.sleep(60)
if __name__ == "__main__":
if len(sys.argv) >= 2 and sys.argv[1] == "--forecast-once":
selected_anlage = sys.argv[2] if len(sys.argv) >= 3 else None
run_forecast(only_anlagen_id=selected_anlage, manual=True)
_write_json_atomic(LAST_SUCCESS_PATH, {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"anlagen_id": selected_anlage,
"pid": os.getpid(),
})
elif len(sys.argv) >= 2 and sys.argv[1] == "--train-once":
run_training()
else:
main()
@@ -0,0 +1,240 @@
import numpy as np
import pandas as pd
from scipy.optimize import Bounds, LinearConstraint, milp
from scipy.sparse import lil_matrix
DT_H = 5.0 / 60.0
def train_artifact(kind):
return {"trained": True, "type": "battery_48h_cost_milp_v3", "source": kind}
def _cfg_float(config, key, default):
try:
value = config.get(key, default)
return float(default if value is None or value == "" else value)
except Exception:
return float(default)
def _cfg_bool(config, key, default=False):
value = config.get(key, default)
if value is None or value == "":
return bool(default)
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "ja", "on"}
def _use_dynamic(config, key):
value = str(config.get(key, "") or "").strip().lower()
return any(token in value for token in ("dynam", "marktpreis", "referenzmarktpreis", "market"))
def _price(data_obj, timestamp, column, fallback, dynamic_enabled):
if not dynamic_enabled:
return fallback
frame = data_obj["df_fut"]
if column in frame.columns and timestamp in frame.index:
try:
value = float(frame.at[timestamp, column])
if np.isfinite(value):
return value
except Exception:
pass
return fallback
def _battery_meta(config, data_obj):
cap_kwh = _cfg_float(config, "batt_capacity_kwh", 0.0)
max_power_w = _cfg_float(config, "batt_power_kw", 0.0) * 1000.0
min_soc = _cfg_float(config, "batt_min_soc", _cfg_float(config, "batt_min_soc_percent", 0.0))
max_soc = _cfg_float(config, "batt_max_soc", _cfg_float(config, "batt_max_soc_percent", 100.0))
start_soc_value = data_obj.get("current_soc_perc")
if start_soc_value is None:
start_soc_value = min_soc
try:
start_soc = float(start_soc_value)
except (TypeError, ValueError):
start_soc = min_soc
min_soc = max(0.0, min(100.0, min_soc))
max_soc = max(min_soc, min(100.0, max_soc))
start_soc = max(min_soc, min(max_soc, start_soc))
charge_eff = max(0.01, min(1.0, _cfg_float(config, "batt_charge_efficiency", 0.95)))
discharge_eff = max(0.01, min(1.0, _cfg_float(config, "batt_discharge_efficiency", 0.95)))
return cap_kwh, max_power_w, min_soc, max_soc, start_soc, charge_eff, discharge_eff
def _quarter_groups(index):
groups = {}
for position, timestamp in enumerate(index):
quarter = timestamp.floor("15min") if hasattr(timestamp, "floor") else position // 3
groups.setdefault(quarter, []).append(position)
return list(groups.values())
def _fallback_plan(index, residual_w):
grid = {timestamp: float(value) for timestamp, value in zip(index, residual_w)}
battery = {timestamp: 0.0 for timestamp in index}
return {"grid": grid, "battery": battery, "solver": "fallback"}
def optimize_battery_plan(data_obj, pv_dict, load_dict):
config = data_obj["config"]
cap_kwh, max_power_w, min_soc, max_soc, start_soc, charge_eff, discharge_eff = _battery_meta(config, data_obj)
index = list(data_obj["df_fut"].index)
if not index:
return {"grid": {}, "battery": {}, "solver": "empty"}
load_w = np.array([max(0.0, float(load_dict.get(t, 0.0))) for t in index])
pv_w = np.array([max(0.0, float(pv_dict.get(t, 0.0))) for t in index])
residual_w = load_w - pv_w
if cap_kwh <= 0.0 or max_power_w <= 0.0:
return _fallback_plan(index, residual_w)
import_fixed = _cfg_float(config, "tarif_bezug_fest", 0.30)
export_fixed = _cfg_float(config, "tarif_einspeisung_fest", 0.10)
import_dynamic = _use_dynamic(config, "tarif_bezug")
export_dynamic = _use_dynamic(config, "tarif_einspeisung")
import_price = np.array([
_price(data_obj, t, "import_price", import_fixed, import_dynamic) for t in index
])
export_price = np.array([
_price(data_obj, t, "export_price", export_fixed, export_dynamic) for t in index
])
n = len(index)
imp, exp, charge, discharge, curtail, soc = 0, n, 2 * n, 3 * n, 4 * n, 5 * n
peak = 6 * n + 1
battery_mode = peak + 1
grid_mode = battery_mode + n
variable_count = grid_mode + n
max_import_w = max(float(load_w.max(initial=0.0)) + max_power_w, max_power_w, 1.0)
configured_import_limit = _cfg_float(config, "grid_import_limit_w", 0.0)
if configured_import_limit > 0.0:
max_import_w = min(max_import_w, configured_import_limit)
max_export_w = max(float(pv_w.max(initial=0.0)) + max_power_w, max_power_w, 1.0)
configured_export_limit = _cfg_float(config, "grid_export_limit_w", 0.0)
if configured_export_limit > 0.0:
max_export_w = min(max_export_w, configured_export_limit)
lower = np.zeros(variable_count)
upper = np.full(variable_count, np.inf)
upper[imp:imp + n] = max_import_w
upper[exp:exp + n] = max_export_w
upper[charge:charge + n] = max_power_w
if not _cfg_bool(config, "batt_grid_charging_enabled", False):
upper[charge:charge + n] = np.minimum(max_power_w, np.maximum(0.0, pv_w - load_w))
upper[discharge:discharge + n] = max_power_w
upper[curtail:curtail + n] = pv_w
reserve_soc = max(
min_soc,
min(100.0, _cfg_float(config, "batt_economic_reserve_soc_percent", 10.0)),
)
economic_min_soc = max(min_soc, min(start_soc, reserve_soc))
min_kwh = cap_kwh * economic_min_soc / 100.0
max_kwh = cap_kwh * max_soc / 100.0
lower[soc:soc + n + 1] = min_kwh
upper[soc:soc + n + 1] = max_kwh
current_peak_kw = max(0.0, float(data_obj.get("current_month_peak_kw", 0.0) or 0.0))
lower[peak] = current_peak_kw
upper[peak] = max(current_peak_kw, max_import_w / 1000.0)
upper[battery_mode:battery_mode + n] = 1.0
upper[grid_mode:grid_mode + n] = 1.0
objective = np.zeros(variable_count)
objective[imp:imp + n] = import_price * DT_H / 1000.0
objective[exp:exp + n] = -export_price * DT_H / 1000.0
degradation = max(0.0, _cfg_float(config, "batt_degradation_chf_kwh", 0.03))
objective[charge:charge + n] = (degradation / 2.0 + 1e-7) * DT_H / 1000.0
objective[discharge:discharge + n] = (degradation / 2.0 + 1e-7) * DT_H / 1000.0
objective[curtail:curtail + n] = 1e-9 * DT_H / 1000.0
objective[peak] = max(0.0, _cfg_float(config, "tarif_peak_fest", 0.0))
terminal_value = _cfg_float(config, "batt_terminal_value_chf_kwh", np.median(import_price))
objective[soc + n] = -max(0.0, terminal_value) * discharge_eff
equality_rows = 2 * n + 1
equality = lil_matrix((equality_rows, variable_count), dtype=float)
equality_rhs = np.zeros(equality_rows)
for i in range(n):
equality[i, imp + i] = 1.0
equality[i, exp + i] = -1.0
equality[i, charge + i] = -1.0
equality[i, discharge + i] = 1.0
equality[i, curtail + i] = -1.0
equality_rhs[i] = residual_w[i]
row = n + i
equality[row, soc + i] = -1.0
equality[row, soc + i + 1] = 1.0
equality[row, charge + i] = -charge_eff * DT_H / 1000.0
equality[row, discharge + i] = DT_H / (1000.0 * discharge_eff)
equality[2 * n, soc] = 1.0
equality_rhs[2 * n] = cap_kwh * start_soc / 100.0
quarter_groups = _quarter_groups(pd.Index(index))
inequality_rows = 4 * n + len(quarter_groups)
inequality = lil_matrix((inequality_rows, variable_count), dtype=float)
inequality_upper = np.zeros(inequality_rows)
row = 0
for i in range(n):
inequality[row, charge + i] = 1.0
inequality[row, battery_mode + i] = -max_power_w
row += 1
inequality[row, discharge + i] = 1.0
inequality[row, battery_mode + i] = max_power_w
inequality_upper[row] = max_power_w
row += 1
inequality[row, imp + i] = 1.0
inequality[row, grid_mode + i] = -max_import_w
row += 1
inequality[row, exp + i] = 1.0
inequality[row, grid_mode + i] = max_export_w
inequality_upper[row] = max_export_w
row += 1
for group in quarter_groups:
for i in group:
inequality[row, imp + i] = 1.0 / (len(group) * 1000.0)
inequality[row, peak] = -1.0
row += 1
integrality = np.zeros(variable_count, dtype=int)
integrality[battery_mode:battery_mode + n] = 1
integrality[grid_mode:grid_mode + n] = 1
constraints = [
LinearConstraint(equality.tocsr(), equality_rhs, equality_rhs),
LinearConstraint(inequality.tocsr(), -np.inf, inequality_upper),
]
result = milp(
objective,
integrality=integrality,
bounds=Bounds(lower, upper),
constraints=constraints,
options={"time_limit": max(5.0, _cfg_float(config, "batt_optimizer_timeout_seconds", 30.0))},
)
if not result.success or result.x is None:
return _fallback_plan(index, residual_w)
grid_values = result.x[imp:imp + n] - result.x[exp:exp + n]
battery_values = result.x[charge:charge + n] - result.x[discharge:discharge + n]
threshold_w = max(25.0, max_power_w * 0.005)
grid_values[np.abs(grid_values) < threshold_w] = 0.0
battery_values[np.abs(battery_values) < threshold_w] = 0.0
return {
"grid": {t: float(v) for t, v in zip(index, grid_values)},
"battery": {t: float(v) for t, v in zip(index, battery_values)},
"solver": "scipy-milp",
"objective_chf": float(result.fun),
"planned_peak_kw": float(result.x[peak]),
"start_soc_percent": float(start_soc),
"economic_min_soc_percent": float(economic_min_soc),
}
def optimize_grid_setpoint(data_obj, pv_dict, load_dict, forecast_id=None):
plan = optimize_battery_plan(data_obj, pv_dict, load_dict)
if forecast_id is not None:
data_obj.setdefault("battery_plans", {})[int(forecast_id)] = plan
return plan["grid"]
@@ -0,0 +1,32 @@
import os
import joblib
MODEL_DIR = "/app/data/models"
def model_path(aid, forecast_id):
os.makedirs(MODEL_DIR, exist_ok=True)
return os.path.join(MODEL_DIR, f"forecast_var_{forecast_id}_{aid}.pkl")
def save_model(aid, forecast_id, artifact):
path = model_path(aid, forecast_id)
tmp_path = f"{path}.tmp.{os.getpid()}"
try:
joblib.dump(artifact, tmp_path)
os.replace(tmp_path, path)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
return path
def load_model(aid, forecast_id):
path = model_path(aid, forecast_id)
if not os.path.exists(path):
return None
try:
return joblib.load(path)
except Exception as exc:
print(f"Modell {path} konnte nicht geladen werden: {exc}")
return None
@@ -0,0 +1,66 @@
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from methods.common import load_model, save_model
from shared_utils import calc_pure_math_pv, roof_features
FORECAST_ID = 1
FEATURES = [
"math_pv",
"temp_c",
"cloud",
"hour_sin",
"hour_cos",
"sin_year",
"cos_year",
"pv_kwp_total",
"roof_azimuth_sin",
"roof_azimuth_cos",
"roof_tilt_avg",
"roof_south_factor",
]
def _features(frame, config):
out = frame.copy()
out["math_pv"] = [calc_pure_math_pv(config, t) for t in out.index]
rf = roof_features(config)
for k, v in rf.items():
out[k] = v
for col, default in [("temp_c", 15.0), ("cloud", 20.0)]:
if col not in out.columns:
out[col] = default
out[col] = pd.to_numeric(out[col], errors="coerce").ffill().bfill().fillna(default)
return out[FEATURES].astype(float)
def train(data_obj):
config = data_obj["config"]
aid = config["anlagen_id"]
df = data_obj.get("df_pv_training", data_obj["df_hist"]).copy()
if "PV" not in df.columns:
return {"trained": False, "reason": "PV fehlt"}
X = _features(df, config)
y = pd.to_numeric(df["PV"], errors="coerce")
valid = X.notna().all(axis=1) & y.notna()
X, y = X.loc[valid], y.loc[valid]
if len(X) < 288:
return {"trained": False, "reason": "zu wenig Daten", "samples": int(len(X))}
model = RandomForestRegressor(n_estimators=400, max_depth=18, min_samples_leaf=2, random_state=42, n_jobs=-1)
model.fit(X, y)
path = save_model(aid, FORECAST_ID, {"model": model, "features": FEATURES})
return {"trained": True, "samples": int(len(X)), "path": path}
def predict(data_obj):
config = data_obj["config"]
aid = config["anlagen_id"]
artifact = load_model(aid, FORECAST_ID)
X = _features(data_obj["df_fut"], config)
if artifact and "model" in artifact:
values = artifact["model"].predict(X)
else:
values = X["math_pv"].to_numpy()
ac_limit = float(config.get("ac_leistung", 10.0) or 10.0) * 1000.0
return {t: max(0.0, min(float(v), ac_limit)) for t, v in zip(data_obj["df_fut"].index, values)}
@@ -0,0 +1,12 @@
from telemetry_quality import repeat_daily_profile
import datetime
from methods.common import save_model
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, 10, {"type": "repeat_pv_24h"})}
def predict(data_obj):
return repeat_daily_profile(data_obj["df_hist"], data_obj["df_fut"].index, 'PV')
@@ -0,0 +1,12 @@
from telemetry_quality import repeat_daily_profile
import datetime
from methods.common import save_model
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, 11, {"type": "repeat_load_24h"})}
def predict(data_obj):
return repeat_daily_profile(data_obj["df_hist"], data_obj["df_fut"].index, 'Hausverbrauch')
@@ -0,0 +1,13 @@
from methods.battery_optimizer import optimize_grid_setpoint, train_artifact
from methods.common import save_model
FORECAST_ID = 13
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, FORECAST_ID, train_artifact("var_10_11"))}
def predict(data_obj, pv_dict, load_dict):
return optimize_grid_setpoint(data_obj, pv_dict, load_dict, forecast_id=13)
@@ -0,0 +1,169 @@
import datetime
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from methods.common import load_model, save_model
from telemetry_quality import profile_source_value
FORECAST_ID = 2
FEATURES = [
"temp_c",
"cloud",
"hour_sin",
"hour_cos",
"weekday",
"is_weekday",
"load_24h_ago",
"load_7d_ago",
"energy_24h_rolling",
"load_3d_same_time_mean",
"load_7d_same_time_mean",
]
def _history_features(df):
out = df.copy()
out["load_24h_ago"] = out["Hausverbrauch"].shift(288)
out["load_7d_ago"] = out["Hausverbrauch"].shift(2016)
out["energy_24h_rolling"] = (out["Hausverbrauch"] * 5 / 60 / 1000).shift(1).rolling(288).sum()
same_time_lags = [out["Hausverbrauch"].shift(288 * d) for d in range(1, 8)]
out["load_3d_same_time_mean"] = pd.concat(same_time_lags[:3], axis=1).mean(axis=1)
out["load_7d_same_time_mean"] = pd.concat(same_time_lags, axis=1).mean(axis=1)
for col, default in [("temp_c", 15.0), ("cloud", 20.0)]:
if col not in out.columns:
out[col] = default
out[col] = pd.to_numeric(out[col], errors="coerce").ffill().bfill().fillna(default)
return out
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
df = data_obj.get("df_load_training", data_obj["df_hist"]).copy()
if "Hausverbrauch" not in df.columns:
return {"trained": False, "reason": "Hausverbrauch fehlt"}
df = _history_features(df)
X = df[FEATURES].astype(float)
y = pd.to_numeric(df["Hausverbrauch"], errors="coerce")
valid = X.notna().all(axis=1) & y.notna()
X, y = X.loc[valid], y.loc[valid]
if len(X) < 288:
return {"trained": False, "reason": "zu wenig Daten", "samples": int(len(X))}
validation_day = X.index.max().normalize()
validation_mask = X.index >= validation_day
if int(validation_mask.sum()) < 144:
validation_day -= datetime.timedelta(days=1)
validation_mask = (X.index >= validation_day) & (X.index < validation_day + datetime.timedelta(days=1))
score = None
if int((~validation_mask).sum()) >= 288 and int(validation_mask.sum()) >= 96:
eval_model = HistGradientBoostingRegressor(
max_iter=900,
max_depth=12,
learning_rate=0.02,
min_samples_leaf=8,
random_state=42,
)
eval_model.fit(X.loc[~validation_mask], y.loc[~validation_mask])
score = float(eval_model.score(X.loc[validation_mask], y.loc[validation_mask]))
model = HistGradientBoostingRegressor(
max_iter=1200,
max_depth=12,
learning_rate=0.02,
min_samples_leaf=8,
random_state=42,
)
model.fit(X, y)
path = save_model(
aid,
FORECAST_ID,
{"model": model, "features": FEATURES, "r2_last_day": score},
)
return {
"trained": True,
"samples": int(len(X)),
"r2_last_day": score,
"path": path,
}
def _history_value(history, ts):
if history is None or history.empty or ts not in history.index or "Hausverbrauch" not in history.columns:
return None
value = history.at[ts, "Hausverbrauch"]
return float(value) if pd.notna(value) and np.isfinite(value) and value >= 0 else None
def _same_time_values(history, t, days):
values = []
for day in range(1, days + 1):
value = _history_value(history, t - datetime.timedelta(days=day))
if value is not None and np.isfinite(value):
values.append(max(0.0, value))
return values
def predict(data_obj):
aid = data_obj["config"]["anlagen_id"]
artifact = load_model(aid, FORECAST_ID)
model = artifact["model"] if artifact and "model" in artifact else None
try:
score = float(artifact.get("r2_last_day")) if artifact and artifact.get("r2_last_day") is not None else 0.0
except (TypeError, ValueError):
score = 0.0
model_weight = min(0.35, max(0.0, score) * 0.35) if np.isfinite(score) else 0.0
frames = [
frame
for frame in (
data_obj.get("df_load_training"),
data_obj.get("df_hist"),
data_obj.get("df_recent_raw"),
)
if frame is not None and not frame.empty and "Hausverbrauch" in frame.columns
]
history = pd.concat(frames).sort_index() if frames else pd.DataFrame(columns=["Hausverbrauch"])
history = history[~history.index.duplicated(keep="last")]
fut = data_obj["df_fut"]
res = {}
for t in fut.index:
same_values = _same_time_values(history, t, 7)
load_24 = _history_value(history, t - datetime.timedelta(days=1))
load_7d = _history_value(history, t - datetime.timedelta(days=7))
if load_24 is None:
load_24 = load_7d if load_7d is not None else profile_source_value(history, t, "Hausverbrauch")
if load_7d is None:
load_7d = load_24
same_median = float(np.median(same_values)) if same_values else load_7d
profile = max(0.0, (0.50 * load_24) + (0.30 * load_7d) + (0.20 * same_median))
window = history.loc[
t - datetime.timedelta(days=1):t - datetime.timedelta(minutes=5),
"Hausverbrauch",
]
energy = float((window.sum() * 5 / 60) / 1000.0) if not window.empty else 0.0
same_3 = same_values[:3]
row = pd.DataFrame([[
float(fut.at[t, "temp_c"]),
float(fut.at[t, "cloud"]),
float(fut.at[t, "hour_sin"]),
float(fut.at[t, "hour_cos"]),
int(fut.at[t, "weekday"]),
int(fut.at[t, "is_weekday"]),
load_24,
load_7d,
energy,
float(np.mean(same_3)) if same_3 else profile,
float(np.mean(same_values)) if same_values else profile,
]], columns=FEATURES)
pred = profile
if model is not None and model_weight > 0.0:
model_pred = max(0.0, float(model.predict(row)[0]))
model_pred = min(max(model_pred, profile * 0.25), max(500.0, profile * 3.0))
pred = ((1.0 - model_weight) * profile) + (model_weight * model_pred)
res[t] = pred
history.loc[t, "Hausverbrauch"] = pred
return res
@@ -0,0 +1,62 @@
import datetime
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from methods.common import load_model, save_model
FORECAST_ID = 21
FEATURES = ["temp_c", "hour_cos", "pv_24h_ago"]
def _feature_frame(df):
out = df.copy()
if "temp_c" not in out.columns:
out["temp_c"] = 15.0
out["temp_c"] = pd.to_numeric(out["temp_c"], errors="coerce").ffill().bfill().fillna(15.0)
out["hour_float"] = out.index.hour + out.index.minute / 60.0
out["hour_cos"] = np.cos(2 * np.pi * out["hour_float"] / 24.0)
if "PV" in out.columns:
out["pv_24h_ago"] = out["PV"].shift(288)
return out
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
df = _feature_frame(data_obj.get("df_pv_training", data_obj["df_hist"]).copy())
if "PV" not in df.columns:
return {"trained": False, "reason": "PV fehlt"}
df = df.dropna(subset=FEATURES + ["PV"])
if len(df) < 288:
return {"trained": False, "reason": "zu wenig Daten", "samples": int(len(df))}
last_day = df.index.max().normalize()
train_df = df[df.index < last_day]
test_df = df[(df.index >= last_day) & (df.index < last_day + datetime.timedelta(days=1))]
score = None
if len(train_df) >= 288 and len(test_df) >= 12:
eval_model = RandomForestRegressor(n_estimators=300, max_depth=15, random_state=42)
eval_model.fit(train_df[FEATURES], train_df["PV"])
score = float(eval_model.score(test_df[FEATURES], test_df["PV"]))
print(f"[var_21] R2 PV letzter kompletter Tag: {score:.3f}")
model = RandomForestRegressor(n_estimators=300, max_depth=15, random_state=42)
model.fit(df[FEATURES], df["PV"])
path = save_model(aid, FORECAST_ID, {"model": model, "features": FEATURES, "r2_last_day": score})
return {"trained": True, "samples": int(len(df)), "features": FEATURES, "r2_last_day": score, "path": path}
def predict(data_obj):
aid = data_obj["config"]["anlagen_id"]
artifact = load_model(aid, FORECAST_ID)
model = artifact["model"] if artifact and "model" in artifact else None
hist = data_obj["df_hist"]
fut = data_obj["df_fut"].copy()
res = {}
for t in fut.index:
t_24 = t - datetime.timedelta(days=1)
pv_24 = float(hist.at[t_24, "PV"]) if t_24 in hist.index else 0.0
row = pd.DataFrame([[float(fut.at[t, "temp_c"]), float(fut.at[t, "hour_cos"]), pv_24]], columns=FEATURES)
pred = float(model.predict(row)[0]) if model is not None else pv_24
res[t] = max(0.0, pred)
return res
@@ -0,0 +1,121 @@
import datetime
import numpy as np
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.inspection import permutation_importance
from methods.common import load_model, save_model
FORECAST_ID = 22
FEATURES = ["temp_c", "hour_cos", "load_24h_ago", "load_7d_ago", "energy_24h_rolling", "weekday"]
def _feature_frame(df):
out = df.copy()
if "temp_c" not in out.columns:
out["temp_c"] = 15.0
out["temp_c"] = pd.to_numeric(out["temp_c"], errors="coerce").ffill().bfill().fillna(15.0)
out["hour_float"] = out.index.hour + out.index.minute / 60.0
out["hour_cos"] = np.cos(2 * np.pi * out["hour_float"] / 24.0)
out["weekday"] = out.index.weekday
if "Hausverbrauch" in out.columns:
out["load_24h_ago"] = out["Hausverbrauch"].shift(288)
out["load_7d_ago"] = out["Hausverbrauch"].shift(2016)
out["energy_5m_kwh"] = out["Hausverbrauch"] * (5 / 60) / 1000
out["energy_24h_rolling"] = out["energy_5m_kwh"].shift(1).rolling(window=288).sum()
out = out.drop(columns=["energy_5m_kwh"])
return out
def _history_value(history, recent, reference, ts):
for frame in (history, recent, reference):
if frame is not None and not frame.empty and ts in frame.index and "Hausverbrauch" in frame.columns:
value = frame.at[ts, "Hausverbrauch"]
if pd.notna(value):
return float(value)
return None
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
df = _feature_frame(data_obj.get("df_load_training", data_obj["df_hist"]).copy())
if "Hausverbrauch" not in df.columns:
return {"trained": False, "reason": "Hausverbrauch fehlt"}
df = df.dropna(subset=FEATURES + ["Hausverbrauch"])
if len(df) < 288:
return {"trained": False, "reason": "zu wenig Daten", "samples": int(len(df))}
last_day = df.index.max().normalize()
train_df = df[df.index < last_day]
test_df = df[(df.index >= last_day) & (df.index < last_day + datetime.timedelta(days=1))]
score = None
importance = {}
if len(train_df) >= 288 and len(test_df) >= 12:
eval_model = HistGradientBoostingRegressor(max_iter=2500, max_depth=25, learning_rate=0.01, min_samples_leaf=1, random_state=42)
eval_model.fit(train_df[FEATURES], train_df["Hausverbrauch"])
score = float(eval_model.score(test_df[FEATURES], test_df["Hausverbrauch"]))
print(f"[var_22] R2 Hausverbrauch letzter kompletter Tag: {score:.3f}")
try:
perm = permutation_importance(eval_model, test_df[FEATURES], test_df["Hausverbrauch"], n_repeats=10, random_state=42)
order = perm.importances_mean.argsort()[::-1]
importance = {FEATURES[i]: float(perm.importances_mean[i]) for i in order}
print("[var_22] Feature-Wichtigkeit Hausverbrauch:")
for name, val in importance.items():
print(f" {name}: {val:.4f}")
except Exception as exc:
print(f"[var_22] permutation_importance nicht berechnet: {exc}")
model = HistGradientBoostingRegressor(max_iter=2500, max_depth=25, learning_rate=0.01, min_samples_leaf=1, random_state=42)
model.fit(df[FEATURES], df["Hausverbrauch"])
path = save_model(aid, FORECAST_ID, {"model": model, "features": FEATURES, "r2_last_day": score, "importance": importance})
return {"trained": True, "samples": int(len(df)), "features": FEATURES, "r2_last_day": score, "path": path}
def predict(data_obj):
aid = data_obj["config"]["anlagen_id"]
artifact = load_model(aid, FORECAST_ID)
model = artifact["model"] if artifact and "model" in artifact else None
try:
score = float(artifact.get("r2_last_day")) if artifact and artifact.get("r2_last_day") is not None else 0.0
except (TypeError, ValueError):
score = 0.0
model_weight = min(0.35, max(0.0, score) * 0.35) if np.isfinite(score) else 0.0
hist = data_obj["df_hist"].copy()
recent = data_obj.get("df_recent_raw", pd.DataFrame())
reference = data_obj.get("df_load_training", pd.DataFrame())
fut = data_obj["df_fut"]
res = {}
for t in fut.index:
t_24 = t - datetime.timedelta(days=1)
t_7d = t - datetime.timedelta(days=7)
load_7d = _history_value(hist, recent, reference, t_7d)
load_24 = _history_value(hist, recent, reference, t_24)
if load_24 is None:
load_24 = load_7d if load_7d is not None else 0.0
if load_7d is None:
load_7d = load_24
ref_end = t - datetime.timedelta(days=7)
ref_start = ref_end - datetime.timedelta(days=1)
if not reference.empty and "Hausverbrauch" in reference.columns:
window = reference.loc[ref_start:ref_end - datetime.timedelta(minutes=5), "Hausverbrauch"]
else:
window = pd.Series(dtype=float)
if window.empty and "Hausverbrauch" in recent.columns:
window = recent.loc[t - datetime.timedelta(days=1):t - datetime.timedelta(minutes=5), "Hausverbrauch"]
roll_energy = float((window.sum() * 5 / 60) / 1000.0) if not window.empty else 0.0
row = pd.DataFrame([[
float(fut.at[t, "temp_c"]),
float(fut.at[t, "hour_cos"]),
load_24,
load_7d,
roll_energy,
int(fut.at[t, "weekday"]),
]], columns=FEATURES)
profile = max(0.0, (0.65 * load_24) + (0.35 * load_7d))
pred = profile
if model is not None and model_weight > 0.0:
model_pred = max(0.0, float(model.predict(row)[0]))
pred = ((1.0 - model_weight) * profile) + (model_weight * model_pred)
res[t] = pred
hist.loc[t, "Hausverbrauch"] = pred
return res
@@ -0,0 +1,13 @@
from methods.battery_optimizer import optimize_grid_setpoint, train_artifact
from methods.common import save_model
FORECAST_ID = 23
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, FORECAST_ID, train_artifact("var_21_22"))}
def predict(data_obj, pv_dict, load_dict):
return optimize_grid_setpoint(data_obj, pv_dict, load_dict, forecast_id=23)
@@ -0,0 +1,13 @@
from methods.battery_optimizer import optimize_grid_setpoint, train_artifact
from methods.common import save_model
FORECAST_ID = 3
def train(data_obj):
aid = data_obj["config"]["anlagen_id"]
return {"trained": True, "path": save_model(aid, FORECAST_ID, train_artifact("var_1_2"))}
def predict(data_obj, pv_dict, load_dict):
return optimize_grid_setpoint(data_obj, pv_dict, load_dict, forecast_id=3)
@@ -0,0 +1,45 @@
"""Forecast family fault isolation; no IO, fabricated samples or model switching."""
from collections.abc import Mapping
from math import isfinite
from numbers import Real
def collect_predictions(data, config, models, enabled):
"""Return complete finite PV/load series independently for each requested model.
A model with insufficient history must not prevent other valid families being
published. It is omitted, not filled with zeros or replaced by another model.
Global raw-telemetry checks still run BEFORE this helper in run_forecast.
"""
expected = tuple(data['df_fut'].index)
if not expected or len(set(expected)) != len(expected):
raise ValueError('Nonempty unique future interval index required')
expected_keys = set(expected)
predictions, errors = {}, {}
requested = 0
for variant, model in models.items():
predictions[variant] = {}
if not enabled(config, variant):
continue
requested += 1
try:
values = model.predict(data)
if not isinstance(values, Mapping) or set(values) != expected_keys:
raise ValueError('Missing, extra or non-matching forecast timestamps')
cleaned = {}
for at in expected:
value = values[at]
if isinstance(value, bool) or not isinstance(value, Real):
raise ValueError('Non-numeric forecast power')
value = float(value)
if not isfinite(value) or value < 0:
raise ValueError('Invalid nonnegative forecast power')
cleaned[at] = value
predictions[variant] = cleaned
except Exception as error:
# Expected data failures and unexpected model failures are visible,
# but model exception text may contain filesystem paths. Do not leak it.
errors[variant] = {'status': 'unavailable', 'errorType': type(error).__name__}
if requested and all(not p for p in predictions.values()):
raise ValueError('All requested PV/load models failed; no forecasts published')
return predictions, errors
@@ -0,0 +1,108 @@
"""Stdlib-only, explicit opt-in publisher for the existing ENELIX services.
No credentials are printed. Existing V1 computation survives shadow-service errors.
Legacy UTC-naive forecast indices are explicitly interpreted as UTC here.
"""
from datetime import datetime,timezone
from hashlib import sha256
from uuid import uuid4
from urllib.parse import urlparse
from urllib.request import Request,urlopen
import json,logging,math,os
def timestamp(value):
if hasattr(value,'to_pydatetime'):value=value.to_pydatetime()
if isinstance(value,str):value=datetime.fromisoformat(value.replace('Z','+00:00'))
if value.tzinfo is None:value=value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat()
def tariff_id(label):
if not isinstance(label,str) or not label.strip():raise ValueError('Exact tariff label required')
return 'legacy:'+sha256(label.encode()).hexdigest()[:32]
def envelope(at=None):return {'version':1,'eventId':str(uuid4()),'observedAt':timestamp(at or datetime.now(timezone.utc))}
def tariff_payload(config,at=None):
result=envelope(at)
for side,prefix in (('import','tarif_bezug'),('export','tarif_einspeisung')):
mode={'statisch':'static','dynamisch':'dynamic','static':'static','dynamic':'dynamic'}.get(config.get(prefix+'_modus'))
if mode is None:raise ValueError('Explicit tariff mode required; not inferred from name')
item={'mode':mode,'tariffId':tariff_id(config[prefix])}
if mode=='static':
value=config.get(prefix+'_fest')
if value is None or isinstance(value,bool) or not math.isfinite(float(value)):raise ValueError('Static price missing/invalid')
item['staticChfKwh']=float(value)
result[side]=item
peak=config.get('tarif_peak_fest')
if peak is None or isinstance(peak,bool) or not math.isfinite(float(peak)) or float(peak)<0:raise ValueError('Explicit peak price required')
result['peakChfKwMonth']=float(peak)
return result
def forecast_payload(forecasts,at=None,load_basis='house_total',trained_until=None):
result=envelope(at);result['families']={}
for key,pv,load in forecasts:
if not pv or not load:continue
if set(pv)!=set(load):raise ValueError('PV/load timestamps differ')
result['families'][str(key)]={'loadBasis':load_basis,'trainedUntil':trained_until,
'points':[{'time':timestamp(t),'pvW':float(pv[t]),'loadW':float(load[t])} for t in sorted(pv)]}
if not result['families']:raise ValueError('No forecast families supplied')
return result
def ckw_payload(label,rows,publication_timestamp=None,at=None):
"""Provider-delimited integrated price intervals only. No scalar price replication."""
result=envelope(at);periods=[]
units={'CHF_kWh':'CHF/kWh','CHF/kWh':'CHF/kWh','Rp/kWh':'Rp/kWh','CHF/MWh':'CHF/MWh'}
for row in rows:
if not row.get('start_timestamp') or not row.get('end_timestamp'):raise ValueError('Explicit delivery start/end required')
integrated=row.get('integrated')
if isinstance(integrated,list):
if len(integrated)!=1:raise ValueError('Ambiguous integrated price components')
integrated=integrated[0]
if not isinstance(integrated,dict) or integrated.get('unit') not in units:raise ValueError('Provider-declared unit required')
value=integrated.get('value')
if value is None or isinstance(value,bool) or not math.isfinite(float(value)):raise ValueError('Finite integrated price required')
item={'tariffId':tariff_id(label),'side':'import','start':timestamp(row['start_timestamp']),
'end':timestamp(row['end_timestamp']),'value':float(value),'unit':units[integrated['unit']],
'observedAt':result['observedAt'],'sourceKind':'published_interval'}
if publication_timestamp:
published=timestamp(publication_timestamp)
if datetime.fromisoformat(published)>datetime.fromisoformat(result['observedAt']):raise ValueError('Future publication')
item['publishedAt']=published
periods.append(item)
result['periods']=periods
return result
def enabled(plant):return bool(os.getenv('NETPLAN_V4_URL') and plant in {p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()})
def send(plant,kind,payload):
if not enabled(plant):return {'status':'disabled'}
token=os.getenv('PROGNOSIS_SERVICE_TOKEN','');base=os.environ['NETPLAN_V4_URL'].rstrip('/');url=urlparse(base)
if not token or url.scheme not in ('http','https') or url.username or url.password:raise ValueError('Private V4 transport not configured')
UUID=__import__('uuid').UUID;UUID(plant)
if kind not in ('forecast','tariffs','prices'):raise ValueError('Unsupported publisher input kind')
req=Request(base+'/internal/v2/prognosis/'+plant+'/planner/inputs/'+kind,
json.dumps(payload,allow_nan=False).encode(),{'Content-Type':'application/json','X-Enelix-Service-Token':token},method='POST')
with urlopen(req,timeout=5) as response:
data=response.read(65537)
if len(data)>65536:raise ValueError('Oversized service response')
return json.loads(data)
def publish_forecasts(config,forecasts):
plant=config['anlagen_id']
if not enabled(plant):return
try:
send(plant,'tariffs',tariff_payload(config));send(plant,'forecast',forecast_payload(forecasts))
except Exception as exc:logging.getLogger(__name__).warning('V4 shadow forecast for %s: %s',plant,type(exc).__name__)
def publish_tariffs(plant,config):
if not enabled(plant):return
try:send(plant,'tariffs',tariff_payload(config))
except Exception as exc:logging.getLogger(__name__).warning('V4 shadow tariffs for %s: %s',plant,type(exc).__name__)
def publish_ckw(label,rows,publication_timestamp=None,tariff_type='integrated'):
plants=[p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()]
if not os.getenv('NETPLAN_V4_URL') or not plants:return
try:
if tariff_type!='integrated':raise ValueError('Full integrated tariff required')
payload=ckw_payload(label,rows,publication_timestamp)
for plant in plants:send(plant,'prices',payload)
except Exception as exc:logging.getLogger(__name__).warning('V4 CKW price provenance: %s',type(exc).__name__)
@@ -0,0 +1,93 @@
import math
def as_float(value, default=0.0):
try:
if value is None:
return default
return float(value)
except Exception:
return default
def roof_kwp(roof):
return as_float(roof.get("kwp", roof.get("leistung", roof.get("pv_kwp", 0.0))), 0.0)
def roof_azimuth_deg(roof):
return as_float(roof.get("azimut", roof.get("azimuth", roof.get("ausrichtung", 180.0))), 180.0)
def roof_tilt_deg(roof):
return as_float(roof.get("neigung", roof.get("tilt", 30.0)), 30.0)
def roof_features(config):
roofs = config.get("daecher", []) or []
if not roofs:
roofs = [{"kwp": as_float(config.get("ac_leistung", 0.0), 0.0), "azimut": 180.0, "neigung": 30.0}]
total_kwp = sum(roof_kwp(r) for r in roofs)
if total_kwp <= 0:
total_kwp = as_float(config.get("ac_leistung", 0.0), 0.0)
weighted_az = 0.0
weighted_tilt = 0.0
south_factor = 0.0
for roof in roofs:
w = roof_kwp(roof) / total_kwp if total_kwp > 0 else 0.0
az = roof_azimuth_deg(roof)
tilt = roof_tilt_deg(roof)
weighted_az += w * az
weighted_tilt += w * tilt
south_factor += w * max(0.0, math.cos(math.radians(az - 180.0)))
return {
"pv_kwp_total": total_kwp,
"roof_azimuth_sin": math.sin(math.radians(weighted_az)),
"roof_azimuth_cos": math.cos(math.radians(weighted_az)),
"roof_tilt_avg": weighted_tilt,
"roof_south_factor": south_factor,
}
def calc_pure_math_pv(config, dt):
ac_limit = as_float(config.get("ac_leistung", 10.0), 10.0) * 1000.0
roofs = config.get("daecher", []) or []
if not roofs:
roofs = [{"kwp": as_float(config.get("ac_leistung", 0.0), 0.0), "neigung": 30.0, "azimut": 180.0}]
day = dt.timetuple().tm_yday
hour = dt.hour + dt.minute / 60.0
lat = math.radians(as_float(config.get("latitude", 47.0), 47.0))
decl = math.radians(23.45 * math.sin(math.radians(360.0 * (day - 81) / 365.0)))
hour_angle = math.radians(15.0 * (hour - 12.0))
sin_alt = math.sin(lat) * math.sin(decl) + math.cos(lat) * math.cos(decl) * math.cos(hour_angle)
sun_alt = math.asin(max(-1.0, min(1.0, sin_alt)))
if sun_alt <= 0.0:
return 0.0
sun_az = math.atan2(
math.sin(hour_angle),
math.cos(hour_angle) * math.sin(lat) - math.tan(decl) * math.cos(lat),
)
total = 0.0
for roof in roofs:
kwp = roof_kwp(roof)
if kwp <= 0:
continue
tilt = math.radians(roof_tilt_deg(roof))
az = math.radians(roof_azimuth_deg(roof))
cos_inc = math.sin(sun_alt) * math.cos(tilt) + math.cos(sun_alt) * math.sin(tilt) * math.cos(sun_az - az)
if cos_inc > 0:
total += kwp * 1000.0 * cos_inc
return max(0.0, min(total, ac_limit))
def calc_pure_math_load(dt):
hour = dt.hour + dt.minute / 60.0
base = 650.0
morning = 220.0 * math.exp(-((hour - 7.0) ** 2) / 5.0)
evening = 380.0 * math.exp(-((hour - 19.0) ** 2) / 8.0)
weekend = 1.12 if dt.weekday() >= 5 else 1.0
return max(0.0, (base + morning + evening) * weekend)
@@ -0,0 +1,66 @@
from influxdb_client import Point, WritePrecision
DT_H = 5.0 / 60.0
def _f(config, key, default):
try:
return float(config.get(key, default) or default)
except Exception:
return float(default)
def battery_soc_points(data_obj, forecast_var, pv_dict, load_dict, grid_dict):
config = data_obj["config"]
aid = str(config["anlagen_id"])
cap_kwh = _f(config, "batt_capacity_kwh", 0.0)
if cap_kwh <= 0 or not grid_dict:
return []
min_soc = _f(config, "batt_min_soc", _f(config, "batt_min_soc_percent", 0.0))
max_soc = _f(config, "batt_max_soc", _f(config, "batt_max_soc_percent", 100.0))
charge_eff = max(0.01, min(1.0, _f(config, "batt_charge_efficiency", 0.95)))
discharge_eff = max(0.01, min(1.0, _f(config, "batt_discharge_efficiency", 0.95)))
start_soc_value = data_obj.get("current_soc_perc")
if start_soc_value is None or start_soc_value == "":
start_soc_value = config.get("batt_soc_percent", 50.0)
start_soc = float(start_soc_value)
start_soc = max(min_soc, min(max_soc, start_soc))
min_kwh = cap_kwh * min_soc / 100.0
max_kwh = cap_kwh * max_soc / 100.0
soc_kwh = max(min_kwh, min(max_kwh, cap_kwh * start_soc / 100.0))
planned_battery = (
data_obj.get("battery_plans", {})
.get(int(forecast_var), {})
.get("battery", {})
)
points = []
for t in data_obj["df_fut"].index:
if t not in grid_dict:
continue
if t in planned_battery:
battery_target_w = float(planned_battery[t]) # positiv = laden
else:
residual_w = float(load_dict.get(t, 0.0)) - float(pv_dict.get(t, 0.0))
grid_w = float(grid_dict.get(t, 0.0))
battery_target_w = grid_w - residual_w
if battery_target_w > 0:
soc_kwh += battery_target_w * DT_H / 1000.0 * charge_eff
elif battery_target_w < 0:
soc_kwh += battery_target_w * DT_H / 1000.0 / discharge_eff
soc_kwh = max(min_kwh, min(max_kwh, soc_kwh))
soc_percent = max(0.0, min(100.0, soc_kwh / cap_kwh * 100.0))
points.append(
Point("forecast_diagnostics")
.tag("anlagen_id", aid)
.tag("data_type", "battery_soc_simulation")
.tag("forecast_var", f"prog_var_{forecast_var}")
.field("soc_percent", float(soc_percent))
.time(t.to_pydatetime() if hasattr(t, "to_pydatetime") else t, WritePrecision.S)
)
return points
@@ -0,0 +1,106 @@
"""Measured telemetry integrity and repeat-profile helpers.
No IO, no fabricated measurements and no fixed household-load fallback.
Forecast freshness is checked against raw telemetry, never against filled features.
"""
from __future__ import annotations
import datetime
import math
import numpy as np
import pandas as pd
MEASURED_COLUMNS = ('PV', 'Hausverbrauch', 'Netzleistung', 'SOC')
class TelemetryUnavailable(ValueError):
"""No publishable forecast can be derived from the supplied observations."""
def sanitize_measured_frame(frame):
out = frame.copy()
for column in MEASURED_COLUMNS:
if column not in out:
continue
raw = out[column]
numeric = pd.to_numeric(raw, errors='coerce').astype(float)
bad = ~np.isfinite(numeric) | raw.map(lambda x: isinstance(x, (bool, np.bool_)))
if column in ('PV', 'Hausverbrauch', 'SOC'):
bad |= numeric < 0
if column == 'SOC':
bad |= numeric > 100
out[column] = numeric.mask(bad)
return out
def _naive_utc(value):
stamp = pd.Timestamp(value)
if stamp.tzinfo is not None:
stamp = stamp.tz_convert('UTC').tz_localize(None)
return stamp
def require_recent_telemetry(data_obj, fields=('PV','Hausverbrauch'), max_age_minutes=30.0):
"""Refuse missing/stale inputs without changing real zeros or raw samples."""
if isinstance(max_age_minutes, bool) or not math.isfinite(max_age_minutes) or max_age_minutes <= 0:
raise ValueError('Positive telemetry age limit required')
now = _naive_utc(data_obj['now'])
raw = data_obj.get('df_recent_raw')
raw = sanitize_measured_frame(raw) if raw is not None else pd.DataFrame()
if not raw.empty:
raw.index = pd.DatetimeIndex([_naive_utc(t) for t in raw.index])
raw = raw.loc[raw.index < now].sort_index()
report = {}
errors = []
for field in fields:
if field not in MEASURED_COLUMNS:
raise ValueError('Unknown telemetry target')
values = raw[field].dropna() if field in raw else pd.Series(dtype=float)
if values.empty:
errors.append(field + ': keine gemessenen Werte')
continue
stamp = values.index[-1]
age = (now-stamp).total_seconds()/60.0
report[field] = {'lastObservedInterval': stamp.isoformat()+'Z', 'ageMinutes': age,
'observedIntervals': int(len(values)), 'lastValue': float(values.iloc[-1])}
if age > max_age_minutes:
errors.append(field + ': Messdaten veraltet (' + format(age,'.1f') + ' min)')
if errors:
raise TelemetryUnavailable('; '.join(errors) + '. Keine neuen Prognosen/Fahrplaene veroeffentlicht.')
return report
def _finite_nonnegative(value):
if isinstance(value, (bool, np.bool_)):
return None
try:
value = float(value)
except (ValueError, TypeError):
return None
return value if math.isfinite(value) and value >= 0.0 else None
def profile_source_value(history, at, column, predictions=None):
"""Repeat yesterday; prefer same weekday if yesterday is missing, then older days.
Explicitly generated first-day values may be repeated on the second forecast day.
Missing historical values are not zeros. No future measurement is read.
"""
if column not in ('PV','Hausverbrauch'):
raise ValueError('Unsupported repeat-profile target')
predictions = {} if predictions is None else predictions
at = pd.Timestamp(at)
for day in (1, 7, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14):
source = at - datetime.timedelta(days=day)
if source in predictions:
value = _finite_nonnegative(predictions[source])
if value is not None:
return value
if history is not None and column in history and source in history.index:
value = _finite_nonnegative(history.at[source,column])
if value is not None:
return value
raise TelemetryUnavailable(column + ': kein gemessener Tagesprofilwert fuer ' + str(at))
def repeat_daily_profile(history, future_index, column):
result = {}
for at in future_index:
result[at] = profile_source_value(history, at, column, result)
return result
@@ -0,0 +1,133 @@
import unittest
import pandas as pd
from methods.battery_optimizer import DT_H, optimize_battery_plan
class BatteryCostOptimizerTest(unittest.TestCase):
def plan(self, load, pv, import_prices=None, export_prices=None, **overrides):
index = pd.date_range("2026-09-29T00:00:00", periods=len(load), freq="5min")
config = {
"batt_capacity_kwh": 2.0,
"batt_power_kw": 1.0,
"batt_min_soc": 0.0,
"batt_max_soc": 100.0,
"batt_charge_efficiency": 1.0,
"batt_discharge_efficiency": 1.0,
"batt_soc_percent": 0.0,
"batt_grid_charging_enabled": False,
"batt_degradation_chf_kwh": 0.0,
"tarif_bezug": "dynamic",
"tarif_einspeisung": "dynamic",
"tarif_peak_fest": 0.0,
}
config.update(overrides)
data = {
"config": config,
"df_fut": pd.DataFrame({
"import_price": import_prices or [0.30] * len(index),
"export_price": export_prices or [0.10] * len(index),
}, index=index),
"current_soc_perc": config["batt_soc_percent"],
"current_month_peak_kw": overrides.get("current_month_peak_kw", 0.0),
}
return index, optimize_battery_plan(data, dict(zip(index, pv)), dict(zip(index, load)))
def test_grid_charging_is_opt_in(self):
index, plan = self.plan(
[500.0] * 4,
[0.0] * 4,
import_prices=[0.05, 0.05, 0.50, 0.50],
)
self.assertTrue(all(plan["battery"][t] <= 1e-6 for t in index))
def test_cheap_grid_energy_is_shifted_to_expensive_period(self):
index, plan = self.plan(
[500.0] * 4,
[0.0] * 4,
import_prices=[0.05, 0.05, 0.50, 0.50],
batt_grid_charging_enabled=True,
)
self.assertGreater(plan["battery"][index[0]], 0.0)
self.assertLess(plan["battery"][index[-1]], 0.0)
self.assertGreater(plan["grid"][index[0]], 500.0)
self.assertAlmostEqual(plan["grid"][index[-1]], 0.0, places=5)
def test_high_feed_in_value_prefers_export(self):
index, plan = self.plan(
[0.0, 1000.0],
[1000.0, 0.0],
import_prices=[0.20, 0.20],
export_prices=[0.60, 0.60],
)
self.assertAlmostEqual(plan["grid"][index[0]], -1000.0, places=5)
self.assertAlmostEqual(plan["grid"][index[1]], 1000.0, places=5)
def test_peak_tariff_prevents_grid_charge_above_existing_peak(self):
index, plan = self.plan(
[1000.0] * 6,
[0.0] * 6,
import_prices=[0.05] * 3 + [0.50] * 3,
batt_grid_charging_enabled=True,
tarif_peak_fest=20.0,
current_month_peak_kw=1.0,
)
self.assertLessEqual(plan["planned_peak_kw"], 1.0 + 1e-7)
self.assertTrue(all(plan["grid"][t] <= 1000.0 + 1e-5 for t in index))
def test_nearly_empty_battery_is_not_discharged_further_at_low_value(self):
index, plan = self.plan(
[1000.0] * 4,
[0.0] * 4,
import_prices=[0.01] * 4,
export_prices=[0.0] * 4,
batt_soc_percent=5.0,
batt_economic_reserve_soc_percent=10.0,
batt_terminal_value_chf_kwh=0.0,
)
self.assertTrue(all(plan["battery"][timestamp] >= -1e-6 for timestamp in index))
self.assertTrue(all(abs(plan["grid"][timestamp] - 1000.0) <= 1e-5 for timestamp in index))
self.assertAlmostEqual(plan["economic_min_soc_percent"], 5.0)
def test_profitable_export_discharge_remains_allowed_above_reserve(self):
index, plan = self.plan(
[100.0, 100.0],
[1000.0, 1000.0],
import_prices=[0.20, 0.20],
export_prices=[0.80, 0.80],
batt_soc_percent=100.0,
batt_economic_reserve_soc_percent=10.0,
batt_degradation_chf_kwh=0.03,
batt_terminal_value_chf_kwh=0.0,
)
self.assertTrue(any(plan["battery"][timestamp] < -1e-6 for timestamp in index))
self.assertTrue(all(plan["grid"][timestamp] <= -899.0 for timestamp in index))
self.assertAlmostEqual(plan["economic_min_soc_percent"], 10.0)
def test_power_and_soc_limits_hold(self):
load = [0.0] * 12 + [1000.0] * 12
pv = [1000.0] * 12 + [0.0] * 12
index, plan = self.plan(
load,
pv,
batt_capacity_kwh=1.0,
batt_power_kw=0.5,
batt_min_soc=20.0,
batt_max_soc=80.0,
batt_soc_percent=20.0,
)
soc = 0.2
for timestamp in index:
target = plan["battery"][timestamp]
self.assertLessEqual(abs(target), 500.0 + 1e-6)
if target >= 0.0:
soc += target * DT_H / 1000.0
else:
soc += target * DT_H / 1000.0
self.assertGreaterEqual(soc, 0.2 - 1e-8)
self.assertLessEqual(soc, 0.8 + 1e-8)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,66 @@
import unittest
from unittest.mock import patch
import numpy as np
import pandas as pd
from methods.var_2 import predict
from methods.var_11 import predict as predict_repeat
class LoadProfileForecastTest(unittest.TestCase):
def test_profile_keeps_daily_shape_across_48_hours(self):
history_index = pd.date_range("2026-09-21", periods=8 * 288, freq="5min")
phase = 2 * np.pi * (
history_index.hour.to_numpy() + history_index.minute.to_numpy() / 60.0
) / 24.0
history = pd.DataFrame(
{"Hausverbrauch": 1800.0 + 900.0 * np.cos(phase - np.pi)},
index=history_index,
)
future_index = pd.date_range(history_index[-1] + pd.Timedelta(minutes=5), periods=2 * 288, freq="5min")
future_phase = 2 * np.pi * (
future_index.hour.to_numpy() + future_index.minute.to_numpy() / 60.0
) / 24.0
future = pd.DataFrame(
{
"temp_c": 15.0,
"cloud": 20.0,
"hour_sin": np.sin(future_phase),
"hour_cos": np.cos(future_phase),
"weekday": future_index.weekday,
"is_weekday": (future_index.weekday < 5).astype(int),
},
index=future_index,
)
data = {
"config": {"anlagen_id": "test"},
"df_hist": history,
"df_load_training": history,
"df_recent_raw": history.iloc[-2 * 288 :],
"df_fut": future,
}
with patch("methods.var_2.load_model", return_value=None):
values = np.array(list(predict(data).values()))
self.assertGreater(float(values.max() - values.min()), 1200.0)
np.testing.assert_allclose(values[:288], values[288:], rtol=0.0, atol=1e-6)
def test_repeat_profile_remains_available_on_second_day(self):
history_index = pd.date_range("2026-09-29", periods=288, freq="5min")
daily_values = np.arange(288, dtype=float) + 1000.0
history = pd.DataFrame({"Hausverbrauch": daily_values}, index=history_index)
future_index = pd.date_range(history_index[-1] + pd.Timedelta(minutes=5), periods=576, freq="5min")
values = np.array(list(predict_repeat({
"df_hist": history,
"df_fut": pd.DataFrame(index=future_index),
}).values()))
np.testing.assert_allclose(values[:288], daily_values)
np.testing.assert_allclose(values[288:], daily_values)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,71 @@
import ast
import contextlib
import datetime
import io
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
from model_isolation import collect_predictions
class ModelIsolationTest(unittest.TestCase):
def setUp(self):
self.index = [datetime.datetime(2026,10,2,6,0) + datetime.timedelta(minutes=5*i) for i in range(3)]
self.data = {'df_fut': SimpleNamespace(index=self.index)}
self.good = {t: 1000.0 for t in self.index}
def model(self, values=None):
return SimpleNamespace(predict=Mock(return_value=self.good if values is None else values))
def call(self, models, enabled=lambda c,n:True):
return collect_predictions(self.data, {}, models, enabled)
def test_one_failed_model_does_not_remove_other_families(self):
bad=self.model();bad.predict.side_effect=ValueError('private path not logged')
forecasts,errors=self.call({1:self.model(),10:bad,21:self.model()})
self.assertEqual(forecasts[1],self.good);self.assertEqual(forecasts[21],self.good)
self.assertEqual(forecasts[10],{});self.assertEqual(errors[10]['errorType'],'ValueError')
self.assertNotIn('private',str(errors))
def test_missing_timestamp_does_not_get_filled(self):
forecasts,errors=self.call({1:self.model(),10:self.model({self.index[0]:20.0})})
self.assertFalse(forecasts[10]);self.assertIn(10,errors)
def test_nonfinite_negative_and_boolean_rejected(self):
for value in (float('nan'),float('inf'),-1.0,True,'2'):
with self.subTest(value=value):
result,errors=self.call({1:self.model(),10:self.model({t:value for t in self.index})})
self.assertFalse(result[10]);self.assertIn(10,errors)
def test_explicit_zero_forecast_is_not_imputed(self):
result,errors=self.call({10:self.model({t:0.0 for t in self.index})})
self.assertEqual(errors,{});self.assertEqual(sum(result[10].values()),0.)
def test_all_requested_models_fail_closed(self):
with self.assertRaisesRegex(ValueError,'All requested'):
self.call({10:self.model({})})
def test_disabled_models_not_called(self):
model=self.model();result,errors=self.call({10:model},lambda c,n:False)
model.predict.assert_not_called();self.assertEqual(result,{10:{}});self.assertEqual(errors,{})
def test_input_time_order_preserved(self):
result,_=self.call({1:self.model(dict(reversed(list(self.good.items()))))})
self.assertEqual(list(result[1]),self.index)
def test_run_forecast_publishes_valid_pairs_after_repeat_failure(self):
root=Path(__file__).resolve().parents[1]
tree=ast.parse((root/'main.py').read_text())
fn=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='run_forecast')
modules={n:self.model() for n in (1,2,3,10,11,13,21,22,23)}
modules[10].predict.side_effect=ValueError('profile gap')
client=Mock();published=Mock()
data={**self.data,'current_soc_perc':20.,'current_soc_source':'telemetry'}
ns={'datetime':datetime,'LOCAL_TZ':datetime.timezone.utc,'traceback':Mock(),
'get_configs':lambda:[{'anlagen_id':'test','batt_capacity_kwh':10.}],
'InfluxDBClient':Mock(return_value=client),'SYNCHRONOUS':object(),
'INFLUX_URL':'offline','INFLUX_TOKEN':'synthetic','INFLUX_ORG':'offline',
'INFLUX_BUCKET':'offline','INFLUX_TIMEOUT_MS':1,
'build_data_object':lambda *a,**k:data,'active':lambda c,n:True,
'require_recent_telemetry':lambda *a,**k:{},'collect_predictions':collect_predictions,
'_v4_publish_forecasts':published,'battery_soc_points':lambda *a:[],
'_forecast_point':lambda *a:a,'_snapshot_point':lambda *a:a,
'write_quality_metrics':Mock(),**{'v'+str(n):m for n,m in modules.items()}}
exec(compile(ast.Module(body=[fn],type_ignores=[]),'source-run-forecast','exec'),ns)
with contextlib.redirect_stdout(io.StringIO()):r=ns['run_forecast']()
self.assertEqual(r['completed'],['test'])
families=published.call_args.args[1]
self.assertTrue(families[0][1] and families[0][2]);self.assertFalse(families[1][1])
self.assertTrue(families[2][1] and families[2][2]);modules[13].predict.assert_not_called()
client.write_api.return_value.write.assert_called_once()
@@ -0,0 +1,213 @@
"""Offline regressions against the exact reviewed orchestration source.
Only selected function definitions are compiled; main is NOT imported, and there
is no database, network, model-file, prediction-publication or device access.
"""
import ast
import contextlib
import datetime
import io
from pathlib import Path
import unittest
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
from telemetry_quality import (TelemetryUnavailable, require_recent_telemetry,
sanitize_measured_frame, repeat_daily_profile, profile_source_value)
ROOT = Path(__file__).resolve().parents[1]
AT = pd.Timestamp('2026-10-01T20:00:00')
def functions(*names, **extra):
tree = ast.parse((ROOT/'main.py').read_text())
nodes = [n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name in names]
if {n.name for n in nodes} != set(names):
raise AssertionError('Reviewed function missing')
ns = {'pd':pd,'np':np,'datetime':datetime,
'sanitize_measured_frame':sanitize_measured_frame,
'require_recent_telemetry':require_recent_telemetry}
ns.update(extra)
exec(compile(ast.Module(body=nodes,type_ignores=[]),str(ROOT/'main.py'),'exec'),ns)
return ns
def raw_frame(start=None, periods=288):
index=pd.date_range(start if start is not None else AT-pd.Timedelta(days=1),periods=periods,freq='5min')
return pd.DataFrame({'PV':200.,'Hausverbrauch':3200.,'SOC':25.,'Netzleistung':3000.},index=index)
class MeasuredTelemetryTest(unittest.TestCase):
def test_empty_history_is_not_zero_consumption(self):
ns=functions('_fill_defaults','_add_time_features')
frame=ns['_fill_defaults'](pd.DataFrame(index=pd.date_range(AT,periods=3,freq='5min')),True)
self.assertTrue(frame[['PV','Hausverbrauch','SOC','Netzleistung']].isna().all().all())
self.assertEqual(frame['temp_c'].tolist(),[15.]*3)
def test_recorded_zero_is_preserved(self):
frame=raw_frame(periods=3);frame[:]=0.
ns=functions('_fill_defaults','_add_time_features')
out=ns['_fill_defaults'](frame,True)
self.assertEqual(out['Hausverbrauch'].tolist(),[0.]*3)
self.assertEqual(out['PV'].tolist(),[0.]*3)
def test_internal_and_trailing_measurement_gaps_remain_missing(self):
frame=raw_frame(periods=7)
frame.loc[frame.index[[0,2,3,6]],['PV','Hausverbrauch','SOC','Netzleistung']]=np.nan
ns=functions('_fill_defaults','_add_time_features')
out=ns['_fill_defaults'](frame,True)
self.assertEqual(int(out['Hausverbrauch'].isna().sum()),4)
self.assertEqual(int(out['SOC'].isna().sum()),4)
self.assertEqual(int(out['Netzleistung'].isna().sum()),4)
def test_bad_values_not_real_measurements(self):
frame=pd.DataFrame({'Hausverbrauch':[np.inf,-1.,True,0.,250.], 'Netzleistung':[-100.,np.nan,0.,1.,2.], 'SOC':[101.,-1.,np.inf,0.,100.]})
out=sanitize_measured_frame(frame)
self.assertTrue(out['Hausverbrauch'].iloc[:3].isna().all())
self.assertEqual(out['Hausverbrauch'].iloc[3],0.)
self.assertEqual(out['Netzleistung'].iloc[0],-100.)
self.assertTrue(out['SOC'].iloc[:3].isna().all())
def test_recent_recorded_zero_passes(self):
frame=raw_frame();frame[['PV','Hausverbrauch']]=0.
report=require_recent_telemetry({'now':AT,'df_recent_raw':frame})
self.assertEqual(report['Hausverbrauch']['ageMinutes'],5.)
def test_missing_raw_cannot_be_hidden_by_filled_feature_grid(self):
with self.assertRaises(TelemetryUnavailable):
require_recent_telemetry({'now':AT,'df_recent_raw':pd.DataFrame(),'df_hist':raw_frame()})
def test_stale_values_fail(self):
with self.assertRaises(TelemetryUnavailable):
require_recent_telemetry({'now':AT,'df_recent_raw':raw_frame(AT-pd.Timedelta(days=2))})
def test_freshness_is_checked_per_field(self):
frame=raw_frame();frame.loc[frame.index[-12:],'Hausverbrauch']=np.nan
with self.assertRaisesRegex(TelemetryUnavailable,'Hausverbrauch'):
require_recent_telemetry({'now':AT,'df_recent_raw':frame})
def test_future_measurements_do_not_rescue_freshness(self):
frame=raw_frame(AT,periods=3)
with self.assertRaises(TelemetryUnavailable):
require_recent_telemetry({'now':AT,'df_recent_raw':frame})
def test_aware_timestamps_normalized_to_utc(self):
frame=raw_frame();frame.index=frame.index.tz_localize('UTC').tz_convert('Europe/Zurich')
report=require_recent_telemetry({'now':AT.tz_localize('UTC'),'df_recent_raw':frame})
self.assertEqual(report['Hausverbrauch']['ageMinutes'],5.)
def test_soc_staleness_cannot_be_hidden(self):
frame=raw_frame();frame.loc[frame.index[-24:],'SOC']=np.nan
with self.assertRaisesRegex(TelemetryUnavailable,'SOC'):
require_recent_telemetry({'now':AT,'df_recent_raw':frame},['SOC'])
def test_only_exogenous_future_defaults_are_filled(self):
ns=functions('_fill_defaults','_add_time_features')
out=ns['_fill_defaults'](pd.DataFrame(index=pd.date_range(AT,periods=6,freq='5min')),False)
self.assertTrue(out[['PV','Hausverbrauch','SOC','Netzleistung']].isna().all().all())
def test_input_unchanged(self):
frame=raw_frame();before=frame.copy(deep=True)
sanitize_measured_frame(frame);require_recent_telemetry({'now':AT,'df_recent_raw':frame})
pd.testing.assert_frame_equal(frame,before)
class HistoryOrchestrationTest(unittest.TestCase):
def data(self,telemetry):
class FrozenDateTime(datetime.datetime):
@classmethod
def utcnow(cls):return AT.to_pydatetime()
import types
dates=types.SimpleNamespace(datetime=FrozenDateTime,timedelta=datetime.timedelta)
weather=pd.DataFrame({'temp_c':15.,'cloud':20.},index=pd.date_range(AT-pd.Timedelta(days=14),AT+pd.Timedelta(days=2),freq='5min'))
fetch=Mock(return_value=(telemetry,weather,pd.DataFrame()))
ns=functions('build_data_object','_fill_defaults','_add_time_features','_longest_consistent_segment',datetime=dates,fetch_influx_frames=fetch,FORECAST_HORIZON_HOURS=48)
return ns['build_data_object']({'anlagen_id':'offline'},False)
def test_weather_tail_does_not_erase_historical_load(self):
telemetry=raw_frame(AT-pd.Timedelta(days=2),periods=288)
data=self.data(telemetry)
self.assertEqual(data['df_hist']['Hausverbrauch'].count(),len(telemetry))
self.assertEqual(data['df_recent_raw']['Hausverbrauch'].count(),len(telemetry))
self.assertEqual(data['df_recent_raw'].index.max(),telemetry.index.max())
self.assertTrue(data['df_hist']['Hausverbrauch'].iloc[-24:].isna().all())
with self.assertRaises(TelemetryUnavailable):require_recent_telemetry(data)
def test_no_measurements_preserves_all_missing(self):
data=self.data(pd.DataFrame())
self.assertTrue(data['df_hist']['Hausverbrauch'].isna().all())
with self.assertRaises(TelemetryUnavailable):require_recent_telemetry(data)
def test_reconnected_short_tail_does_not_delete_past_profile(self):
old=raw_frame(AT-pd.Timedelta(days=2),periods=288)
new=raw_frame(AT-pd.Timedelta(minutes=10),periods=2)
data=self.data(pd.concat([old,new]))
self.assertEqual(data['df_hist']['Hausverbrauch'].count(),290)
self.assertEqual(require_recent_telemetry(data)['Hausverbrauch']['ageMinutes'],5.)
def test_stale_forecast_never_predicts_or_publishes(self):
import types
models={n:types.SimpleNamespace(predict=Mock()) for n in (1,2,3,10,11,13,21,22,23)}
client=Mock();write=Mock();client.write_api.return_value=write
publish=Mock();trace=Mock()
ns=functions('run_forecast',get_configs=lambda:[{'anlagen_id':'offline','batt_capacity_kwh':10}],
InfluxDBClient=Mock(return_value=client),SYNCHRONOUS=object(),
INFLUX_URL='offline',INFLUX_TOKEN='synthetic',INFLUX_ORG='offline',INFLUX_BUCKET='offline',INFLUX_TIMEOUT_MS=1,
build_data_object=lambda *a,**k:{'now':AT,'df_recent_raw':pd.DataFrame()},active=lambda *args:True,
_v4_publish_forecasts=publish,traceback=trace,**{'v'+str(k):v for k,v in models.items()})
with contextlib.redirect_stdout(io.StringIO()),self.assertRaises(RuntimeError):ns['run_forecast']()
for model in models.values():model.predict.assert_not_called()
publish.assert_not_called();write.write.assert_not_called();client.close.assert_called_once()
def test_stale_training_does_not_overwrite_model(self):
import types
train=Mock();mod=types.SimpleNamespace(train=train)
ns=functions('run_training',get_configs=lambda:[{'anlagen_id':'offline'}],
MODEL_MODULES={2:mod},QUALITY_TARGETS={2:'Hausverbrauch'},active=lambda *a:True,
build_data_object=lambda *a,**k:{'now':AT,'df_recent_raw':pd.DataFrame()},traceback=Mock())
with contextlib.redirect_stdout(io.StringIO()):ns['run_training']()
train.assert_not_called()
def test_queries_exclude_forecasts_and_align_interval_starts(self):
source=(ROOT/'main.py').read_text();ns=functions('fetch_influx_frames',
HISTORY_START='1970-01-01T00:00:00Z',FORECAST_HORIZON_HOURS=48,INFLUX_BUCKET='offline',
_query_df=Mock(return_value=pd.DataFrame()),_pivot_frame=lambda df,fields:df,
_tariff_frame=lambda df,cfg:df,_time_literal=lambda t:t.isoformat())
ns['fetch_influx_frames']({'anlagen_id':'offline'},False)
query=ns['_query_df'].call_args_list[0].args[0]
self.assertIn('timeSrc: "_start"',query)
self.assertIn('!= "forecast_snapshot"',query)
self.assertIn('!= "forecast"',query)
class RepeatProfileIntegrityTest(unittest.TestCase):
def test_pv_repeats_on_second_day(self):
history=raw_frame();history['PV']=np.maximum(0.,np.sin(np.arange(288)*2*np.pi/288))*12000.
idx=pd.date_range(AT,periods=576,freq='5min')
actual=np.array(list(repeat_daily_profile(history,idx,'PV').values()))
np.testing.assert_allclose(actual[:288],history['PV'])
np.testing.assert_allclose(actual[288:],history['PV'])
def test_missing_yesterday_uses_older_finite_day(self):
t=AT
history=pd.DataFrame({'Hausverbrauch':[3500.,np.nan]},index=[t-pd.Timedelta(days=7),t-pd.Timedelta(days=1)])
self.assertEqual(profile_source_value(history,t,'Hausverbrauch'),3500.)
def test_missing_profile_is_not_zero(self):
history=pd.DataFrame({'Hausverbrauch':[np.nan]},index=[AT-pd.Timedelta(days=1)])
with self.assertRaises(TelemetryUnavailable):repeat_daily_profile(history,[AT],'Hausverbrauch')
def test_recorded_profile_zero_is_valid(self):
history=pd.DataFrame({'PV':[0.]},index=[AT-pd.Timedelta(days=1)])
self.assertEqual(profile_source_value(history,AT,'PV'),0.)
def test_negative_profile_cannot_be_silently_clamped(self):
history=pd.DataFrame({'Hausverbrauch':[-100.]},index=[AT-pd.Timedelta(days=1)])
with self.assertRaises(TelemetryUnavailable):profile_source_value(history,AT,'Hausverbrauch')
def test_future_value_is_never_used_as_history(self):
history=pd.DataFrame({'Hausverbrauch':[3300.]},index=[AT+pd.Timedelta(days=1)])
with self.assertRaises(TelemetryUnavailable):profile_source_value(history,AT,'Hausverbrauch')
if __name__=='__main__':unittest.main()
@@ -0,0 +1,9 @@
{
"libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a",
"libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9",
"libs/ManagerNetzfahrplanV4Trait.php": "13d2867d2b4fe7f8846a08d9b4b81269320db6373d44c5f731a09c21c4912ab7",
"tests/fixtures/NativeV4Scenarios.php": "56a5a0df03cb53ea69f6e199c6d2405041a329c7df540ea8bacc08bfaa8766c6",
"tests/fixtures/V4BezugszaehlerScenarios.php": "7820bab98d1249aac3fee9f015f8da500744c12bfb5b198fcc735cadf3167ec2",
"tests/NetzfahrplanV4BezugszaehlerTest.php": "65b2daa769773198859ab40d2b230b1f3c43f1c618df0c4b90a494d31efb786c",
"tests/NetzfahrplanV4BetriebsdatenTest.php": "4a4f5af4cd86797fc40a4a36a7103a26fcf1e59ab381bafa8f67d35b34419dbc"
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
// OFFLINE ONLY: no Symcon kernel, HTTP requests, timers or connected devices.
if (PHP_SAPI !== 'cli' || function_exists('IPS_GetVariable')) {
fwrite(STDERR, "This is an isolated CLI test, not a Symcon runtime script.\n");
exit(2);
}
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
$manifest = json_decode(file_get_contents(__DIR__ . '/SOURCE_MANIFEST.json'), true, 512, JSON_THROW_ON_ERROR);
foreach ($manifest as $relative => $expected) {
$path = __DIR__ . '/' . $relative;
if (!is_file($path) || hash_file('sha256', $path) !== $expected) {
throw new RuntimeException('Source manifest mismatch: ' . $relative);
}
$command = escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($path);
$lines = [];
exec($command, $lines, $code);
if ($code !== 0) {
throw new RuntimeException('PHP syntax failure: ' . $relative);
}
}
require __DIR__ . '/libs/NetzfahrplanV4Bezugszaehler.php';
require __DIR__ . '/libs/NetzfahrplanV4Betriebsdaten.php';
require __DIR__ . '/libs/ManagerNetzfahrplanV4Trait.php';
require __DIR__ . '/tests/fixtures/NativeV4Scenarios.php';
require __DIR__ . '/tests/fixtures/V4BezugszaehlerScenarios.php';
$native = \Belevo\EnelixEMS\Tests\NativeV4Scenarios::run();
$meters = \Belevo\EnelixEMS\Tests\V4BezugszaehlerScenarios::run();
if (count($native) !== 24 || count($meters) !== 20) {
throw new RuntimeException('Expected 24 native and 20 meter scenarios.');
}
foreach (array_merge($native, $meters) as $name) {
echo 'PASS ', $name, "\n";
}
echo 'PHP ', PHP_VERSION, ': 44 offline conversion scenarios passed.', "\n";
echo "NOT a full EMS suite and NOT an IP-Symcon runtime/physical test.\n";
} catch (Throwable $error) {
fwrite(STDERR, 'FAIL: ' . $error->getMessage() . "\n");
exit(1);
}
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
/** Shadow telemetry only. Does not read/apply a V4 schedule or write actuators. */
trait ManagerNetzfahrplanV4Trait
{
private function registriereNetzfahrplanV4(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4NetzladenErlaubt', false);
$this->RegisterPropertyString('NetzfahrplanV4BatterieOptionen', '{}');
$this->RegisterPropertyInteger('NetzfahrplanV4MessnachweisVariableID', 0);
$this->RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]');
$this->RegisterAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4Senden', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Senden', true);");
}
public function GetNetzfahrplanV4Diagnose(): string
{
try {
$manager = [];
foreach (['NetzleistungVariableID', 'MesswertMaxAlter', 'VerbraucherTimeout',
'Lastspitzenmodus'] as $key) {
$manager[$key] = $this->ReadPropertyInteger($key);
}
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
$manager[$key] = $this->ReadPropertyFloat($key);
}
$manager['V4ControlPlanSource'] = $this->leseJsonAttribut('Netzfahrplan')['sourceModel'] ?? 'legacy-unspecified';
$manager['EinspeisebegrenzungAktiv'] = $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv');
$manager['NetzladenErlaubt'] = $this->ReadPropertyBoolean('NetzfahrplanV4NetzladenErlaubt');
$manager['Monatsgrenzen'] = json_decode($this->ReadPropertyString('Monatsgrenzen'), true, 512, JSON_THROW_ON_ERROR);
$manager['BatterieOptionen'] = json_decode($this->ReadPropertyString('NetzfahrplanV4BatterieOptionen'), true, 512, JSON_THROW_ON_ERROR);
$manager['V4BezugszaehlerQuellen'] = json_decode(
$this->ReadPropertyString('NetzfahrplanV4BezugszaehlerQuellen'), true, 512, JSON_THROW_ON_ERROR
);
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 512, JSON_THROW_ON_ERROR);
$cache = $this->leseJsonAttribut('VerbraucherCache');
$controllers = [];
foreach ($this->aktiveVerbraucherIDs() as $id) {
if (!IPS_InstanceExists($id)
|| IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
continue;
}
$c = ['InstanzID' => $id];
foreach (['LadezustandVariableID', 'MaxLadeleistungVariableID', 'MaxEntladeleistungVariableID',
'IstleistungVariableID', 'MindestLadezustand', 'ReserveLadezustand', 'LadezustandHysterese', 'MesswertMaxAlter', 'Batteriemanagement'] as $key) {
$c[$key] = IPS_GetProperty($id, $key);
}
$entry = $cache[(string) $id] ?? [];
$c['EmpfangenAm'] = $entry['EmpfangenAm'] ?? 0;
$c['Verfuegbar'] = $entry['Daten']['Verfuegbar'] ?? false;
foreach (($entry['Daten']['Zustand'] ?? []) as $state) {
if (($state['Kennung'] ?? '') === 'HystereseAktiv') {
$c['HystereseAktiv'] = $state['Wert'];
}
}
$controllers[] = $c;
}
$evidence = [];
$evidenceID = $this->ReadPropertyInteger('NetzfahrplanV4MessnachweisVariableID');
if ($evidenceID > 0) {
if (!IPS_VariableExists($evidenceID) || IPS_GetVariable($evidenceID)['VariableType'] !== 3) {
throw new RuntimeException('Der konfigurierte Messnachweis ist keine JSON-Stringvariable.');
}
$evidence = json_decode(GetValue($evidenceID), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($evidence)) {
throw new RuntimeException('Ungueltiger Messnachweis.');
}
}
$read = static function (int $id): array {
if (!IPS_VariableExists($id)) {
throw new RuntimeException('Messvariable ' . $id . ' fehlt.');
}
$before = IPS_GetVariable($id);
$value = GetValue($id);
$after = IPS_GetVariable($id);
if ($before['VariableUpdated'] !== $after['VariableUpdated']) {
throw new RuntimeException('Messwert hat sich beim Lesen geaendert; naechsten Durchlauf abwarten.');
}
$object = IPS_GetObject($id);
return ['value' => $value, 'updated' => (int) $after['VariableUpdated'],
'ident' => $object['ObjectIdent'], 'parentID' => (int) $object['ParentID']];
};
return json_encode(NetzfahrplanV4Betriebsdaten::erstellen($manager, $assets, $controllers,
$read, $evidence, time()), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (Throwable $error) {
return json_encode(['status' => 'invalid_inputs', 'reason' => substr($error->getMessage(), 0, 300)], JSON_THROW_ON_ERROR);
}
}
public function GetNetzfahrplanV4Sendestatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4Sendestatus');
}
private function sendeNetzfahrplanV4(): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
return;
}
try {
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
}
$snapshot = $this->GetNetzfahrplanV4Diagnose();
$data = json_decode($snapshot, true, 512, JSON_THROW_ON_ERROR);
if (!isset($data['operation'])) {
throw new RuntimeException($data['reason'] ?? 'Betriebsdaten unvollstaendig.');
}
// Decoding as objects preserves empty JSON dictionaries in the API contract.
$payload = json_decode($snapshot, false, 512, JSON_THROW_ON_ERROR);
if (!isset($payload->operation)) {
throw new RuntimeException('Keine konsistenten Betriebsdaten.');
}
$id = $this->ReadAttributeString('LizenzInstallationID');
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
if (!preg_match('/^[0-9a-f-]{36}$/i', $id) || $token === '') {
throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.');
}
$url = 'https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4/operation';
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('V4-Verbindung konnte nicht vorbereitet werden.');
}
try {
curl_setopt_array($handle, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_POSTFIELDS => json_encode($payload->operation, JSON_THROW_ON_ERROR),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $token]]);
$answer = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
if ($answer === false || $status !== 200) {
throw new RuntimeException('V4-Schattenanbindung HTTP ' . $status . '; bestehende Regelung unveraendert.');
}
if (strlen($answer) > 65536) {
throw new RuntimeException('V4-Antwort ist zu gross.');
}
$ack = json_decode($answer, true, 512, JSON_THROW_ON_ERROR);
if (!in_array($ack['status'] ?? '', ['stored', 'duplicate', 'archived_older'], true)) {
throw new RuntimeException('Unerwartete V4-Annahmebestaetigung.');
}
} finally {
curl_close($handle);
}
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([
'status' => 'sent_shadow', 'capturedAt' => $payload->operation->observedAt,
'acceptance' => $ack['status'], 'warnings' => $data['warnings'], 'liveEnabled' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $error) {
// Only this sender's diagnosis changes; never SetStatus, live plan or actuator commands.
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([
'status' => 'error', 'reason' => substr($error->getMessage(), 0, 300), 'liveEnabled' => false], JSON_THROW_ON_ERROR));
}
}
}
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
require_once __DIR__ . '/NetzfahrplanV4Bezugszaehler.php';
/** Read-only conversion. Meter evidence is never derived from a configured limit. */
final class NetzfahrplanV4Betriebsdaten
{
private static function number($value, string $label, float $min = -1.0e9, float $max = 1.0e9): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value)
|| $value < $min || $value > $max) {
throw new InvalidArgumentException($label . ': ungueltiger Zahlenwert.');
}
return (float) $value;
}
private static function timestamp($value): int
{
if (!is_string($value) || !preg_match('/(?:Z|[+-]\d{2}:\d{2})$/', $value)) {
throw new InvalidArgumentException('Messnachweis benoetigt einen Zeitpunkt mit Zeitzone.');
}
return (new DateTimeImmutable($value))->getTimestamp();
}
/** $read returns ['value' => int|float, 'updated' => int], without changing a device. */
private static function measurement(callable $read, int $id, int $now, int $age, string $label): array
{
if ($id <= 0) {
throw new InvalidArgumentException($label . ': Messquelle fehlt.');
}
$m = $read($id);
if (!isset($m['updated']) || !is_int($m['updated']) || $m['updated'] > $now
|| $now - $m['updated'] > $age) {
throw new InvalidArgumentException($label . ': Messwert fehlt, ist veraltet oder liegt in der Zukunft.');
}
return ['value' => self::number($m['value'] ?? null, $label), 'updated' => $m['updated']];
}
/** No inference of paid peak or elapsed energy from power, reserve or forecast. */
private static function evidence(array $input, int $now, string $meterID, array &$warnings): array
{
$peaks = [];
$past = null;
if ($input === []) {
$warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.';
return [(object) [], null];
}
if ($meterID === '' || ($input['version'] ?? null) !== 1 || ($input['meterId'] ?? null) !== $meterID) {
throw new InvalidArgumentException('Messnachweis: falsche Version oder Bezugszaehler-Zuordnung.');
}
$measured = self::timestamp($input['measuredAt'] ?? null);
if ($measured > $now || $now - $measured > 120) {
throw new InvalidArgumentException('Messnachweis ist nicht aktuell.');
}
$month = (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('Y-m');
foreach (($input['measuredPeaks'] ?? []) as $key => $p) {
if (!is_string($key) || !preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $key) || $key > $month
|| !in_array($p['source'] ?? '', ['meter_month_register', 'verified_month_history', 'verified_new_month'], true)) {
throw new InvalidArgumentException('Messnachweis: Monatspeak ist kein gueltiger Messnachweis.');
}
$peaks[$key] = ['kw' => self::number($p['kw'] ?? null, 'Monatspeak', 0.0), 'source' => $p['source']];
}
if (!array_key_exists($month, $peaks)) {
$warnings[] = 'Vollstaendiger Monatspeak fuer ' . $month . ' fehlt; Managergrenze ist kein Ersatz.';
}
$q = intdiv($now, 900) * 900;
if (isset($input['quarterPast'])) {
$p = $input['quarterPast'];
// Stale quarter evidence remains absent, not extrapolated to the decision time.
if ($measured === $now && self::timestamp($p['start'] ?? null) === $q
&& ($p['measuredSeconds'] ?? null) === $now - $q) {
$past = ['start' => gmdate('c', $q),
'measuredSeconds' => $now - $q,
'importKwh' => self::number($p['importKwh'] ?? null, 'Viertelstundenenergie', 0.0)];
} else {
$warnings[] = 'Viertelstundenenergie passt nicht exakt zum Entscheidungszeitpunkt; nicht verwendet.';
}
}
if ($now !== $q && $past === null) {
$warnings[] = 'Bisherige Energie der laufenden Viertelstunde fehlt; keine Hochrechnung als Messung.';
}
return [(object) $peaks, $past];
}
/** Inputs are explicit non-secret configuration/cache fields, not full instance settings. */
public static function erstellen(array $manager, array $assets, array $controllers, callable $read, array $evidence, int $now): array
{
$warnings = [];
$meter = NetzfahrplanV4Bezugszaehler::lesen(
$manager['V4BezugszaehlerQuellen'] ?? [], $read, $now
);
$grid = self::measurement($read, (int) ($manager['NetzleistungVariableID'] ?? 0), $now,
(int) ($manager['MesswertMaxAlter'] ?? 60), 'Netzleistung');
$gridW = $grid['value'] * self::number($manager['Netzleistungsfaktor'] ?? 1.0, 'Netzleistungsfaktor');
$mode = $manager['Lastspitzenmodus'] ?? 0;
$import = null;
$months = [];
if ($mode === 1) {
$import = self::number($manager['Lastspitzengrenze'] ?? null, 'Managergrenze', 0.0);
} elseif ($mode === 2) {
foreach (($manager['Monatsgrenzen'] ?? []) as $row) {
$m = $row['MonatIndex'] ?? null;
if (!is_int($m) || $m < 1 || $m > 12 || isset($months[(string) $m])) {
throw new InvalidArgumentException('Monatsgrenzen fehlen oder sind doppelt.');
}
$months[(string) $m] = self::number($row['Grenze_W'] ?? null, 'Monatsgrenze', 0.0);
}
if (count($months) !== 12) {
throw new InvalidArgumentException('Alle zwoelf Monatsgrenzen werden benoetigt.');
}
} elseif ($mode !== 0) {
throw new InvalidArgumentException('Unbekannter Lastspitzenmodus.');
}
$export = !empty($manager['EinspeisebegrenzungAktiv'])
? self::number($manager['Einspeisegrenze'] ?? null, 'Einspeisegrenze', 0.0) : null;
$result = [];
$seen = [];
$used = [];
foreach ($assets as $asset) {
$id = $asset['ID'] ?? '';
if (!is_string($id) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', $id) || isset($seen[$id])) {
throw new InvalidArgumentException('Batterie-ID fehlt oder ist doppelt.');
}
$seen[$id] = true;
$socID = (int) ($asset['SOCVariableID'] ?? 0);
$matches = array_values(array_filter($controllers, static function (array $c) use ($socID): bool {
return $socID > 0 && ($c['LadezustandVariableID'] ?? 0) === $socID;
}));
if (count($matches) !== 1) {
throw new InvalidArgumentException($id . ': keine eindeutige aktive Batterieinstanz zur SOC-Quelle.');
}
$c = $matches[0];
$controllerID = $c['InstanzID'];
if (isset($used[$controllerID])) {
throw new InvalidArgumentException('Eine Batterieinstanz darf nicht doppelt bilanziert werden.');
}
$used[$controllerID] = true;
if (($asset['LeistungVariableID'] ?? 0) !== ($c['IstleistungVariableID'] ?? 0)) {
throw new InvalidArgumentException($id . ': Topologie und Batterieinstanz verwenden verschiedene Leistungsmessungen.');
}
$opts = $manager['BatterieOptionen'][$id] ?? [];
$nominal = self::number($asset['Nennkapazitaet_kWh'] ?? null, 'Nennkapazitaet', 0.001);
$usable = self::number($asset['Nutzkapazitaet_kWh'] ?? null, 'Nutzkapazitaet', 0.001, $nominal);
if (abs($nominal - $usable) > 1.0e-6 && !array_key_exists('SOCKapazitaet_kWh', $opts)) {
throw new InvalidArgumentException($id . ': SOC-Kapazitaetsbasis bei verschiedener Nenn-/Nutzkapazitaet bestaetigen.');
}
$capacity = self::number($opts['SOCKapazitaet_kWh'] ?? $nominal, 'SOC-Kapazitaet', 0.001, $nominal);
$age = (int) ($c['MesswertMaxAlter'] ?? 30);
$soc = self::measurement($read, $socID, $now, $age, $id . ' SOC');
$charge = self::measurement($read, (int) $c['MaxLadeleistungVariableID'], $now, $age, $id . ' max. Laden');
$discharge = self::measurement($read, (int) $c['MaxEntladeleistungVariableID'], $now, $age, $id . ' max. Entladen');
$min = max(self::number($c['MindestLadezustand'], 'BMS-Minimum', 0, 100),
self::number($c['ReserveLadezustand'], 'Betriebsreserve', 0, 100));
$max = self::number($opts['MaxSOC_Prozent'] ?? 100.0, 'Maximal-SOC', $min, 100);
$socValue = self::number($soc['value'], 'SOC', 0, 100);
$physicalMin = self::number($c['MindestLadezustand'], 'Technisches Minimum', 0, $min);
if ($socValue < $physicalMin || $socValue > $max) {
throw new InvalidArgumentException($id . ': SOC ausserhalb des Planungsbereichs; kein kuenstliches Anheben.');
}
if (($c['EmpfangenAm'] ?? 0) > $now || $now - ($c['EmpfangenAm'] ?? 0) > (int) ($manager['VerbraucherTimeout'] ?? 60)) {
throw new InvalidArgumentException($id . ': Verbraucher-Rueckmeldung veraltet.');
}
$maxCharge = min(self::number($charge['value'], 'Ladeleistung', 0), 1000 * self::number($asset['MaxLadeleistung_kW'], 'Nennladeleistung', 0));
$maxDischarge = min(self::number($discharge['value'], 'Entladeleistung', 0), 1000 * self::number($asset['MaxEntladeleistung_kW'], 'Nennentladeleistung', 0));
if (($c['Verfuegbar'] ?? false) !== true || ($c['Batteriemanagement'] ?? 0) !== 2) {
$maxCharge = $maxDischarge = 0.0;
$warnings[] = $id . ': nicht fuer Managerregelung verfuegbar; keine Batterieleistung eingeplant.';
}
$rearm = min($max, $min + self::number($c['LadezustandHysterese'] ?? 0.0, 'Entladehysterese', 0, 100));
$blocked = !empty($c['HystereseAktiv']) || $socValue <= $min;
if ($blocked) {
$warnings[] = $id . ': Entladesperre im Modell aktiv bis zur Wiederfreigabe nach vorheriger Ladung.';
}
if ($socValue < $min) {
$warnings[] = $id . ': unter Betriebsreserve; realen SOC behalten und nur zulassige Wiederaufladung planen.';
}
$result[] = ['id' => $id, 'capacityKwh' => $capacity, 'socPercent' => $socValue,
'minSocPercent' => $min, 'maxSocPercent' => $max, 'maxChargeW' => $maxCharge,
'maxDischargeW' => $maxDischarge, 'measuredAt' => gmdate('c', min($soc['updated'], $charge['updated'], $discharge['updated'])),
'gridCharging' => ($manager['NetzladenErlaubt'] ?? false) === true,
'physicalMinSocPercent' => $physicalMin, 'recoveryAllowed' => true,
'dischargeBlocked' => $blocked, 'rearmSocPercent' => $rearm];
}
if ($result === []) {
throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.');
}
[$peaks, $past] = self::evidence($evidence, $now, $meter['meterId'], $warnings);
$payload = ['version' => 1, 'observedAt' => gmdate('c', $now), 'gridW' => $gridW,
'meteringBoundary' => 'common_pcc', 'batteries' => $result,
'limits' => ['importW' => $import, 'exportW' => $export, 'managerMonthLimitsW' => (object) $months],
'measuredPeaks' => $peaks, 'quarterPast' => $past];
// A fresh acquisition sample is an operational estimate. It does NOT
// claim a calibrated billing-period boundary or full historical coverage.
$policy = [
'adapterVersion' => 'v4-native-estimated-meter-1',
'sourcePlan' => $manager['V4ControlPlanSource'] ?? 'legacy-unspecified',
'mode' => $mode, 'importW' => $import, 'exportW' => $export,
'months' => $months,
'batteryPolicy' => array_map(static function (array $b): array {
return array_intersect_key($b, array_flip(['id', 'capacityKwh', 'minSocPercent', 'maxSocPercent', 'physicalMinSocPercent', 'rearmSocPercent']));
}, $result),
];
$payload['meterObservation'] = [
'meterId' => $meter['meterId'], 'sampleAt' => gmdate('c', $now),
'powerW' => $gridW, 'totalImportKwh' => $meter['totalKwh'],
'controlPolicyId' => hash('sha256', json_encode($policy, JSON_THROW_ON_ERROR)),
];
$payload['eventId'] = 'symcon-operation-' . hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
return ['status' => $warnings === [] ? 'ready_shadow' : 'incomplete_shadow', 'warnings' => $warnings, 'bezugszaehler' => $meter, 'operation' => $payload];
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/** Read-only active-import counter sum, independent of the legacy energy counter.
* Observation timestamps are NOT proof of a billing-quarter boundary or history.
*/
final class NetzfahrplanV4Bezugszaehler
{
public static function quellen(array $sources): array
{
if ($sources === [] || count($sources) > 8
|| array_keys($sources) !== range(0, count($sources) - 1)) {
throw new InvalidArgumentException('V4 benoetigt 1 bis 8 explizite Wirkenergie-Bezugsquellen.');
}
$result = [];
$parent = null;
foreach ($sources as $source) {
if (!is_array($source) || count($source) !== 5
|| ($source['Messgroesse'] ?? null) !== 'WirkenergieBezug') {
throw new InvalidArgumentException('V4 Bezugszaehler: Messgroesse oder Quellenschema ungueltig.');
}
$id = $source['VariableID'] ?? null;
$pid = $source['ElternID'] ?? null;
$ident = $source['Ident'] ?? null;
$factor = $source['FaktorZuKWh'] ?? null;
if (!is_int($id) || $id <= 0 || !is_int($pid) || $pid <= 0
|| !is_string($ident) || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/D', $ident)
|| (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor)
|| $factor <= 0 || $factor > 1.0e6 || isset($result[$id])) {
throw new InvalidArgumentException('V4 Bezugszaehler: ID, Ident, Einheit oder doppelte Quelle ungueltig.');
}
if ($parent !== null && $parent !== $pid) {
throw new InvalidArgumentException('V4 T1/T2 muessen zum selben physischen Bezugszaehler gehoeren.');
}
$parent = $pid;
foreach ($result as $existing) {
if ($existing['Ident'] === $ident) {
throw new InvalidArgumentException('V4 Bezugszaehler: Register doppelt angegeben.');
}
}
$result[$id] = ['VariableID' => $id, 'ElternID' => $pid, 'Ident' => $ident,
'FaktorZuKWh' => (float) $factor, 'Messgroesse' => 'WirkenergieBezug'];
}
ksort($result, SORT_NUMERIC);
return array_values($result);
}
public static function identitaet(array $sources): string
{
// Source/factor changes invalidate old measurement evidence; names do not matter.
return 'symcon-active-import:' . hash('sha256', json_encode(self::quellen($sources),
JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
}
private static function probe(array $source, callable $read, int $now, int $maxAge): array
{
$m = $read($source['VariableID']);
if (!is_array($m) || ($m['parentID'] ?? null) !== $source['ElternID']
|| ($m['ident'] ?? null) !== $source['Ident']) {
throw new InvalidArgumentException('V4 Bezugszaehler: Variable passt nicht zum konfigurierten Register.');
}
$value = $m['value'] ?? null;
$updated = $m['updated'] ?? null;
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < 0
|| !is_int($updated) || $updated <= 0 || $updated > $now || $now - $updated > $maxAge) {
throw new InvalidArgumentException('V4 Bezugszaehler: Teilwert fehlt, ist veraltet oder ungueltig.');
}
$kwh = (float) $value * $source['FaktorZuKWh'];
if (!is_finite($kwh) || $kwh > 1.0e12) {
throw new InvalidArgumentException('V4 Bezugszaehler: Energie ausserhalb des Messbereichs.');
}
return ['variableId' => $source['VariableID'], 'rawValue' => (float) $value,
'totalKwh' => $kwh, 'updatedAt' => $updated];
}
/** $read must return only value, updated, ident, parentID. Never uses an archive fallback. */
public static function lesen(array $sources, callable $read, int $now, int $maxAge = 60, int $maxSkew = 2): array
{
if ($now <= 0 || $maxAge < 1 || $maxAge > 300 || $maxSkew < 0 || $maxSkew > 5) {
throw new InvalidArgumentException('V4 Bezugszaehler: Zeitfenster ungueltig.');
}
$sources = self::quellen($sources);
$samples = [];
foreach ($sources as $source) {
$samples[] = self::probe($source, $read, $now, $maxAge);
}
// Reread all channels to reject concurrent updates, including changes in the same second.
foreach ($sources as $i => $source) {
if ($samples[$i] !== self::probe($source, $read, $now, $maxAge)) {
throw new InvalidArgumentException('V4 Bezugszaehler wurde waehrend des Lesens aktualisiert.');
}
}
$times = array_column($samples, 'updatedAt');
if (max($times) - min($times) > $maxSkew) {
throw new InvalidArgumentException('V4 Bezugszaehler: T1/T2-Zeitpunkte liegen zu weit auseinander.');
}
return ['meterId' => self::identitaet($sources), 'quantity' => 'active_import', 'unit' => 'kWh',
'totalKwh' => array_sum(array_column($samples, 'totalKwh')),
'observedAt' => gmdate('c', $now), 'sourceObservationFrom' => gmdate('c', min($times)),
'sourceObservationUntil' => gmdate('c', max($times)), 'components' => $samples,
'billingEvidence' => false, 'historyComplete' => false];
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php';
require_once __DIR__ . '/fixtures/NativeV4Scenarios.php';
final class NetzfahrplanV4BetriebsdatenTest extends TestCase
{
public function testOfflineInputScenarios(): void
{
self::assertCount(24, NativeV4Scenarios::run());
}
public function testSenderCannotChangeLivePlanOrActuators(): void
{
$source = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
foreach (['sendeManagerdaten(', 'aktualisiereNetzfahrplan(', '->regeln(', '->SetStatus(', "WriteAttributeString('Netzfahrplan',"] as $forbidden) {
self::assertStringNotContainsString($forbidden, $source);
}
self::assertStringContainsString("RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false)", $source);
self::assertStringContainsString('/prognosis/planner-v4/operation', $source);
self::assertStringNotContainsString('/prognosis/schedule', $source);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../libs/NetzfahrplanV4Bezugszaehler.php';
require_once __DIR__ . '/fixtures/V4BezugszaehlerScenarios.php';
final class NetzfahrplanV4BezugszaehlerTest extends TestCase
{
public function testReadOnlyCounterSources(): void
{
self::assertCount(20, V4BezugszaehlerScenarios::run());
}
public function testNoLegacyMeterOrActuatorFallback(): void
{
$source = file_get_contents(__DIR__ . '/../libs/NetzfahrplanV4Bezugszaehler.php');
foreach (['SetValue(', 'IPS_SetProperty(', 'RequestAction(', 'AC_Set', '53476'] as $forbidden) {
self::assertStringNotContainsString($forbidden, $source);
}
$trait = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
self::assertStringNotContainsString("'NetzbezugEnergieVariableID'", $trait);
self::assertStringContainsString("RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]')", $trait);
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\NetzfahrplanV4Betriebsdaten;
use RuntimeException;
use InvalidArgumentException;
/** Runs offline. No IPS API, network, physical devices or production settings. */
final class NativeV4Scenarios
{
public static function run(): array
{
$now = strtotime('2026-10-01T12:05:00Z');
$m = ['NetzleistungVariableID' => 1, 'NetzbezugEnergieVariableID' => 9,
'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60, 'Lastspitzenmodus' => 1,
'Lastspitzengrenze' => 25000.0, 'EinspeisebegrenzungAktiv' => true, 'Einspeisegrenze' => 25000.0];
$m['V4BezugszaehlerQuellen'] = [
['VariableID' => 6, 'ElternID' => 99, 'Ident' => 'Energy_0', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
['VariableID' => 7, 'ElternID' => 99, 'Ident' => 'Energy_1', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
];
$a = [['ID' => 'ev', 'Nennkapazitaet_kWh' => 161.44, 'Nutzkapazitaet_kWh' => 161.44,
'MaxLadeleistung_kW' => 39.0, 'MaxEntladeleistung_kW' => 30.0, 'SOCVariableID' => 2, 'LeistungVariableID' => 5]];
$c = [['InstanzID' => 42, 'LadezustandVariableID' => 2, 'MaxLadeleistungVariableID' => 3,
'MaxEntladeleistungVariableID' => 4, 'IstleistungVariableID' => 5,
'ReserveLadezustand' => 15.0, 'MindestLadezustand' => 3.0, 'MesswertMaxAlter' => 30,
'Verfuegbar' => true, 'Batteriemanagement' => 2, 'EmpfangenAm' => $now]];
$read = static function (int $id) use ($now): array {
return ['value' => [1 => -5332.0, 2 => 19.0, 3 => 39000.0, 4 => 35000.0, 6 => 3992.203, 7 => 0.0][$id],
'updated' => $now, 'parentID' => 99, 'ident' => [6 => 'Energy_0', 7 => 'Energy_1'][$id] ?? 'other'];
};
$check = static function (bool $ok): void { if (!$ok) { throw new RuntimeException('Assertion failed'); } };
$fail = static function (callable $fn): void {
try { $fn(); } catch (InvalidArgumentException $e) { return; }
throw new RuntimeException('Expected invalid-input rejection');
};
$cases = [];
$build = static function ($mm = null, $aa = null, $cc = null, $r = null, $e = []) use ($m, $a, $c, $read, $now): array {
return NetzfahrplanV4Betriebsdaten::erstellen($mm ?? $m, $aa ?? $a, $cc ?? $c, $r ?? $read, $e, $now);
};
$cases['native_mapping_and_asymmetric_limits'] = static function () use ($build, $check): void {
$x = $build()['operation']; $b = $x['batteries'][0];
$check($x['gridW'] === -5332.0 && $b['capacityKwh'] === 161.44 && $b['minSocPercent'] === 15.0
&& $b['maxChargeW'] === 39000.0 && $b['maxDischargeW'] === 30000.0);
};
$cases['manager_cap_not_paid_peak'] = static function () use ($build, $check): void {
$x = $build()['operation']; $check($x['limits']['importW'] === 25000.0 && (array) $x['measuredPeaks'] === [] && $x['quarterPast'] === null);
};
$cases['empty_maps_are_objects'] = static function () use ($build, $check): void {
$x = json_decode(json_encode($build()['operation'])); $check(is_object($x->measuredPeaks) && is_object($x->limits->managerMonthLimitsW));
};
$cases['zero_limits_not_unlimited'] = static function () use ($build, $m, $check): void {
$m['Einspeisegrenze'] = 0.0; $m['Lastspitzengrenze'] = 0.0; $x = $build($m)['operation'];
$check($x['limits']['importW'] === 0.0 && $x['limits']['exportW'] === 0.0);
};
$cases['disabled_limits_are_null'] = static function () use ($build, $m, $check): void {
$m['EinspeisebegrenzungAktiv'] = false; $m['Lastspitzenmodus'] = 0; $x = $build($m)['operation'];
$check($x['limits']['importW'] === null && $x['limits']['exportW'] === null);
};
$cases['all_monthly_limits'] = static function () use ($build, $m, $check, $fail): void {
$m['Lastspitzenmodus'] = 2; $m['Monatsgrenzen'] = [];
for ($i = 1; $i <= 12; $i++) { $m['Monatsgrenzen'][] = ['MonatIndex' => $i, 'Grenze_W' => $i * 1000]; }
$x = $build($m)['operation']; $check($x['limits']['managerMonthLimitsW']->{'10'} === 10000.0);
array_pop($m['Monatsgrenzen']); $fail(static fn() => $build($m));
};
$cases['no_unconfirmed_soc_capacity'] = static function () use ($build, $a, $m, $fail, $check): void {
$a[0]['Nutzkapazitaet_kWh'] = 140.; $fail(static fn() => $build(null, $a));
$m['BatterieOptionen'] = ['ev' => ['SOCKapazitaet_kWh' => 161.44]];
$check($build($m, $a)['operation']['batteries'][0]['capacityKwh'] === 161.44);
};
$cases['no_duplicate_controller'] = static function () use ($build, $a, $fail): void {
$a[] = array_replace($a[0], ['ID' => 'other']); $fail(static fn() => $build(null, $a));
};
$cases['no_ambiguous_mapping'] = static function () use ($build, $c, $fail): void {
$c[] = array_replace($c[0], ['InstanzID' => 43]); $fail(static fn() => $build(null, null, $c));
};
$cases['mismatched_power_source'] = static function () use ($build, $c, $fail): void {
$c[0]['IstleistungVariableID'] = 100; $fail(static fn() => $build(null, null, $c));
};
$cases['stale_measurements_fail'] = static function () use ($build, $read, $now, $fail): void {
$r = static function ($id) use ($read, $now) { $v = $read($id); $v['updated'] = $now - 61; return $v; };
$fail(static fn() => $build(null, null, null, $r));
};
$cases['stale_consumer_cache_fails'] = static function () use ($build, $c, $now, $fail): void {
$c[0]['EmpfangenAm'] = $now - 61; $fail(static fn() => $build(null, null, $c));
};
$cases['soc_below_reserve_is_not_fabricated'] = static function () use ($build, $read, $check): void {
$r = static function ($id) use ($read) { $v = $read($id); if ($id === 2) { $v['value'] = 5.; } return $v; };
$b = $build(null, null, null, $r)['operation']['batteries'][0];
$check($b['socPercent'] === 5.0 && $b['minSocPercent'] === 15.0 && $b['physicalMinSocPercent'] === 3.0 && $b['recoveryAllowed'] === true && $b['dischargeBlocked'] === true);
};
$cases['unavailable_asset_no_power'] = static function () use ($build, $c, $check): void {
$c[0]['Verfuegbar'] = false; $x = $build(null, null, $c)['operation']['batteries'][0];
$check($x['maxChargeW'] === 0.0 && $x['maxDischargeW'] === 0.0);
};
$cases['hysteresis_not_silently_ignored'] = static function () use ($build, $c, $check): void {
$c[0]['HystereseAktiv'] = true; $x = $build(null, null, $c);
$check($x['operation']['batteries'][0]['dischargeBlocked'] === true && $x['operation']['batteries'][0]['maxDischargeW'] === 30000.0 && count($x['warnings']) > 1);
};
$cases['grid_charge_explicit_opt_in'] = static function () use ($build, $m, $check): void {
$check($build()['operation']['batteries'][0]['gridCharging'] === false);
$m['NetzladenErlaubt'] = true; $check($build($m)['operation']['batteries'][0]['gridCharging'] === true);
};
$ev = ['version' => 1, 'meterId' => \Belevo\EnelixEMS\NetzfahrplanV4Bezugszaehler::identitaet($m['V4BezugszaehlerQuellen']), 'measuredAt' => gmdate('c', $now),
'measuredPeaks' => ['2026-10' => ['kw' => 18.4, 'source' => 'meter_month_register']],
'quarterPast' => ['start' => '2026-10-01T12:00:00Z', 'measuredSeconds' => 300, 'importKwh' => 0.5]];
$cases['actual_meter_evidence_used'] = static function () use ($build, $ev, $check): void {
$x = $build(null, null, null, null, $ev);
$check($x['status'] === 'ready_shadow' && $x['operation']['measuredPeaks']->{'2026-10'}['kw'] === 18.4 && $x['operation']['quarterPast']['importKwh'] === 0.5);
};
$cases['stale_quarter_not_extrapolated'] = static function () use ($build, $ev, $now, $check): void {
$ev['measuredAt'] = gmdate('c', $now - 1); $x = $build(null, null, null, null, $ev);
$check($x['operation']['quarterPast'] === null && count($x['warnings']) > 0);
};
$cases['wrong_meter_evidence_fails'] = static function () use ($build, $ev, $fail): void {
$ev['meterId'] = 'symcon:100'; $fail(static fn() => $build(null, null, null, null, $ev));
};
$cases['planned_peak_not_evidence'] = static function () use ($build, $ev, $fail): void {
$ev['measuredPeaks']['2026-10']['source'] = 'manager_cap'; $fail(static fn() => $build(null, null, null, null, $ev));
};
$cases['deterministic_retry_event_id'] = static function () use ($build, $check): void {
$check($build()['operation']['eventId'] === $build()['operation']['eventId']);
};
$cases['legacy_meter_identity_not_accepted'] = static function () use ($build, $ev, $fail): void {
$ev['meterId'] = 'symcon:9'; $fail(static fn() => $build(null, null, null, null, $ev));
};
$cases['missing_v4_sources_never_use_legacy'] = static function () use ($build, $m, $fail): void {
unset($m['V4BezugszaehlerQuellen']); $fail(static fn() => $build($m));
};
$cases['counter_snapshot_is_not_billing_evidence'] = static function () use ($build, $check): void {
$v = $build(); $check($v['bezugszaehler']['totalKwh'] === 3992.203
&& !$v['bezugszaehler']['billingEvidence'] && (array) $v['operation']['measuredPeaks'] === []);
};
$passed = [];
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
return $passed;
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\NetzfahrplanV4Bezugszaehler as Meter;
use InvalidArgumentException;
use RuntimeException;
/** Offline only: injected readings, no Symcon, archive, network or actuator access. */
final class V4BezugszaehlerScenarios
{
public static function run(): array
{
$now = 1790874000;
$sources = [
['VariableID' => 59607, 'ElternID' => 11490, 'Ident' => 'Energy_0', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
['VariableID' => 26620, 'ElternID' => 11490, 'Ident' => 'Energy_1', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
];
$samples = [
59607 => ['value' => 3992.203, 'updated' => $now, 'parentID' => 11490, 'ident' => 'Energy_0'],
26620 => ['value' => 0.0, 'updated' => $now, 'parentID' => 11490, 'ident' => 'Energy_1'],
];
$check = static function (bool $value): void { if (!$value) { throw new RuntimeException('Assertion failed'); } };
$reject = static function (callable $fn): void {
try { $fn(); } catch (InvalidArgumentException $e) { return; }
throw new RuntimeException('Expected invalid source/sample rejection');
};
$snapshot = static function ($ss = null, $mm = null) use ($sources, $samples, $now): array {
$mm = $mm ?? $samples;
return Meter::lesen($ss ?? $sources, static fn(int $id) => $mm[$id] ?? null, $now);
};
$cases = [];
$cases['zero_t2_is_valid_but_not_history'] = static function () use ($snapshot, $check): void {
$v = $snapshot();
$check(abs($v['totalKwh'] - 3992.203) < 1e-9 && !$v['historyComplete'] && !$v['billingEvidence']);
$check(!isset($v['measuredPeaks']) && !isset($v['quarterPast']));
};
$cases['both_tariffs_are_summed'] = static function () use ($snapshot, $samples, $check): void {
$samples[26620]['value'] = 27.25;
$check(abs($snapshot(null, $samples)['totalKwh'] - 4019.453) < 1e-9);
};
$cases['zero_total_not_replaced'] = static function () use ($snapshot, $samples, $check): void {
$samples[59607]['value'] = 0;
$check($snapshot(null, $samples)['totalKwh'] === 0.0);
};
$cases['source_order_does_not_change_identity'] = static function () use ($sources, $check): void {
$check(Meter::identitaet($sources) === Meter::identitaet(array_reverse($sources)));
};
$cases['changed_factor_invalidates_identity'] = static function () use ($sources, $check): void {
$old = Meter::identitaet($sources); $sources[0]['FaktorZuKWh'] = 0.001;
$check($old !== Meter::identitaet($sources));
};
$cases['changed_variable_invalidates_identity'] = static function () use ($sources, $check): void {
$old = Meter::identitaet($sources); $sources[0]['VariableID'] = 12345;
$check($old !== Meter::identitaet($sources));
};
$cases['no_fallback_to_legacy_source'] = static function () use ($snapshot, $reject): void { $reject(static fn() => $snapshot([])); };
$cases['duplicate_variable_rejected'] = static function () use ($sources, $snapshot, $reject): void {
$sources[1] = $sources[0]; $reject(static fn() => $snapshot($sources));
};
$cases['different_meter_boundary_rejected'] = static function () use ($sources, $snapshot, $reject): void {
$sources[1]['ElternID'] = 48065; $reject(static fn() => $snapshot($sources));
};
$cases['reactive_quantity_rejected'] = static function () use ($sources, $snapshot, $reject): void {
$sources[0]['Messgroesse'] = 'Blindenergie'; $reject(static fn() => $snapshot($sources));
};
$cases['explicit_conversion_required'] = static function () use ($sources, $snapshot, $reject): void {
unset($sources[0]['FaktorZuKWh']); $reject(static fn() => $snapshot($sources));
};
$cases['missing_tariff_not_zero'] = static function () use ($snapshot, $samples, $reject): void {
unset($samples[26620]); $reject(static fn() => $snapshot(null, $samples));
};
$cases['stale_zero_tariff_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[26620]['updated'] -= 61; $reject(static fn() => $snapshot(null, $samples));
};
$cases['future_reading_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[26620]['updated']++; $reject(static fn() => $snapshot(null, $samples));
};
$cases['wrong_ident_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[59607]['ident'] = 'Energy_6'; $reject(static fn() => $snapshot(null, $samples));
};
$cases['wrong_parent_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[59607]['parentID'] = 48065; $reject(static fn() => $snapshot(null, $samples));
};
$cases['invalid_numbers_rejected'] = static function () use ($snapshot, $samples, $reject): void {
foreach ([false, '3992.203', NAN, INF, -1.0] as $bad) {
$samples[59607]['value'] = $bad; $reject(static fn() => $snapshot(null, $samples));
}
};
$cases['skewed_tariff_observations_rejected'] = static function () use ($snapshot, $samples, $reject): void {
$samples[59607]['updated'] -= 3; $reject(static fn() => $snapshot(null, $samples));
};
$cases['concurrent_update_in_same_second_rejected'] = static function () use ($sources, $samples, $now, $reject): void {
$calls = 0;
$read = static function (int $id) use ($samples, &$calls): array {
$v = $samples[$id]; if (++$calls > 2) { $v['value'] += 0.1; } return $v;
};
$reject(static fn() => Meter::lesen($sources, $read, $now));
};
$cases['observation_not_fake_exact_billing_time'] = static function () use ($snapshot, $samples, $now, $check): void {
$samples[59607]['updated'] -= 1;
$v = $snapshot(null, $samples);
$check($v['sourceObservationFrom'] === gmdate('c', $now - 1));
$check($v['sourceObservationUntil'] === gmdate('c', $now) && $v['observedAt'] === gmdate('c', $now));
$check(!$v['billingEvidence']);
};
$passed = [];
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
return $passed;
}
}
@@ -0,0 +1,100 @@
"""Read-only probe executed INSIDE the existing forecast-engine container.
No get_configs() (it may migrate SQL), no training/prediction, no forecast writing,
no manual /run_now request, and no user credentials in output. Reads one plant's
stored raw 5-minute forecast values and the input frames used by the engine.
"""
import contextlib
from datetime import datetime, timezone
import hashlib
import io
import json
import math
import os
from pathlib import Path
import sqlite3
from urllib.parse import quote
from uuid import UUID
class Discard(io.TextIOBase):
def write(self,text):return len(text)
def clean_time(value):
if hasattr(value,'to_pydatetime'):value=value.to_pydatetime()
if value.tzinfo is None:value=value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat()
def run():
aid=str(UUID(os.environ['ENELIX_ACCEPTANCE_PLANT']))
os.environ['FORECAST_INFLUX_TIMEOUT_MS']='20000'
# These assignments affect only this diagnostic process, not the service.
with contextlib.redirect_stdout(Discard()),contextlib.redirect_stderr(Discard()):
import main
import numpy as np
import pandas as pd
path=Path(main.SQLITE_DB_PATH)
if not path.is_file():raise RuntimeError('Existing configuration database missing')
con=sqlite3.connect('file:'+quote(str(path))+'?mode=ro',uri=True)
con.row_factory=sqlite3.Row
try:row=con.execute('SELECT * FROM anlagen_meta WHERE anlagen_id=?',(aid,)).fetchone()
finally:con.close()
if row is None:raise RuntimeError('Installation not found')
cfg=dict(row)
cfg['daecher']=json.loads(cfg.get('daecher') or '[]')
for key,default in [('ac_leistung',10.),('batt_capacity_kwh',0.),('batt_power_kw',0.),('tarif_bezug_fest',.3),('tarif_einspeisung_fest',.1),('tarif_peak_fest',5.)]:
value=cfg.get(key);cfg[key]=float(default if value is None or value=='' else value)
data=main.build_data_object(cfg,training=False)
frames={}
for key in ('df_hist','df_recent_raw','df_load_training','df_fut'):
frame=data.get(key)
if frame is None:frames[key]={'available':False};continue
item={'rows':len(frame),'from':clean_time(frame.index.min()) if len(frame) else None,'until':clean_time(frame.index.max()) if len(frame) else None,'columns':{}}
for col in ('Hausverbrauch','PV','Netzleistung','SOC'):
if col not in frame.columns:continue
values=pd.to_numeric(frame[col],errors='coerce');valid=values[np.isfinite(values)]
item['columns'][col]={'finite':len(valid),'zeros':int((valid==0).sum()),'median':float(valid.median()) if len(valid) else None,'maximum':float(valid.max()) if len(valid) else None,'lastFiniteAt':clean_time(valid.index[-1]) if len(valid) else None,'recentValues':[{'time':clean_time(t),'value':float(v)} for t,v in valid.tail(12).items()]}
frames[key]=item
start=datetime.now(timezone.utc).replace(second=0,microsecond=0)
from datetime import timedelta
end=start+timedelta(hours=48)
fields=('prog_var_1','prog_var_2','prog_var_10','prog_var_11','prog_var_21','prog_var_22')
field_filter=' or '.join('r["_field"] == '+json.dumps(f) for f in fields)
query='''from(bucket: %s)
|> range(start: %s, stop: %s)
|> filter(fn: (r) => r["_measurement"] == "api_telemetry")
|> filter(fn: (r) => r["anlagen_id"] == %s)
|> filter(fn: (r) => r["data_type"] == "forecast")
|> filter(fn: (r) => %s)
|> keep(columns: ["_time", "_field", "_value"])
''' % (json.dumps(main.INFLUX_BUCKET),start.isoformat(),end.isoformat(),json.dumps(aid),field_filter)
client=main.InfluxDBClient(url=main.INFLUX_URL,token=main.INFLUX_TOKEN,org=main.INFLUX_ORG,timeout=20000)
series={field:[] for field in fields}
try:
tables=client.query_api().query(org=main.INFLUX_ORG,query=query)
for table in tables:
for record in table.records:
value=record.get_value();field=record.get_field()
if field in series and isinstance(value,(int,float)) and math.isfinite(value):
series[field].append({'time':clean_time(record.get_time()),'value':float(value)})
finally:client.close()
for field in series:series[field].sort(key=lambda p:p['time'])
versions={}
for relative in ('main.py','methods/var_1.py','methods/var_2.py','methods/var_10.py','methods/var_11.py','methods/var_21.py','methods/var_22.py'):
source=Path('/app')/relative
if source.is_file():versions[relative]=hashlib.sha256(source.read_bytes()).hexdigest()
summary={}
for field,points in series.items():
values=[p['value'] for p in points]
summary[field]={'points':len(values),'allZero':bool(values) and max(abs(v) for v in values)==0,'maximumW':max(values) if values else None,'from':points[0]['time'] if points else None,'until':points[-1]['time'] if points else None}
native=True
for points in series.values():
stamps=[datetime.fromisoformat(p['time']).timestamp() for p in points]
if not stamps or any(t%300 for t in stamps) or any(b-a!=300 for a,b in zip(stamps,stamps[1:])):native=False
return {'status':'read_only_acquired','installationId':aid,'observedAt':datetime.now(timezone.utc).isoformat(),'queryResolution':'raw_no_chart_resampling','nativeFiveMinuteForecast':native,'generationTimeVerified':False,'inputFrames':frames,'forecastSummary':summary,'forecastSeries':series,'runningSourceHashes':versions,'modelWrite':False,'liveControlChanged':False}
try:
result=run()
except Exception as error:
# Never echo exception details containing SQL payloads, credentials or URLs.
result={'status':'read_only_probe_failed','errorType':type(error).__name__,'liveControlChanged':False}
print(json.dumps(result,allow_nan=False))