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

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