feat(workflow): publish verified forecast controls and consolidated docs
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.
This commit is contained in:
dh
2026-10-08 10:02:58 +00:00
parent af69151165
commit 74a2d6c685
82 changed files with 5054 additions and 617 deletions
+238
View File
@@ -0,0 +1,238 @@
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()