feat(application): integrate measured-load ingestion training and planner source
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""One consolidated ROOT preflight; tests and read-only acquisition, NEVER deploy.
|
||||
|
||||
Builds disposable test images, executes Python and native-PHP conversion tests,
|
||||
then reads actual raw forecast/input diagnostics using the existing container.
|
||||
No docker up/restart, no live-setting change, no training or actuator call.
|
||||
A passing preflight is NOT a production release or full plant acceptance.
|
||||
"""
|
||||
from datetime import datetime,timezone
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from uuid import UUID
|
||||
from forecast_acceptance import verify_forecast_bundle
|
||||
|
||||
ROOT=Path(__file__).resolve().parent
|
||||
REPO=Path('/srv/agent/repos/Enelix-EMS')
|
||||
PROJECT=Path('/home/agent/services/prognosis-manager-enelix2')
|
||||
|
||||
|
||||
def command_list(plant):
|
||||
return [
|
||||
('python311_and_portal',[sys.executable,str(ROOT/'deploy_shadow.py'),'--plant',plant,'--test-only'],ROOT,900),
|
||||
('php_image',['docker','build','-f',str(ROOT/'acceptance/Dockerfile.php'),'-t','enelix-netplan-v4-php-check:local',str(ROOT/'acceptance')],ROOT,900),
|
||||
('php83_offline',['docker','run','--rm','--network','none','--read-only','--user','1000:1000','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:rw,noexec,nosuid,size=32m','enelix-netplan-v4-php-check:local'],ROOT,120),
|
||||
('forecast_candidate_image',['docker','build','-f',str(ROOT/'acceptance/Dockerfile.forecast'),'-t','enelix-forecast-candidate-check:local',str(ROOT/'acceptance')],ROOT,900),
|
||||
('forecast_candidate_python311',['docker','run','--rm','--network','none','--read-only','--user','1000:1000','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:rw,noexec,nosuid,size=64m','enelix-forecast-candidate-check:local'],ROOT,180),
|
||||
('gui_syntax',['node','--check',str(ROOT/'gui/netplan-v4.js')],ROOT,30),
|
||||
]
|
||||
|
||||
|
||||
def source_manifest():
|
||||
selected=list((ROOT/'netplan_v4').glob('*.py'))+list((ROOT/'tests').glob('*.py'))+[ROOT/'gui/netplan-v4.js',ROOT/'acceptance/read_native_forecasts.py',ROOT/'forecast_acceptance.py',ROOT/'acceptance/check_forecast.py',ROOT/'acceptance/Dockerfile.forecast',ROOT/'acceptance/forecast-src/SOURCE_MANIFEST.json']
|
||||
selected += [ROOT/'release_preflight.py', ROOT/'Dockerfile', ROOT/'acceptance/Dockerfile.php', ROOT/'acceptance/php-src/SOURCE_MANIFEST.json']
|
||||
return {str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(selected)}
|
||||
|
||||
|
||||
def verify_native_bundle():
|
||||
bundle=ROOT/'acceptance/php-src'
|
||||
manifest=json.loads((bundle/'SOURCE_MANIFEST.json').read_text())
|
||||
for relative,expected in manifest.items():
|
||||
if Path(relative).is_absolute() or '..' in Path(relative).parts:raise ValueError('Unsafe source manifest')
|
||||
for p in (bundle/relative,REPO/relative):
|
||||
if not p.is_file() or p.is_symlink() or hashlib.sha256(p.read_bytes()).hexdigest()!=expected:
|
||||
raise ValueError('Native source changed after staging; review and refresh acceptance bundle')
|
||||
|
||||
|
||||
def save(path,value):
|
||||
raw=json.dumps(value,indent=2,allow_nan=False)+'\n'
|
||||
with path.open('w') as output:output.write(raw)
|
||||
os.chmod(path,0o640)
|
||||
if os.geteuid()==0:os.chown(path,1000,1000)
|
||||
|
||||
|
||||
def run(plant):
|
||||
verify_native_bundle()
|
||||
forecast_hashes=verify_forecast_bundle(ROOT/'acceptance/forecast-src',PROJECT/'forecast_engine')
|
||||
stamp=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||
reports=ROOT/'acceptance-reports'/stamp;reports.mkdir(parents=True,exist_ok=False)
|
||||
if os.geteuid()==0:
|
||||
os.chown(reports.parent,1000,1000);os.chown(reports,1000,1000)
|
||||
report={'createdAt':datetime.now(timezone.utc).isoformat(),'installationId':plant,'productionReady':False,'deploymentPerformed':False,'tests':{},'sourceHashes':source_manifest(),'remainingWork':['Native source installation and complete Symcon runtime test','Restore/validate real forecast telemetry and nonzero load profiles','Wire productive historical family replay and model promotion','Verify base-load/SDL accounting and physical meter metadata','Implement and test live V4 plan execution and fallback before activation']}
|
||||
report['forecastCandidateSourceHashes']=forecast_hashes
|
||||
report['forecastCandidateRuntimeTestIsOffline']=True
|
||||
env=dict(os.environ,NETPLAN_V4_PLANTS=plant)
|
||||
success=True
|
||||
for name,command,cwd,timeout in command_list(plant):
|
||||
print('\n=== '+name+' ===',flush=True)
|
||||
try:
|
||||
completed=subprocess.run(command,cwd=cwd,env=env,timeout=timeout,check=False)
|
||||
code=completed.returncode
|
||||
except subprocess.TimeoutExpired:code=124
|
||||
report['tests'][name]={'exitCode':code,'passed':code==0}
|
||||
if code:
|
||||
success=False;break
|
||||
if success:
|
||||
print('\n=== Existing forecast engine: READ ONLY ===',flush=True)
|
||||
command=['docker','compose','-f',str(PROJECT/'compose.yaml'),'exec','-T','-e','ENELIX_ACCEPTANCE_PLANT='+plant,'forecast-engine','python','-B','-']
|
||||
try:
|
||||
result=subprocess.run(command,cwd=PROJECT,env=env,input=(ROOT/'acceptance/read_native_forecasts.py').read_text(),text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120,check=False)
|
||||
if result.returncode or len(result.stdout)>8000000:raise RuntimeError('Probe execution failed or oversized')
|
||||
probe=json.loads(result.stdout)
|
||||
save(reports/'native-inputs.json',probe)
|
||||
report['nativeInputStatus']=probe.get('status')
|
||||
report['forecastSummary']=probe.get('forecastSummary',{})
|
||||
report['dataBlockers']=[]
|
||||
for key,value in report['forecastSummary'].items():
|
||||
if key in ('prog_var_2','prog_var_11','prog_var_22') and (not value.get('points') or value.get('allZero')):
|
||||
report['dataBlockers'].append(key+': missing or unconfirmed all-zero load forecast')
|
||||
recent=probe.get('inputFrames',{}).get('df_recent_raw',{}).get('columns',{}).get('Hausverbrauch',{}).get('lastFiniteAt')
|
||||
if not recent or (datetime.now(timezone.utc)-datetime.fromisoformat(recent)).total_seconds()>1800:
|
||||
report['dataBlockers'].append('Recent raw load telemetry missing or older than 30 minutes')
|
||||
if probe.get('status')!='read_only_acquired' or report['dataBlockers']:success=False
|
||||
except (ValueError,RuntimeError,subprocess.TimeoutExpired) as error:
|
||||
report['nativeInputStatus']='failed';report['nativeInputErrorType']=type(error).__name__;success=False
|
||||
try:
|
||||
verify_native_bundle()
|
||||
report['sourceStableDuringTests']=(source_manifest()==report['sourceHashes'] and verify_forecast_bundle(ROOT/'acceptance/forecast-src',PROJECT/'forecast_engine')==forecast_hashes)
|
||||
except (OSError,ValueError):
|
||||
report['sourceStableDuringTests']=False
|
||||
if not report['sourceStableDuringTests']:
|
||||
report.setdefault('dataBlockers',[]).append('Source changed during checks; results cannot certify current candidate')
|
||||
success=False
|
||||
report['preflightChecksPassed']=success
|
||||
save(reports/'REPORT.json',report)
|
||||
save(ROOT/'acceptance-reports'/'LATEST.json',{'report':str(reports/'REPORT.json')})
|
||||
print('\nPRECHECK '+('PASSED' if success else 'NEEDS REVIEW')+'; NOT a production release.')
|
||||
print('REPORT: '+str(reports/'REPORT.json'))
|
||||
for key,value in report.get('forecastSummary',{}).items():print(key,json.dumps(value))
|
||||
for blocker in report.get('dataBlockers',[]):print('DATA BLOCKER:',blocker)
|
||||
print('Existing containers, Symcon settings, timers and actuators unchanged.')
|
||||
return 0 if success else 1
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--plant',required=True,type=lambda v:str(UUID(v)))
|
||||
parser.add_argument('--show-commands',action='store_true')
|
||||
args=parser.parse_args()
|
||||
if args.show_commands:
|
||||
for name,command,cwd,timeout in command_list(args.plant):print(name,json.dumps(command))
|
||||
print('Native input probe: read-only docker compose exec forecast-engine python with reviewed stdin script')
|
||||
else:
|
||||
if os.geteuid()!=0:raise SystemExit('Run as root; do not alter Docker socket permissions.')
|
||||
try:raise SystemExit(run(args.plant))
|
||||
except (OSError,ValueError) as error:raise SystemExit('Preflight stopped safely: '+str(error))
|
||||
Reference in New Issue
Block a user