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