feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s
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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user