Tests / test (push) Successful in 1m1s
Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
239 lines
12 KiB
Python
239 lines
12 KiB
Python
import copy
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
|
|
from fastapi.testclient import TestClient
|
|
from netplan_v4 import measurement_pipeline as m, workflow, meter_runtime
|
|
from netplan_v4.service import create_app, run_once, status, ingest
|
|
from netplan_v4.store import PlannerStore, canonical
|
|
from test_measurement_pipeline import config, record
|
|
from test_v4 import AID, TOKEN, inputs
|
|
|
|
OTHER = '00000000-0000-4000-8000-000000000002'
|
|
NOW = datetime(2026, 10, 2, 12, tzinfo=timezone.utc)
|
|
|
|
|
|
def setup_payload(plant=AID):
|
|
c = config(maximumGapSeconds=5, minimumTrainingHours=24, historyDays=30)
|
|
c['sources'] = [s for s in c['sources'] if s['role'] != 'sdl_request']
|
|
return {'version': 1, 'installationId': plant, 'dataset': c, 'learningEnabled': True}
|
|
|
|
|
|
def report_payload(plant=AID, at=NOW, **changes):
|
|
return {'version': 1, 'installationId': plant, 'observedAt': at.isoformat(),
|
|
'learningEnabled': True, 'controlRequested': False, 'controlActive': False,
|
|
'reason': '', **changes}
|
|
|
|
|
|
class WorkflowTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.path = str(Path(self.temp.name)/'workflow.sqlite')
|
|
self.store = PlannerStore(self.path)
|
|
|
|
def tearDown(self):
|
|
self.store.close()
|
|
self.temp.cleanup()
|
|
|
|
def setup(self, payload=None, plant=AID):
|
|
return workflow.setup(self.store, plant, payload or setup_payload(plant), NOW)
|
|
|
|
def test_setup_initializes_conservative_planning_only(self):
|
|
answer = self.setup()
|
|
self.assertTrue(answer['settingsInitialized'])
|
|
self.assertFalse(answer['controlEnabled'])
|
|
settings = self.store.settings(AID)
|
|
self.assertEqual(settings['forecastSource'], 'corrected_profile')
|
|
self.assertEqual(settings['trainingCadence'], 'daily')
|
|
self.assertEqual(settings['measurementPolicy'], 'allow_estimates')
|
|
self.assertEqual(settings['runMode'], 'shadow')
|
|
self.assertEqual(status(self.store, AID, NOW)['workflow']['state'], 'collecting')
|
|
|
|
def test_repeat_setup_keeps_user_settings_revision_and_history(self):
|
|
self.setup()
|
|
self.store.save_settings(AID, {'family': '23', 'trainingCadence': 'weekly'}, 1, NOW)
|
|
c = setup_payload()['dataset']
|
|
m.ingest_batch(self.store.con, AID, {'version': 1, 'datasetId': c['datasetId'],
|
|
'records': [record(int(NOW.timestamp()), c)]}, int(NOW.timestamp()))
|
|
before = self.store.settings(AID)
|
|
answer = self.setup()
|
|
self.assertFalse(answer['settingsInitialized'])
|
|
self.assertEqual(before, self.store.settings(AID))
|
|
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_observations').fetchone()[0], 1)
|
|
|
|
def test_existing_custom_dataset_selection_is_not_overwritten(self):
|
|
m.register_dataset(self.store.con, AID, config(datasetId='custom-derived'), int(NOW.timestamp()))
|
|
self.store.save_settings(AID, {'forecastSource': 'corrected_profile', 'measurementDataset': 'custom-derived'}, 0, NOW)
|
|
result = self.setup()
|
|
self.assertEqual(result['selectedDatasetId'], 'custom-derived')
|
|
self.assertEqual(result['settingsRevision'], 1)
|
|
self.assertEqual(self.store.settings(AID)['measurementPolicy'], 'verified_only')
|
|
|
|
def test_drift_requires_new_dataset_and_keeps_old_selection(self):
|
|
self.setup()
|
|
payload = setup_payload()
|
|
payload['dataset']['mappingSha256'] = 'c'*64
|
|
with self.assertRaisesRegex(ValueError, 'immutable'):
|
|
self.setup(payload)
|
|
payload['dataset']['datasetId'] = 'physical-v2'
|
|
result = self.setup(payload)
|
|
self.assertEqual(result['selectedDatasetId'], 'physical-v1')
|
|
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_data_sets').fetchone()[0], 2)
|
|
|
|
def test_two_plants_remain_isolated(self):
|
|
self.setup()
|
|
self.setup(plant=OTHER)
|
|
self.store.save_settings(AID, {'family': '23'}, 1, NOW)
|
|
self.assertEqual(self.store.settings(OTHER)['family'], '3')
|
|
workflow.report(self.store, AID, report_payload(controlRequested=True), NOW)
|
|
self.assertFalse(status(self.store, OTHER, NOW)['workflow']['controlRequested'])
|
|
bad = setup_payload(OTHER)
|
|
with self.assertRaises(ValueError):
|
|
self.setup(bad)
|
|
|
|
def test_setup_cannot_supply_safety_or_relaxed_policies(self):
|
|
for mutate in (lambda v: v.update(controlEnabled=True),
|
|
lambda v: v['dataset'].update(minimumTrainingHours=1),
|
|
lambda v: v['dataset'].update(maximumGapSeconds=10),
|
|
lambda v: v.update(version=True),
|
|
lambda v: v.update(learningEnabled=1)):
|
|
value = setup_payload()
|
|
mutate(value)
|
|
with self.assertRaises(ValueError):
|
|
self.setup(value)
|
|
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_enrollment').fetchone()[0], 0)
|
|
|
|
def test_reports_are_monotonic_idempotent_and_tenant_bound(self):
|
|
self.setup()
|
|
value = report_payload()
|
|
self.assertEqual(workflow.report(self.store, AID, value, NOW)['status'], 'recorded')
|
|
self.assertEqual(workflow.report(self.store, AID, value, NOW)['status'], 'duplicate')
|
|
for bad in ({**value, 'reason': 'different'}, report_payload(at=NOW-timedelta(seconds=1)),
|
|
report_payload(plant=OTHER)):
|
|
with self.assertRaises(ValueError):
|
|
workflow.report(self.store, AID, bad, NOW)
|
|
|
|
def test_reports_reject_invalid_types_time_and_false_active_claim(self):
|
|
self.setup()
|
|
for change in ({'controlActive': True}, {'controlRequested': 1}, {'reason': 'x'*301},
|
|
{'reason': 'line\nbreak'}, {'version': True}, {'observedAt': '2026-10-02T12:00:00'},
|
|
{'observedAt': (NOW-timedelta(seconds=121)).isoformat()},
|
|
{'observedAt': (NOW+timedelta(seconds=31)).isoformat()}):
|
|
with self.assertRaises(ValueError):
|
|
workflow.report(self.store, AID, report_payload(**change), NOW)
|
|
|
|
def ready(self, native_meter=False):
|
|
self.setup()
|
|
c = setup_payload()['dataset']
|
|
t = int(NOW.timestamp())
|
|
rows = [record(ts, c) for ts in range(t-25*3600, t+1, 30)]
|
|
for i in range(0, len(rows), 120):
|
|
m.ingest_batch(self.store.con, AID, {'version': 1, 'datasetId': c['datasetId'], 'records': rows[i:i+120]}, t)
|
|
m.advance(self.store.con, AID, c['datasetId'], self.store.settings(AID), t)
|
|
values = inputs(NOW)
|
|
if native_meter:
|
|
with self.store.con:
|
|
for seconds in range(-900, 1, 30):
|
|
at = NOW+timedelta(seconds=seconds)
|
|
observation = {'meterId': 'symcon-active-import:'+'c'*64, 'sampleAt': at.isoformat(),
|
|
'powerW': 3000., 'totalImportKwh': 100.+(seconds+900)/1200,
|
|
'controlPolicyId': 'native-test'}
|
|
meter_runtime.observe(self.store.con, AID, observation, at)
|
|
values[0]['measuredPeaks'] = {}
|
|
values[0]['meterObservation'] = observation
|
|
for kind, value in zip(('operation', 'forecast', 'tariffs'), values):
|
|
ingest(self.store, AID, kind, value, NOW)
|
|
self.assertTrue(run_once(self.store, NOW)['executable'])
|
|
|
|
def test_physical_only_ready_plan_retains_pv_and_prices(self):
|
|
self.ready()
|
|
view = status(self.store, AID, NOW)
|
|
self.assertEqual(view['workflow']['state'], 'ready')
|
|
self.assertFalse(view['workflow']['controlActive'])
|
|
plan = view['plan']
|
|
self.assertEqual(plan['points'][0]['pvW'], 5000.)
|
|
self.assertEqual(plan['points'][0]['externalW'], 0.)
|
|
self.assertEqual(plan['inputQuality']['dataPipeline']['externalPolicy'], 'no_external_sdl_channel')
|
|
|
|
def test_native_onboarding_is_ready_without_manual_peak_evidence(self):
|
|
self.ready(native_meter=True)
|
|
state = status(self.store, AID, NOW)
|
|
self.assertEqual(state['workflow']['state'], 'ready')
|
|
self.assertEqual(state['plan']['peakBasis']['2026-10']['quality'], 'estimated')
|
|
self.assertEqual(state['plan']['measuredPeaksKw'], {})
|
|
self.assertFalse(state['liveEnabled'])
|
|
|
|
def test_report_false_pauses_autonomous_worker_not_only_display(self):
|
|
self.setup()
|
|
workflow.report(self.store, AID, report_payload(learningEnabled=False), NOW)
|
|
self.assertEqual(run_once(self.store, NOW)['status'], 'learning_disabled')
|
|
|
|
def test_ordinary_replan_retains_readiness_but_never_receiver_freshness(self):
|
|
self.ready()
|
|
original = self.store.current(AID)
|
|
self.store.request(AID, 'telemetry_changed', NOW)
|
|
state = status(self.store, AID, NOW)
|
|
self.assertFalse(state['fresh'])
|
|
self.assertTrue(state['workflow']['planningReady'])
|
|
self.assertTrue(state['workflow']['usingPreviousPlan'])
|
|
self.assertEqual(state['plan'], original)
|
|
self.store.save_settings(AID, {'family': '23'}, 1, NOW)
|
|
self.assertFalse(status(self.store, AID, NOW)['workflow']['planningReady'])
|
|
|
|
def test_optimal_or_ack_alone_never_means_active(self):
|
|
self.ready()
|
|
plan = self.store.current(AID)
|
|
self.store.acknowledge(AID, plan['planId'], 1, NOW, status='received')
|
|
self.assertEqual(status(self.store, AID, NOW)['workflow']['state'], 'ready')
|
|
|
|
def test_active_requires_fresh_report_and_matching_fresh_plan(self):
|
|
self.ready()
|
|
workflow.report(self.store, AID, report_payload(controlRequested=True, controlActive=True), NOW)
|
|
view = status(self.store, AID, NOW)['workflow']
|
|
self.assertEqual(view['state'], 'active')
|
|
self.assertTrue(view['controlActive'])
|
|
stale = status(self.store, AID, NOW+timedelta(seconds=121))['workflow']
|
|
self.assertEqual(stale['state'], 'interrupted')
|
|
self.assertEqual(stale['reason'], 'manager_report_stale')
|
|
self.assertFalse(stale['controlActive'])
|
|
self.store.request(AID, 'manual', NOW)
|
|
self.assertFalse(status(self.store, AID, NOW)['workflow']['controlActive'])
|
|
|
|
def test_active_report_without_usable_model_is_interrupted(self):
|
|
self.setup()
|
|
workflow.report(self.store, AID, report_payload(controlRequested=True, controlActive=True), NOW)
|
|
view = status(self.store, AID, NOW)['workflow']
|
|
self.assertEqual(view['state'], 'interrupted')
|
|
self.assertFalse(view['controlActive'])
|
|
|
|
def test_learning_disabled_pauses_worker_but_retains_data(self):
|
|
self.setup({**setup_payload(), 'learningEnabled': False})
|
|
self.assertEqual(run_once(self.store, NOW)['status'], 'learning_disabled')
|
|
self.assertEqual(status(self.store, AID, NOW)['workflow']['state'], 'disabled')
|
|
self.assertEqual(self.store.con.execute('SELECT COUNT(*) FROM planner_data_sets').fetchone()[0], 1)
|
|
|
|
def test_private_setup_enrolls_without_expanding_actuator_allowlist(self):
|
|
with TestClient(create_app(self.path, TOKEN, [], start_worker=False)) as client:
|
|
path = '/internal/v2/prognosis/'+OTHER+'/planner'
|
|
headers = {'X-Enelix-Service-Token': TOKEN}
|
|
self.assertEqual(client.post(path+'/setup', json=setup_payload(OTHER)).status_code, 401)
|
|
self.assertEqual(client.get(path, headers=headers).status_code, 403)
|
|
result = client.post(path+'/setup', json=setup_payload(OTHER), headers=headers)
|
|
self.assertEqual(result.status_code, 200, result.text)
|
|
state = client.get(path, headers=headers).json()
|
|
self.assertFalse(state['controlledTrialAuthorized'])
|
|
self.assertIsNone(state['controlledTrial'])
|
|
self.assertFalse(state['liveEnabled'])
|
|
registry = client.get('/internal/v2/planner/installations', headers=headers)
|
|
self.assertEqual(registry.json()['installationIds'], [OTHER])
|
|
self.assertEqual(client.get('/internal/v2/planner/installations').status_code, 401)
|
|
self.assertEqual(client.post(path+'/trial/arm', json={}, headers=headers).status_code, 409)
|
|
self.assertEqual(client.post(path+'/setup', json=setup_payload(AID), headers=headers).status_code, 400)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|