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
+14 -1
View File
@@ -8,7 +8,7 @@ function fixture(options={}){
configuredPrognosisPlant:(req,res,id,csrf)=>{auth.push({id,csrf});return options.denied?null:{plant:{installation_id:INSTALL},license:{quantities:{grid_schedule:options.unlicensed?0:1}},session:{}};},
roleAllowed:()=>!options.viewer,bodyJson:async()=>({expectedRevision:0,changes:{family:'23'}}),
json:(res,status,payload)=>sent.push({status,payload}),deviceActivation:()=>options.deviceDenied?null:{plant_id:PLANT},
checkDeviceRate:()=>!options.ratelimited,ownedLicenseState:()=>({quantities:{grid_schedule:1}}),serviceToken:'synthetic-test-only',
checkDeviceRate:()=>!options.ratelimited,ownedLicenseState:()=>({quantities:{grid_schedule:options.unlicensed?0:1}}),serviceToken:'synthetic-test-only',
fetchImpl:async(url,args)=>{upstream.push({url:String(url),args});if(options.down)throw new Error('secret-host-detail');return {ok:!options.conflict,status:options.conflict?409:200,json:async()=>options.conflict?{detail:'internal-field'}:{liveEnabled:false,plan:{planId:'p1'}}};}
});return {bridge,sent,auth,upstream};
}
@@ -30,3 +30,16 @@ test('measurement batches use authenticated dedicated data ingress',async()=>{co
test('unauthenticated measurement upload cannot reach storage',async()=>{const f=fixture({deviceDenied:true});await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/measurements`));assert.equal(f.upstream.length,0);});
test('device cannot change dataset mapping through public proxy',async()=>{const f=fixture();assert.equal(await f.bridge({method:'PUT'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/datasets/physical-v1`)),false);assert.equal(f.upstream.length,0);});
test('measurement upload keeps device rate protection',async()=>{const f=fixture({ratelimited:true});await f.bridge({method:'POST'},{},new URL(`https://portal.test/api/v1/installations/${INSTALL}/prognosis/planner-v4/measurements`));assert.equal(f.sent[0].status,429);assert.equal(f.upstream.length,0);});
for(const action of ['setup','workflow']){
const endpoint=id=>new URL(`https://portal.test/api/v1/installations/${id}/prognosis/planner-v4/${action}`);
test(`${action} uses authenticated device license and installation binding`,async()=>{
const f=fixture();assert.equal(await f.bridge({method:'POST'},{},endpoint(INSTALL)),true);
assert.match(f.upstream[0].url,new RegExp(`/prognosis/${INSTALL}/planner/${action}$`));
assert.equal(f.upstream[0].args.method,'POST');
});
for(const option of ['deviceDenied','unlicensed','ratelimited'])test(`${action} rejects ${option} before forwarding`,async()=>{
const f=fixture({[option]:true});await f.bridge({method:'POST'},{},endpoint(INSTALL));assert.equal(f.upstream.length,0);
});
test(`${action} cannot use a read request to mutate`,async()=>{const f=fixture();await f.bridge({method:'GET'},{},endpoint(INSTALL));assert.equal(f.sent[0].status,405);assert.equal(f.upstream.length,0);});
}
@@ -0,0 +1,61 @@
"""Cross-language contract for the ordinary Manager's generated measurement map."""
from hashlib import sha256
import json
from pathlib import Path
import shutil
import sqlite3
import subprocess
import unittest
from netplan_v4 import measurement_pipeline as pipeline
@unittest.skipUnless(shutil.which('php'), 'Native PHP CLI required for capture contract')
class NativeMeasurementSetupTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
fixture = Path(__file__).resolve().parents[3] / 'tests' / 'V4Setup' / 'checks.php'
result = subprocess.run(['php', str(fixture), '--fixture'], check=True, capture_output=True, text=True)
if result.stderr:
raise AssertionError(result.stderr)
cls.fixture = json.loads(result.stdout)
def test_dataset_matches_backend_and_exact_php_capture_hash(self):
setup = self.fixture['setup']
config = pipeline.validate_config(setup['dataset'])
self.assertEqual(config['mappingSha256'], sha256(self.fixture['captureJson'].encode()).hexdigest())
self.assertEqual(config['mappingSha256'], self.fixture['record']['mappingSha256'])
self.assertEqual(config['inventorySha256'], self.fixture['record']['reportedInventorySha256'])
self.assertEqual(config['datasetId'], setup['datasetId'])
self.assertEqual(len(config['sources']), 3)
def test_capture_ingests_without_virtual_double_count_or_permissions(self):
setup = self.fixture['setup']
record = self.fixture['record']
config = setup['dataset']
plant = record['installationId']
now = pipeline.epoch(record['capturedAt'])
con = sqlite3.connect(':memory:')
try:
pipeline.schema(con)
registered = pipeline.register_dataset(con, plant, config, now)
self.assertFalse(registered['controlEnabled'])
receipt = pipeline.ingest_batch(con, plant, {'version': 1, 'datasetId': setup['datasetId'], 'records': [record]}, now)
self.assertEqual(receipt['stored'], 1)
self.assertFalse(receipt['controlEnabled'])
values = {key: source['value'] for key, source in record['raw'].items()}
self.assertEqual(pipeline.physical_value(values, config), 4000)
self.assertFalse(record['trainingEligible'])
self.assertFalse(record['measurementBoundaryVerified'])
changed = json.loads(json.dumps(config))
changed['sources'][0]['factorToW'] *= -1
with self.assertRaises(ValueError):
pipeline.register_dataset(con, plant, changed, now)
with self.assertRaises(ValueError):
pipeline.ingest_batch(con, 'other-installation', {'version': 1, 'datasetId': setup['datasetId'], 'records': [record]}, now)
finally:
con.close()
if __name__ == '__main__':
unittest.main()
@@ -40,7 +40,12 @@ class ReleasePreflightTest(unittest.TestCase):
self.assertIn('?mode=ro',text);self.assertNotIn('ALTER TABLE',text)
def test_gui_labels_estimates_and_incomplete_features(self):
text=(ROOT/'gui/netplan-v4.js').read_text()
self.assertIn('SCHATTENBETRIEB',text)
self.assertIn('Die Kurven zeigen berechnete Plaene, keine nachgewiesene Ausfuehrung',text)
self.assertIn('Geraete- und Netzgrenzen behalten Vorrang',text)
self.assertIn('Planempfang ist kein Ausfuehrungsnachweis',text)
self.assertIn('kein Nachweis realisierter Einsparungen',text)
self.assertIn('Planung mit geschaetzter Peakbasis',text)
self.assertIn('kein Abrechnungsnachweis',text)
self.assertIn('measurementPolicy',text)
self.assertIn('erstellt vergleichbare Kosten-Replays',text)
self.assertIn('keine vollstaendige Nachbildung laufender Neuoptimierungen',text)
@@ -48,4 +53,15 @@ class ReleasePreflightTest(unittest.TestCase):
self.assertIn('trainingCadence',text)
self.assertIn('Restmonatsbewertung ist keine bereits bezahlte Peakfreigabe',text)
def test_gui_separates_planning_from_confirmed_manager_control(self):
text=(ROOT/'gui/netplan-v4.js').read_text()
self.assertIn('Betriebsstatus nicht bestaetigt',text)
self.assertIn('workflow.controlActive && workflow.controlRequested && workflow.planningReady',text)
self.assertIn('workflow.reportFresh !== true',text)
self.assertIn('Date.parse(workflow.sourceFreshUntil) <= now',text)
self.assertIn("state: 'interrupted'",text)
self.assertIn('controlActive: false',text)
self.assertIn('lokale Schutzpruefung bleibt erforderlich',text)
self.assertIn("setTimeout(showWorkflow",text)
if __name__=='__main__':unittest.main()
+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()
@@ -0,0 +1,278 @@
"""Stdlib-only enrollment/status tests, independent of the HTTP/solver runtime."""
from datetime import datetime, timedelta, timezone
from copy import deepcopy
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from netplan_v4 import workflow, measurement_pipeline as m, meter_runtime
from netplan_v4.store import PlannerStore, canonical
from netplan_v4.receiver_contract import control_context
PLANT = '00000000-0000-4000-8000-000000000001'
OTHER = '00000000-0000-4000-8000-000000000002'
NOW = datetime(2026, 10, 8, 12, tzinfo=timezone.utc)
def payload(plant=PLANT):
config = {'datasetId': 'physical-v1', 'mappingSha256': 'a'*64, 'inventorySha256': 'b'*64,
'formula': 'physical_sum_v1', 'solarReference': None, 'minimumCoverage': .95,
'maximumGapSeconds': 5, 'minimumTrainingHours': 24, 'historyDays': 30,
'sources': [{'key': role, 'role': role, 'variableId': i+101, 'factorToW': 1,
'maxAgeSeconds': 60} for i, role in enumerate(('grid', 'pv', 'physical_storage'))]}
return {'version': 1, 'installationId': plant, 'dataset': config, 'learningEnabled': True}
def report(**changes):
return {'version': 1, 'installationId': PLANT, 'observedAt': NOW.isoformat(),
'learningEnabled': True, 'controlRequested': False, 'controlActive': False,
'reason': '', **changes}
class WorkflowCoreTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.s = PlannerStore(str(Path(self.temp.name)/'planner.sqlite'))
def tearDown(self):
self.s.close()
self.temp.cleanup()
def state(self, at=NOW, **changes):
state = {'settings': self.s.settings(PLANT), 'fresh': False, 'plan': None,
'dataPipeline': {'datasets': []}, 'lastRun': None, **changes}
return workflow.view(self.s, PLANT, state, at)
def test_new_installation_enrolls_and_duplicate_retains_user_revision(self):
result = workflow.setup(self.s, PLANT, payload(), NOW)
self.assertTrue(result['settingsInitialized'])
self.assertFalse(result['controlEnabled'])
self.assertFalse(result['controlledTrialAuthorized'])
self.s.save_settings(PLANT, {'family': '23', 'trainingCadence': 'weekly'}, 1, NOW)
old = self.s.settings(PLANT)
workflow.setup(self.s, PLANT, payload(), NOW)
self.assertEqual(old, self.s.settings(PLANT))
self.assertEqual(old['measurementPolicy'], 'allow_estimates')
self.assertEqual(self.state()['state'], 'collecting')
def test_two_installations_and_dataset_drift_are_isolated(self):
workflow.setup(self.s, PLANT, payload(), NOW)
workflow.setup(self.s, OTHER, payload(OTHER), NOW)
drift = payload()
drift['dataset']['mappingSha256'] = 'c'*64
with self.assertRaisesRegex(ValueError, 'immutable'):
workflow.setup(self.s, PLANT, drift, NOW)
self.assertEqual(self.s.con.execute('SELECT COUNT(*) FROM planner_data_sets').fetchone()[0], 2)
workflow.report(self.s, PLANT, report(), NOW)
self.assertIsNone(self.s.con.execute('SELECT value FROM planner_workflow_report WHERE plant=?', (OTHER,)).fetchone())
def test_device_cannot_weaken_thresholds_or_claim_authority(self):
for mutate in (lambda x: x.update(controlEnabled=True), lambda x: x.update(learningEnabled=1),
lambda x: x.update(installationId=OTHER), lambda x: x['dataset'].update(minimumTrainingHours=1),
lambda x: x['dataset'].update(maximumGapSeconds=10)):
value = payload()
mutate(value)
with self.assertRaises(ValueError):
workflow.setup(self.s, PLANT, value, NOW)
self.assertIsNone(workflow.enrolled(self.s.con, PLANT))
def test_report_duplicate_monotonicity_and_exact_types(self):
workflow.setup(self.s, PLANT, payload(), NOW)
self.assertEqual(workflow.report(self.s, PLANT, report(), NOW)['status'], 'recorded')
self.assertEqual(workflow.report(self.s, PLANT, report(), NOW)['status'], 'duplicate')
for changes in ({'reason': 'conflict'}, {'controlRequested': 1}, {'version': True},
{'controlActive': True}, {'reason': 'x'*301}, {'reason': 'line\nbreak'},
{'observedAt': (NOW-timedelta(seconds=1)).isoformat()},
{'observedAt': '2026-10-08T12:00:00'}, {'installationId': OTHER}):
with self.assertRaises(ValueError):
workflow.report(self.s, PLANT, report(**changes), NOW)
def test_fresh_plan_and_model_do_not_imply_active_manager(self):
workflow.setup(self.s, PLANT, payload(), NOW)
with self.s.con:
self.s.con.execute('INSERT INTO planner_load_models VALUES(?,?,?,?,?,?)',
(PLANT, 'physical-v1', 'model-1', int(NOW.timestamp()), int(NOW.timestamp()), '{}'))
self.s.con.execute('INSERT INTO planner_model_current VALUES(?,?,?)', (PLANT, 'physical-v1', 'model-1'))
plan = {'executable': True, 'inputQuality': {'dataPipeline': {'datasetId': 'physical-v1', 'modelId': 'model-1'}}}
self.assertEqual(self.state(fresh=True, plan=plan)['state'], 'ready')
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
self.assertEqual(self.state(fresh=True, plan=plan)['state'], 'active')
self.assertFalse(self.state(fresh=False, plan=plan)['controlActive'])
stale = self.state(at=NOW+timedelta(seconds=121), fresh=True, plan=plan)
self.assertEqual(stale['state'], 'interrupted')
self.assertEqual(stale['reason'], 'manager_report_stale')
self.assertFalse(stale['controlActive'])
def test_model_mismatch_and_unready_plan_block_active(self):
workflow.setup(self.s, PLANT, payload(), NOW)
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
self.assertEqual(self.state()['state'], 'interrupted')
self.assertFalse(self.state()['planningReady'])
def test_learning_toggle_preserves_registration_and_plan_settings(self):
workflow.setup(self.s, PLANT, payload(), NOW)
old = self.s.settings(PLANT)
workflow.setup(self.s, PLANT, {**payload(), 'learningEnabled': False}, NOW)
self.assertEqual(self.state()['state'], 'disabled')
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
self.assertEqual(old, self.s.settings(PLANT))
def test_explicit_learning_stop_overrides_earlier_fresh_active_report(self):
workflow.setup(self.s, PLANT, payload(), NOW)
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
workflow.setup(self.s, PLANT, {**payload(), 'learningEnabled': False}, NOW+timedelta(seconds=1))
self.assertFalse(self.state()['learningEnabled'])
self.assertFalse(self.state()['controlActive'])
def test_workflow_learning_stop_pauses_worker_until_new_enable_report(self):
workflow.setup(self.s, PLANT, payload(), NOW)
original = self.s.settings(PLANT)
workflow.report(self.s, PLANT, report(learningEnabled=False), NOW)
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
self.assertFalse(self.state(at=NOW+timedelta(seconds=121))['learningEnabled'])
later = NOW+timedelta(seconds=122)
workflow.report(self.s, PLANT, report(observedAt=later.isoformat()), later)
self.assertTrue(workflow.learning_enabled(self.s.con, PLANT))
self.assertEqual(original, self.s.settings(PLANT))
def test_pre_enrollment_legacy_plant_can_pause_learning_via_report(self):
workflow.report(self.s, PLANT, report(learningEnabled=False), NOW)
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
def test_report_captured_before_setup_stop_cannot_reenable_learning(self):
workflow.setup(self.s, PLANT, payload(), NOW)
workflow.setup(self.s, PLANT, {**payload(), 'learningEnabled': False}, NOW+timedelta(seconds=10))
delayed = report(observedAt=(NOW+timedelta(seconds=5)).isoformat())
workflow.report(self.s, PLANT, delayed, NOW+timedelta(seconds=11))
self.assertFalse(workflow.learning_enabled(self.s.con, PLANT))
self.assertFalse(self.state(at=NOW+timedelta(seconds=11))['learningEnabled'])
def test_native_meter_bootstrap_preserves_estimated_quality_and_gap_checks(self):
workflow.setup(self.s, PLANT, payload(), NOW)
meter = 'symcon-active-import:'+'c'*64
with self.s.con:
for seconds in range(-900, 61, 30):
at = NOW+timedelta(seconds=seconds)
observation = {'meterId': meter, 'sampleAt': at.isoformat(), 'powerW': 3000.,
'totalImportKwh': 100.+(seconds+900)/1200, 'controlPolicyId': 'native-test'}
meter_runtime.observe(self.s.con, PLANT, observation, at)
peaks = meter_runtime.assumptions(self.s.con, PLANT)
self.assertEqual(peaks['2026-10']['quality'], 'estimated')
self.assertAlmostEqual(peaks['2026-10']['kw'], 3.)
self.assertEqual(self.s.peaks(PLANT), {})
quarter = meter_runtime.current_quarter(self.s.con, PLANT, observation, NOW+timedelta(seconds=60))
self.assertEqual(quarter['quality'], 'estimated')
self.assertEqual(quarter['coverage'], 1.)
self.assertIsNone(meter_runtime.current_quarter(self.s.con, PLANT, observation, NOW+timedelta(seconds=181)))
def continuity_state(self):
workflow.setup(self.s, PLANT, payload(), NOW)
operation = {'eventId': 'new-telemetry', 'observedAt': NOW.isoformat(),
'batteries': [{'id': 'battery', 'capacityKwh': 10., 'minSocPercent': 10.,
'maxSocPercent': 90., 'gridCharging': True}],
'limits': {'importW': 10000., 'exportW': 5000., 'managerMonthLimitsW': {}}}
inputs = {'operation': operation, 'forecast': {'eventId': 'forecast-1'}, 'tariffs': {'eventId': 'tariffs-1'}}
with self.s.con:
for kind, value in inputs.items():
self.s.con.execute('INSERT INTO planner_inputs VALUES(?,?,?,?,?)',
(PLANT, kind, value['eventId'], NOW.isoformat(), canonical(value)))
self.s.con.execute('INSERT INTO planner_input_current VALUES(?,?,?)', (PLANT, kind, value['eventId']))
self.s.con.execute('INSERT INTO planner_load_models VALUES(?,?,?,?,?,?)',
(PLANT, 'physical-v1', 'model-1', int(NOW.timestamp()), int(NOW.timestamp()), '{}'))
self.s.con.execute('INSERT INTO planner_model_current VALUES(?,?,?)', (PLANT, 'physical-v1', 'model-1'))
plan = {'planId': 'plan-1', 'configRevision': 1, 'executable': True,
'generatedAt': NOW.isoformat(), 'validFrom': NOW.isoformat(), 'validUntil': (NOW+timedelta(minutes=15)).isoformat(),
'controlContext': control_context(operation), 'inputRefs': {'operation': 'old-telemetry', 'forecast': 'forecast-1', 'tariffs': 'tariffs-1'},
'inputQuality': {'dataPipeline': {'datasetId': 'physical-v1', 'modelId': 'model-1'}}}
return {'settings': self.s.settings(PLANT), 'fresh': False, 'plan': plan,
'pending': {'revision': 1, 'reasons': canonical(['operation_changed', 'five_minute_tick'])},
'dataPipeline': {'datasets': []},
'lastRun': {'status': 'optimal', 'detail': {'planId': 'plan-1', 'configRevision': 1}}}
def test_routine_same_policy_replan_keeps_readiness_without_renewing_envelope(self):
state = self.continuity_state()
original = deepcopy(state)
workflow.report(self.s, PLANT, report(controlRequested=True, controlActive=True), NOW)
view = workflow.view(self.s, PLANT, state, NOW)
self.assertTrue(view['planningReady'])
self.assertTrue(view['usingPreviousPlan'])
self.assertTrue(view['controlActive'])
self.assertEqual(original, state)
self.assertFalse(state['fresh'])
def test_replan_continuity_rejects_revision_prices_model_and_failed_run(self):
state = self.continuity_state()
for change in (lambda s: s['pending'].update(reasons='["prices_changed"]'),
lambda s: s['pending'].update(reasons='["forecast_changed"]'),
lambda s: s['pending'].update(revision=2),
lambda s: s['settings'].update(revision=2),
lambda s: s['plan'].update(validUntil=NOW.isoformat()),
lambda s: s['plan'].update(generatedAt=(NOW-timedelta(seconds=901)).isoformat()),
lambda s: s['lastRun'].update(status='awaiting_inputs'),
lambda s: s['plan']['inputRefs'].update(forecast='changed'),
lambda s: s['plan']['inputRefs'].update(tariffs='changed'),
lambda s: s['plan']['controlContext']['limits'].update(importW=500.),
lambda s: s['plan']['inputQuality']['dataPipeline'].update(modelId='old-model')):
altered = deepcopy(state)
change(altered)
self.assertFalse(workflow.view(self.s, PLANT, altered, NOW)['planningReady'])
self.assertFalse(workflow.view(self.s, PLANT, state, NOW+timedelta(seconds=121))['planningReady'])
def test_physical_only_training_keeps_pv_and_needs_no_fabricated_sdl(self):
workflow.setup(self.s, PLANT, payload(), NOW)
c = payload()['dataset']
end = int(NOW.timestamp())
rows = []
for stamp in range(end-25*3600, end+1, 30):
rows.append({'schemaVersion': 1, 'kind': 'raw_accounting_capture', 'installationId': PLANT,
'capturedAt': m.iso(stamp), 'captureStartedAt': m.iso(stamp),
'mappingSha256': c['mappingSha256'], 'reportedInventorySha256': c['inventorySha256'],
'raw': {s['key']: {'variableId': s['variableId'], 'value': {'grid': 3000, 'pv': 5000, 'physical_storage': 2000}[s['key']],
'sourceUpdatedAt': stamp, 'issues': []} for s in c['sources']}})
for start in range(0, len(rows), 120):
m.ingest_batch(self.s.con, PLANT, {'version': 1, 'datasetId': c['datasetId'], 'records': rows[start:start+120]}, end)
m.advance(self.s.con, PLANT, c['datasetId'], self.s.settings(PLANT), end)
model = m.current_model(self.s.con, PLANT, c['datasetId'], end)
self.assertIsNotNone(model)
forecast = {'observedAt': NOW.isoformat(), 'families': {'3': {'points': [{'time': NOW.isoformat(), 'pvW': 5000, 'loadW': 9999}]}}}
result = m.apply_load_forecast(self.s.con, PLANT, c['datasetId'], forecast, end)['families']['3']
self.assertEqual(result['points'][0]['loadW'], 6000)
self.assertEqual(result['points'][0]['pvW'], 5000)
self.assertEqual(result['points'][0]['externalW'], 0)
self.assertEqual(result['dataPipeline']['externalPolicy'], 'no_external_sdl_channel')
class PublisherEnrollmentTest(unittest.TestCase):
def setUp(self):
path = Path(__file__).resolve().parents[1]/'integrations/netplan_v4_publisher.py'
spec = importlib.util.spec_from_file_location('workflow_test_publisher', path)
self.publisher = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.publisher)
def response(self, plants):
class Response:
def __enter__(self): return self
def __exit__(self, *args): pass
def read(self, limit): return json.dumps({'installationIds': plants, 'controlEnabled': False}).encode()
return Response()
def test_registry_adds_new_installations_without_static_manual_configuration(self):
env = {'NETPLAN_V4_URL': 'http://planner.test', 'PROGNOSIS_SERVICE_TOKEN': 'synthetic-test-token', 'NETPLAN_V4_PLANTS': PLANT}
with patch.dict(os.environ, env), patch.object(self.publisher, 'urlopen', return_value=self.response([OTHER])) as request:
self.assertTrue(self.publisher.enabled(PLANT))
self.assertTrue(self.publisher.enabled(OTHER))
self.assertEqual(request.call_count, 1)
self.assertEqual(request.call_args.args[0].full_url, 'http://planner.test/internal/v2/planner/installations')
def test_registry_failure_preserves_explicit_plants_not_cached_dynamic_grants(self):
env = {'NETPLAN_V4_URL': 'http://planner.test', 'PROGNOSIS_SERVICE_TOKEN': 'synthetic-test-token', 'NETPLAN_V4_PLANTS': PLANT}
with patch.dict(os.environ, env), patch.object(self.publisher, 'urlopen', side_effect=TimeoutError):
self.assertEqual(self.publisher.planning_plants(), {PLANT})
if __name__ == '__main__':
unittest.main()