Files
Enelix-EMS/services/netplan-v4/acceptance/forecast-src/tests/test_telemetry_integrity.py
T

214 lines
11 KiB
Python

"""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()