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.
215 lines
12 KiB
Python
215 lines
12 KiB
Python
"""Authenticated data onboarding and observed workflow, never actuator authority."""
|
|
from datetime import timedelta
|
|
from uuid import UUID
|
|
import json
|
|
|
|
from . import measurement_pipeline
|
|
from .domain import utc
|
|
from .receiver_contract import control_context
|
|
|
|
REPORT_MAX_AGE_SECONDS = 120
|
|
|
|
|
|
def canonical(value):
|
|
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)
|
|
|
|
|
|
def schema(con):
|
|
con.executescript('''
|
|
CREATE TABLE IF NOT EXISTS planner_enrollment(
|
|
plant TEXT PRIMARY KEY, dataset TEXT NOT NULL, learning_enabled INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS planner_workflow_report(
|
|
plant TEXT PRIMARY KEY, observed_at TEXT NOT NULL, received_at TEXT NOT NULL,
|
|
value TEXT NOT NULL);
|
|
''')
|
|
|
|
|
|
def identity(plant, value):
|
|
if not isinstance(plant, str) or str(UUID(plant)) != plant:
|
|
raise ValueError('Canonical installation UUID required')
|
|
if type(value.get('version')) is not int or value['version'] != 1 or value.get('installationId') != plant:
|
|
raise ValueError('Installation path/payload mismatch or unsupported version')
|
|
|
|
|
|
def enrolled(con, plant):
|
|
return con.execute('SELECT * FROM planner_enrollment WHERE plant=?', (plant,)).fetchone()
|
|
|
|
|
|
def learning_enabled(con, plant):
|
|
row = enrolled(con, plant)
|
|
if row is not None:
|
|
return bool(row['learning_enabled'])
|
|
report_row = con.execute('SELECT value FROM planner_workflow_report WHERE plant=?', (plant,)).fetchone()
|
|
return report_row is None or json.loads(report_row['value'])['learningEnabled']
|
|
|
|
|
|
def setup(store, plant, value, now):
|
|
identity(plant, value)
|
|
if set(value) != {'version', 'installationId', 'dataset', 'learningEnabled'} or type(value['learningEnabled']) is not bool:
|
|
raise ValueError('Explicit dataset and boolean learningEnabled required')
|
|
config = measurement_pipeline.validate_config(value['dataset'])
|
|
# Compatibility proofs and relaxed history-timing policies remain operator-only.
|
|
existing = store.con.execute('SELECT config FROM planner_data_sets WHERE plant=? AND dataset=?', (plant, config['datasetId'])).fetchone()
|
|
if not existing and (config.get('sourceDatasetId') or config.get('historyTimingPolicy')):
|
|
raise ValueError('Device setup cannot grant history compatibility or timing exceptions')
|
|
if not existing and (config['minimumCoverage'] < .95 or config['maximumGapSeconds'] > 5 or config['minimumTrainingHours'] < 24):
|
|
raise ValueError('New datasets require coverage >= .95, gap <= 5s and training >= 24h')
|
|
con = store.con
|
|
con.execute('BEGIN IMMEDIATE')
|
|
try:
|
|
measurement_pipeline.register_dataset(con, plant, config, int(now.timestamp()), own_transaction=False)
|
|
current = store.settings(plant)
|
|
initialized = con.execute('SELECT 1 FROM planner_settings WHERE plant=?', (plant,)).fetchone() is None
|
|
if initialized:
|
|
# Native interval estimates retain explicit provenance and gap checks;
|
|
# this is a planning policy, never billing or actuator evidence.
|
|
current.update(forecastSource='corrected_profile', measurementDataset=config['datasetId'], trainingCadence='daily', measurementPolicy='allow_estimates')
|
|
current.pop('revision')
|
|
store._validate_settings(current)
|
|
con.execute('INSERT INTO planner_settings VALUES(?,?,?)', (plant, 1, canonical(current)))
|
|
store._request(plant, 1, 'onboarding', now)
|
|
con.execute('INSERT INTO planner_audit(plant,at,kind,detail) VALUES(?,?,?,?)',
|
|
(plant, utc(now).isoformat(), 'onboarding', canonical({'datasetId': config['datasetId'], 'controlEnabled': False})))
|
|
stamp = utc(now).isoformat()
|
|
con.execute('''INSERT INTO planner_enrollment VALUES(?,?,?,?,?)
|
|
ON CONFLICT(plant) DO UPDATE SET dataset=excluded.dataset,
|
|
learning_enabled=excluded.learning_enabled,updated_at=excluded.updated_at''',
|
|
(plant, config['datasetId'], int(value['learningEnabled']), stamp, stamp))
|
|
con.commit()
|
|
except Exception:
|
|
con.rollback()
|
|
raise
|
|
settings = store.settings(plant)
|
|
return {'status': 'configured', 'installationId': plant, 'datasetId': config['datasetId'],
|
|
'selectedDatasetId': settings['measurementDataset'], 'settingsInitialized': initialized,
|
|
'settingsRevision': settings['revision'], 'learningEnabled': value['learningEnabled'],
|
|
'controlEnabled': False, 'controlledTrialAuthorized': False}
|
|
|
|
|
|
def report(store, plant, value, now):
|
|
identity(plant, value)
|
|
if set(value) != {'version', 'installationId', 'observedAt', 'learningEnabled', 'controlRequested', 'controlActive', 'reason'}:
|
|
raise ValueError('Explicit workflow report required')
|
|
for key in ('learningEnabled', 'controlRequested', 'controlActive'):
|
|
if type(value[key]) is not bool:
|
|
raise ValueError('Workflow flags must be boolean')
|
|
reason = value['reason']
|
|
if not isinstance(reason, str) or len(reason) > 300 or any(ord(c) < 32 for c in reason):
|
|
raise ValueError('Workflow reason must be plain text up to 300 characters')
|
|
observed = measurement_pipeline.epoch(value['observedAt'])
|
|
if not -30 <= now.timestamp() - observed <= REPORT_MAX_AGE_SECONDS:
|
|
raise ValueError('Fresh whole-second UTC workflow report required')
|
|
if value['controlActive'] and not (value['learningEnabled'] and value['controlRequested']):
|
|
raise ValueError('Active control requires learning and requested control')
|
|
normalized = {**value, 'observedAt': measurement_pipeline.iso(observed)}
|
|
data = canonical(normalized)
|
|
con = store.con
|
|
con.execute('BEGIN IMMEDIATE')
|
|
try:
|
|
old = con.execute('SELECT observed_at,value FROM planner_workflow_report WHERE plant=?', (plant,)).fetchone()
|
|
if old and utc(old['observed_at']).timestamp() > observed:
|
|
raise ValueError('Workflow report moved backwards')
|
|
if old and utc(old['observed_at']).timestamp() == observed:
|
|
if old['value'] != data:
|
|
raise ValueError('Conflicting workflow report at the same time')
|
|
con.commit()
|
|
return {'status': 'duplicate', 'controlEnabled': False}
|
|
con.execute('''INSERT INTO planner_workflow_report VALUES(?,?,?,?)
|
|
ON CONFLICT(plant) DO UPDATE SET observed_at=excluded.observed_at,
|
|
received_at=excluded.received_at,value=excluded.value''',
|
|
(plant, normalized['observedAt'], utc(now).isoformat(), data))
|
|
# The authenticated first switch also pauses autonomous training. A stop
|
|
# remains effective after report expiry; stale telemetry cannot re-enable it.
|
|
registration = enrolled(con, plant)
|
|
if registration and observed >= int(utc(registration['updated_at']).timestamp()):
|
|
con.execute('UPDATE planner_enrollment SET learning_enabled=?,updated_at=? WHERE plant=?',
|
|
(int(value['learningEnabled']), utc(now).isoformat(), plant))
|
|
con.commit()
|
|
except Exception:
|
|
con.rollback()
|
|
raise
|
|
return {'status': 'recorded', 'controlEnabled': False}
|
|
|
|
|
|
def planning_continuity(store, plant, state, now):
|
|
"""Keep readiness during routine telemetry replans, never renew a receipt.
|
|
|
|
This does not change receiver ``fresh``. The Manager must still validate its
|
|
originally accepted envelope and stop when that local envelope expires.
|
|
"""
|
|
if state['fresh']:
|
|
return True
|
|
try:
|
|
pending = state['pending']
|
|
plan = state['plan']
|
|
settings = state['settings']
|
|
run = state['lastRun']
|
|
if not pending or not plan or not run:
|
|
return False
|
|
reasons = json.loads(pending['reasons'])
|
|
if not isinstance(reasons, list) or not reasons or not set(reasons) <= {'operation_changed', 'telemetry_changed', 'five_minute_tick'}:
|
|
return False
|
|
revision = settings['revision']
|
|
if pending['revision'] != revision or plan['configRevision'] != revision:
|
|
return False
|
|
if run['status'] not in ('optimal', 'feasible_time_limit') or run['detail'].get('planId') != plan['planId']:
|
|
return False
|
|
if run['detail'].get('configRevision') != revision:
|
|
return False
|
|
if not utc(plan['validFrom']) <= now < utc(plan['validUntil']) or not 0 <= (now-utc(plan['generatedAt'])).total_seconds() <= 900:
|
|
return False
|
|
rows = store.con.execute('''SELECT i.kind,i.value FROM planner_inputs i
|
|
JOIN planner_input_current c ON i.plant=c.plant AND i.kind=c.kind AND i.event_id=c.event_id
|
|
WHERE i.plant=? AND i.kind IN ('operation','forecast','tariffs')''', (plant,))
|
|
inputs = {row['kind']: json.loads(row['value']) for row in rows}
|
|
if any(plan['inputRefs'][kind] != inputs[kind]['eventId'] for kind in ('forecast', 'tariffs')):
|
|
return False
|
|
operation = inputs['operation']
|
|
if not 0 <= (now-utc(operation['observedAt'])).total_seconds() <= 120:
|
|
return False
|
|
return control_context(operation) == plan['controlContext']
|
|
except (KeyError, TypeError, ValueError, AttributeError):
|
|
return False
|
|
|
|
|
|
def view(store, plant, state, now):
|
|
settings = state['settings']
|
|
registration = enrolled(store.con, plant)
|
|
row = store.con.execute('SELECT value FROM planner_workflow_report WHERE plant=?', (plant,)).fetchone()
|
|
report_value = json.loads(row['value']) if row else None
|
|
source = utc(report_value['observedAt']) if report_value else None
|
|
report_fresh = bool(source and 0 <= (now-source).total_seconds() <= REPORT_MAX_AGE_SECONDS)
|
|
learning = bool(registration['learning_enabled']) if registration else bool(report_fresh and report_value['learningEnabled'])
|
|
requested = bool(report_value and report_value['controlRequested'])
|
|
dataset = settings['measurementDataset']
|
|
model = measurement_pipeline.current_model(store.con, plant, dataset, int(now.timestamp())) if dataset else None
|
|
pipeline = next((d for d in state['dataPipeline']['datasets'] if d['datasetId'] == dataset), {})
|
|
plan = state.get('plan') or {}
|
|
selected = plan.get('inputQuality', {}).get('dataPipeline', {})
|
|
ready = bool(settings['forecastSource'] == 'corrected_profile' and model and planning_continuity(store, plant, state, now)
|
|
and plan.get('executable') is True and selected.get('datasetId') == dataset
|
|
and selected.get('modelId') == model['modelId'])
|
|
active = bool(learning and requested and report_fresh and report_value['controlActive'] and ready)
|
|
if active:
|
|
name, reason = 'active', report_value['reason'] or 'manager_control_active'
|
|
elif requested:
|
|
name = 'interrupted'
|
|
reason = 'manager_report_stale' if not report_fresh else report_value['reason'] or ('planning_not_ready' if not ready else 'manager_control_not_active')
|
|
elif not learning:
|
|
name, reason = 'disabled', 'learning_disabled'
|
|
elif ready:
|
|
name, reason = 'ready', 'plan_and_model_ready'
|
|
elif model:
|
|
name = 'learning'
|
|
reason = (state.get('lastRun') or {}).get('detail', {}).get('reason') or 'awaiting_fresh_plan'
|
|
elif pipeline.get('status') in ('training', 'candidate_pending'):
|
|
name, reason = 'learning', 'model_training'
|
|
else:
|
|
name, reason = 'collecting', 'collecting_measurements' if registration else 'setup_required'
|
|
return {'state': name, 'reason': reason, 'learningEnabled': learning, 'controlRequested': requested,
|
|
'controlActive': active, 'planningReady': ready, 'checkedAt': utc(now).isoformat(),
|
|
'sourceReportAt': source.isoformat() if source else None,
|
|
'sourceFreshUntil': (source+timedelta(seconds=REPORT_MAX_AGE_SECONDS)).isoformat() if source else None,
|
|
'reportFresh': report_fresh, 'usingPreviousPlan': bool(ready and not state['fresh'])}
|