Files
Enelix-EMS/services/netplan-v4/acceptance/read_native_forecasts.py
T

101 lines
5.9 KiB
Python

"""Read-only probe executed INSIDE the existing forecast-engine container.
No get_configs() (it may migrate SQL), no training/prediction, no forecast writing,
no manual /run_now request, and no user credentials in output. Reads one plant's
stored raw 5-minute forecast values and the input frames used by the engine.
"""
import contextlib
from datetime import datetime, timezone
import hashlib
import io
import json
import math
import os
from pathlib import Path
import sqlite3
from urllib.parse import quote
from uuid import UUID
class Discard(io.TextIOBase):
def write(self,text):return len(text)
def clean_time(value):
if hasattr(value,'to_pydatetime'):value=value.to_pydatetime()
if value.tzinfo is None:value=value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat()
def run():
aid=str(UUID(os.environ['ENELIX_ACCEPTANCE_PLANT']))
os.environ['FORECAST_INFLUX_TIMEOUT_MS']='20000'
# These assignments affect only this diagnostic process, not the service.
with contextlib.redirect_stdout(Discard()),contextlib.redirect_stderr(Discard()):
import main
import numpy as np
import pandas as pd
path=Path(main.SQLITE_DB_PATH)
if not path.is_file():raise RuntimeError('Existing configuration database missing')
con=sqlite3.connect('file:'+quote(str(path))+'?mode=ro',uri=True)
con.row_factory=sqlite3.Row
try:row=con.execute('SELECT * FROM anlagen_meta WHERE anlagen_id=?',(aid,)).fetchone()
finally:con.close()
if row is None:raise RuntimeError('Installation not found')
cfg=dict(row)
cfg['daecher']=json.loads(cfg.get('daecher') or '[]')
for key,default in [('ac_leistung',10.),('batt_capacity_kwh',0.),('batt_power_kw',0.),('tarif_bezug_fest',.3),('tarif_einspeisung_fest',.1),('tarif_peak_fest',5.)]:
value=cfg.get(key);cfg[key]=float(default if value is None or value=='' else value)
data=main.build_data_object(cfg,training=False)
frames={}
for key in ('df_hist','df_recent_raw','df_load_training','df_fut'):
frame=data.get(key)
if frame is None:frames[key]={'available':False};continue
item={'rows':len(frame),'from':clean_time(frame.index.min()) if len(frame) else None,'until':clean_time(frame.index.max()) if len(frame) else None,'columns':{}}
for col in ('Hausverbrauch','PV','Netzleistung','SOC'):
if col not in frame.columns:continue
values=pd.to_numeric(frame[col],errors='coerce');valid=values[np.isfinite(values)]
item['columns'][col]={'finite':len(valid),'zeros':int((valid==0).sum()),'median':float(valid.median()) if len(valid) else None,'maximum':float(valid.max()) if len(valid) else None,'lastFiniteAt':clean_time(valid.index[-1]) if len(valid) else None,'recentValues':[{'time':clean_time(t),'value':float(v)} for t,v in valid.tail(12).items()]}
frames[key]=item
start=datetime.now(timezone.utc).replace(second=0,microsecond=0)
from datetime import timedelta
end=start+timedelta(hours=48)
fields=('prog_var_1','prog_var_2','prog_var_10','prog_var_11','prog_var_21','prog_var_22')
field_filter=' or '.join('r["_field"] == '+json.dumps(f) for f in fields)
query='''from(bucket: %s)
|> range(start: %s, stop: %s)
|> filter(fn: (r) => r["_measurement"] == "api_telemetry")
|> filter(fn: (r) => r["anlagen_id"] == %s)
|> filter(fn: (r) => r["data_type"] == "forecast")
|> filter(fn: (r) => %s)
|> keep(columns: ["_time", "_field", "_value"])
''' % (json.dumps(main.INFLUX_BUCKET),start.isoformat(),end.isoformat(),json.dumps(aid),field_filter)
client=main.InfluxDBClient(url=main.INFLUX_URL,token=main.INFLUX_TOKEN,org=main.INFLUX_ORG,timeout=20000)
series={field:[] for field in fields}
try:
tables=client.query_api().query(org=main.INFLUX_ORG,query=query)
for table in tables:
for record in table.records:
value=record.get_value();field=record.get_field()
if field in series and isinstance(value,(int,float)) and math.isfinite(value):
series[field].append({'time':clean_time(record.get_time()),'value':float(value)})
finally:client.close()
for field in series:series[field].sort(key=lambda p:p['time'])
versions={}
for relative in ('main.py','methods/var_1.py','methods/var_2.py','methods/var_10.py','methods/var_11.py','methods/var_21.py','methods/var_22.py'):
source=Path('/app')/relative
if source.is_file():versions[relative]=hashlib.sha256(source.read_bytes()).hexdigest()
summary={}
for field,points in series.items():
values=[p['value'] for p in points]
summary[field]={'points':len(values),'allZero':bool(values) and max(abs(v) for v in values)==0,'maximumW':max(values) if values else None,'from':points[0]['time'] if points else None,'until':points[-1]['time'] if points else None}
native=True
for points in series.values():
stamps=[datetime.fromisoformat(p['time']).timestamp() for p in points]
if not stamps or any(t%300 for t in stamps) or any(b-a!=300 for a,b in zip(stamps,stamps[1:])):native=False
return {'status':'read_only_acquired','installationId':aid,'observedAt':datetime.now(timezone.utc).isoformat(),'queryResolution':'raw_no_chart_resampling','nativeFiveMinuteForecast':native,'generationTimeVerified':False,'inputFrames':frames,'forecastSummary':summary,'forecastSeries':series,'runningSourceHashes':versions,'modelWrite':False,'liveControlChanged':False}
try:
result=run()
except Exception as error:
# Never echo exception details containing SQL payloads, credentials or URLs.
result={'status':'read_only_probe_failed','errorType':type(error).__name__,'liveControlChanged':False}
print(json.dumps(result,allow_nan=False))