feat(v4): consolidate audited data recovery economic replay and optional archival

This commit is contained in:
ENELIX Agent
2026-10-03 10:40:18 +00:00
parent 6c91d6bcc6
commit 8eb9688942
26 changed files with 1747 additions and 38 deletions
+20 -5
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, measurement_pipeline
from . import meter_runtime, controlled_trial, economic_replay, retention, measurement_pipeline
from .forecast_quality import assess_family
from .receiver_contract import provenance
from .peak_policy import basis_record, RestMonthOutlook, empirical_rest_month
@@ -267,10 +267,12 @@ def assemble(store,plant,family,now):
return data,full_end,{'loadBasis':source['loadBasis'],**input_quality,**provenance(values)}
def run_once(store,now):
retention.maintain(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:
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,))]
for dataset in datasets:
try:measurement_pipeline.advance(store.con,plant,dataset,config,int(now.timestamp()))
@@ -286,14 +288,16 @@ def run_once(store,now):
try:
settings=store.settings(plant);previous=store.current(plant)
current=previous['sourceFamily'] if previous else store.registry.entries()[0].key
# Productive replay ingestion is intentionally not fabricated from R2 metrics.
selection=choose_family(settings['family'],current,(),registry=store.registry,now=now)
selection=choose_family(settings['family'],current,economic_replay.scores(store,plant,now),registry=store.registry,now=now,
lookback_days=settings['autoLookbackDays'],minimum_days=settings['autoMinimumDays'],
minimum_coverage=settings['autoMinimumCoverage'],margin_chf=settings['autoSwitchMarginChf'])
data,full_end,quality=assemble(store,plant,selection['family'],now)
data['batteries']=[replace(b,roundtrip_efficiency=settings['roundtripEfficiency']) for b in data['batteries']]
result=optimize(**data,config_revision=settings['revision'],family=selection['family'])
if result['executable']:
result.update({'installationId':plant,'inputRefs':quality.pop('inputRefs'),'controlContext':quality.pop('controlContext'),'runMode':'shadow','liveEnabled':False,'sourceSelection':selection,'forecastUntil':full_end.isoformat(),'pricesKnownUntil':result['validUntil'],'inputQuality':quality,'warnings':quality['warnings']+([] if quality['loadBasis']=='base_load' else ['Aggregate house forecast: base-load/SDL separation not verified; shadow only'])})
store.publish_shadow(plant,result,settings['revision'],now,claim['sequence'],claim['lease_token'])
economic_replay.capture(store,plant,now,assemble,optimize)
return result
except (ValueError,TypeError,KeyError) as exc:
result={'status':'awaiting_inputs' if isinstance(exc,AwaitingInput) else 'invalid_inputs','reason':str(exc)[:300],'executable':False,'points':[]}
@@ -309,7 +313,7 @@ def status(store,plant,now):
pending=store.con.execute('SELECT 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)}
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)}
def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial_plants=()):
allowed={str(UUID(p)) for p in plants}
@@ -343,7 +347,7 @@ def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial
if plant not in allowed:raise HTTPException(403,'Installation not enabled for shadow trial')
return factory()
@app.get('/health')
def health():return {'status':'ok','mode':'shadow','liveEnabled':False,'receiverProtocolVersion':1}
def health():return {'status':'ok','mode':'shadow','liveEnabled':False,'receiverProtocolVersion':1,'applicationRelease':'unified-rc1','mappingCompatibilityVersion':1,'economicReplayVersion':1,'archiveVersion':1}
@app.get('/internal/v2/prognosis/{plant}/planner')
def read(plant:str,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token)
@@ -369,6 +373,17 @@ def create_app(db_path,service_token,plants,*,start_worker=True,controlled_trial
return controlled_trial.revoke(s,plant,payload['sessionId'],datetime.now(timezone.utc))
except (ValueError,KeyError,TypeError) as exc:raise HTTPException(400,str(exc)[:300])
finally:s.close()
@app.put('/internal/v2/prognosis/{plant}/planner/datasets/{dataset}/mapping-compatibility')
def mapping_compatibility(plant:str,dataset:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token)
try:
from .mapping_identity import register
cfg=measurement_pipeline.configuration(s.con,plant,dataset)
return register(s.con,plant,cfg,payload,int(datetime.now(timezone.utc).timestamp()))
except (ValueError,KeyError,TypeError,AttributeError):
raise HTTPException(400, 'Mapping compatibility proof rejected')
finally:s.close()
@app.put('/internal/v2/prognosis/{plant}/planner/settings')
def save(plant:str,payload:dict,token:str=Header(default='',alias='X-Enelix-Service-Token')):
s=authorize(plant,token)