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:
@@ -0,0 +1,214 @@
|
||||
"""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'])}
|
||||
Reference in New Issue
Block a user