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