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