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
+42 -9
View File
@@ -16,7 +16,7 @@ from .domain import Battery,Limits,Price,QuarterPast,Step,month_key,quarter_star
from .store import PlannerStore,canonical
from .selection import choose_family
from .optimizer import optimize
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline, workflow
from .forecast_quality import assess_family
from .receiver_contract import provenance
from .peak_policy import basis_record, RestMonthOutlook, empirical_rest_month
@@ -198,7 +198,9 @@ def assemble(store,plant,family,now,*,include_forecast_points=False):
input_quality['forecastAssessment']=assessment
if source.get('dataPipeline'):
input_quality['dataPipeline']=source['dataPipeline']
input_quality['warnings'].append('Corrected physical-load profile uses configured measurement mapping; external SDL is an explicitly labelled last-request persistence scenario, not a published future SDL schedule')
input_quality['warnings'].append('Corrected physical-load profile uses configured measurement mapping')
if source['dataPipeline'].get('externalPolicy')=='last_sdl_request_persistence_estimate':
input_quality['warnings'].append('External SDL is an explicitly labelled last-request persistence scenario, not a published future SDL schedule')
for p in source['points']:
start=utc(p['time']);end=start+timedelta(minutes=5)
if end<=decision:continue
@@ -276,6 +278,7 @@ def run_once(store,now):
stamp=int(now.timestamp())//300
plants=[r[0] for r in store.con.execute('SELECT plant FROM planner_settings UNION SELECT DISTINCT plant FROM planner_input_current UNION SELECT plant FROM planner_data_sets')]
for plant in plants:
if not workflow.learning_enabled(store.con,plant):continue
config=store.settings(plant)
economic_replay.advance(store,plant,now)
datasets=[r[0] for r in store.con.execute('SELECT dataset FROM planner_data_sets WHERE plant=?',(plant,))]
@@ -291,6 +294,9 @@ def run_once(store,now):
if not claim:return {'status':'idle'}
plant=claim['plant'];result={'status':'internal_error','executable':False,'points':[]}
try:
if not workflow.learning_enabled(store.con,plant):
result={'status':'learning_disabled','executable':False,'points':[]}
return result
settings=store.settings(plant);previous=store.current(plant)
current=previous['sourceFamily'] if previous else store.registry.entries()[0].key
selection=choose_family(settings['family'],current,economic_replay.scores(store,plant,now),registry=store.registry,now=now,
@@ -316,16 +322,18 @@ def run_once(store,now):
def status(store,plant,now):
plan=store.current(plant);settings=store.settings(plant)
row=store.con.execute('SELECT * FROM planner_run_status WHERE plant=?',(plant,)).fetchone()
pending=store.con.execute('SELECT reasons,requested_at FROM planner_work WHERE plant=?',(plant,)).fetchone()
pending=store.con.execute('SELECT revision,reasons,requested_at FROM planner_work WHERE plant=?',(plant,)).fetchone()
ack=store.con.execute('SELECT * FROM planner_ack WHERE plant=?',(plant,)).fetchone()
fresh=bool(plan and plan['configRevision']==settings['revision'] and utc(plan['validUntil'])>now and 0<=(now-utc(plan['generatedAt'])).total_seconds()<=900 and not pending and row and row['status'] in ('optimal','feasible_time_limit'))
return {'receiverProtocolVersion':1,'installationId':plant,'checkedAt':utc(now).isoformat(),'settings':settings,'peakPlanningBases':meter_runtime.assumptions(store.con,plant),'families':[asdict(f) for f in store.registry.entries()],'plan':plan,'fresh':fresh,'pending':dict(pending) if pending else None,'lastRun':{**dict(row),'detail':json.loads(row['detail'])} if row else None,'acknowledgement':dict(ack) if ack else None,'liveEnabled':False,'dataPipeline':measurement_pipeline.pipeline_status(store.con,plant),'economicComparison':economic_replay.status(store,plant),'maintenance':retention.status(store.con)}
state={'receiverProtocolVersion':1,'installationId':plant,'checkedAt':utc(now).isoformat(),'settings':settings,'peakPlanningBases':meter_runtime.assumptions(store.con,plant),'families':[asdict(f) for f in store.registry.entries()],'plan':plan,'fresh':fresh,'pending':dict(pending) if pending else None,'lastRun':{**dict(row),'detail':json.loads(row['detail'])} if row else None,'acknowledgement':dict(ack) if ack else None,'liveEnabled':False,'dataPipeline':measurement_pipeline.pipeline_status(store.con,plant),'economicComparison':economic_replay.status(store,plant),'maintenance':retention.status(store.con)}
state['workflow']=workflow.view(store,plant,state,now)
return state
def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial_plants=()):
allowed={str(UUID(p)) for p in plants}
trial_allowed={str(UUID(p)) for p in controlled_trial_plants}
if not trial_allowed <= allowed:raise ValueError('Trial allowlist must be a subset of plant allowlist')
if not allowed or not service_token or len(service_token)<24:raise ValueError('Private service token and explicit plant allowlist required')
if not service_token or len(service_token)<24:raise ValueError('Private service token required')
path=Path(db_path).resolve()
if path.name in ('users.db','portal.sqlite','settings.json'):raise ValueError('Dedicated planner database required')
path.parent.mkdir(parents=True,exist_ok=True);stop=Event()
@@ -346,14 +354,38 @@ def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial
if start_worker:thread.join(35)
app=FastAPI(title='ENELIX V4 - Schattenbetrieb',lifespan=lifespan)
app.state.store_factory=factory
def authorize(plant,token):
def authorize(plant,token,*,onboarding=False):
if not secrets.compare_digest(token or '',service_token):raise HTTPException(401,'Unauthorized')
try:plant=str(UUID(plant))
try:
if str(UUID(plant))!=plant:raise ValueError('Canonical UUID required')
except ValueError:raise HTTPException(400,'Invalid installation ID')
if plant not in allowed:raise HTTPException(403,'Installation not enabled for shadow trial')
return factory()
s=factory()
if not onboarding and plant not in allowed and not workflow.enrolled(s.con,plant):
s.close();raise HTTPException(403,'Installation not enabled for planning')
return s
@app.post('/internal/v2/prognosis/{plant}/planner/setup')
def setup(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token,onboarding=True)
try:return workflow.setup(s,plant,payload,datetime.now(timezone.utc))
except (ValueError,KeyError,TypeError,AttributeError) as exc:raise HTTPException(409 if 'immutable' in str(exc) else 400,str(exc)[:200])
finally:s.close()
@app.post('/internal/v2/prognosis/{plant}/planner/workflow')
def workflow_report(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token)
try:return workflow.report(s,plant,payload,datetime.now(timezone.utc))
except (ValueError,KeyError,TypeError,AttributeError) as exc:raise HTTPException(400,str(exc)[:200])
finally:s.close()
@app.get('/health')
def health():return {'status':'ok','mode':'shadow','liveEnabled':False,'receiverProtocolVersion':1,'applicationRelease':'unified-rc1','mappingCompatibilityVersion':1,'economicReplayVersion':1,'archiveVersion':1}
@app.get('/internal/v2/planner/installations')
def planning_installations(token:str=Header(default='',alias='X-Enelix-Service-Token')):
if not secrets.compare_digest(token or '',service_token):raise HTTPException(401,'Unauthorized')
s=factory()
try:
registered={r[0] for r in s.con.execute('SELECT plant FROM planner_enrollment WHERE learning_enabled=1')}
paused={r[0] for r in s.con.execute('SELECT plant FROM planner_enrollment WHERE learning_enabled=0')}
return {'installationIds':sorted((allowed|registered)-paused),'controlEnabled':False}
finally:s.close()
@app.get('/internal/v2/prognosis/{plant}/planner')
def read(plant:str,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token)
@@ -419,6 +451,7 @@ def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial
def measurement_batch(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token)
try:
if not workflow.learning_enabled(s.con,plant):raise ValueError('Learning is disabled for this installation')
now=datetime.now(timezone.utc)
result=measurement_pipeline.ingest_batch(s.con,plant,payload,int(now.timestamp()))
# Existing five-minute worker handles rollup/training; no per-record optimizer flood.