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