feat(application): integrate measured-load ingestion training and planner source

This commit is contained in:
ENELIX Agent
2026-10-02 21:00:05 +00:00
parent 44362e4bf5
commit fc024fcfba
96 changed files with 8933 additions and 0 deletions
@@ -0,0 +1,108 @@
"""Stdlib-only, explicit opt-in publisher for the existing ENELIX services.
No credentials are printed. Existing V1 computation survives shadow-service errors.
Legacy UTC-naive forecast indices are explicitly interpreted as UTC here.
"""
from datetime import datetime,timezone
from hashlib import sha256
from uuid import uuid4
from urllib.parse import urlparse
from urllib.request import Request,urlopen
import json,logging,math,os
def timestamp(value):
if hasattr(value,'to_pydatetime'):value=value.to_pydatetime()
if isinstance(value,str):value=datetime.fromisoformat(value.replace('Z','+00:00'))
if value.tzinfo is None:value=value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat()
def tariff_id(label):
if not isinstance(label,str) or not label.strip():raise ValueError('Exact tariff label required')
return 'legacy:'+sha256(label.encode()).hexdigest()[:32]
def envelope(at=None):return {'version':1,'eventId':str(uuid4()),'observedAt':timestamp(at or datetime.now(timezone.utc))}
def tariff_payload(config,at=None):
result=envelope(at)
for side,prefix in (('import','tarif_bezug'),('export','tarif_einspeisung')):
mode={'statisch':'static','dynamisch':'dynamic','static':'static','dynamic':'dynamic'}.get(config.get(prefix+'_modus'))
if mode is None:raise ValueError('Explicit tariff mode required; not inferred from name')
item={'mode':mode,'tariffId':tariff_id(config[prefix])}
if mode=='static':
value=config.get(prefix+'_fest')
if value is None or isinstance(value,bool) or not math.isfinite(float(value)):raise ValueError('Static price missing/invalid')
item['staticChfKwh']=float(value)
result[side]=item
peak=config.get('tarif_peak_fest')
if peak is None or isinstance(peak,bool) or not math.isfinite(float(peak)) or float(peak)<0:raise ValueError('Explicit peak price required')
result['peakChfKwMonth']=float(peak)
return result
def forecast_payload(forecasts,at=None,load_basis='house_total',trained_until=None):
result=envelope(at);result['families']={}
for key,pv,load in forecasts:
if not pv or not load:continue
if set(pv)!=set(load):raise ValueError('PV/load timestamps differ')
result['families'][str(key)]={'loadBasis':load_basis,'trainedUntil':trained_until,
'points':[{'time':timestamp(t),'pvW':float(pv[t]),'loadW':float(load[t])} for t in sorted(pv)]}
if not result['families']:raise ValueError('No forecast families supplied')
return result
def ckw_payload(label,rows,publication_timestamp=None,at=None):
"""Provider-delimited integrated price intervals only. No scalar price replication."""
result=envelope(at);periods=[]
units={'CHF_kWh':'CHF/kWh','CHF/kWh':'CHF/kWh','Rp/kWh':'Rp/kWh','CHF/MWh':'CHF/MWh'}
for row in rows:
if not row.get('start_timestamp') or not row.get('end_timestamp'):raise ValueError('Explicit delivery start/end required')
integrated=row.get('integrated')
if isinstance(integrated,list):
if len(integrated)!=1:raise ValueError('Ambiguous integrated price components')
integrated=integrated[0]
if not isinstance(integrated,dict) or integrated.get('unit') not in units:raise ValueError('Provider-declared unit required')
value=integrated.get('value')
if value is None or isinstance(value,bool) or not math.isfinite(float(value)):raise ValueError('Finite integrated price required')
item={'tariffId':tariff_id(label),'side':'import','start':timestamp(row['start_timestamp']),
'end':timestamp(row['end_timestamp']),'value':float(value),'unit':units[integrated['unit']],
'observedAt':result['observedAt'],'sourceKind':'published_interval'}
if publication_timestamp:
published=timestamp(publication_timestamp)
if datetime.fromisoformat(published)>datetime.fromisoformat(result['observedAt']):raise ValueError('Future publication')
item['publishedAt']=published
periods.append(item)
result['periods']=periods
return result
def enabled(plant):return bool(os.getenv('NETPLAN_V4_URL') and plant in {p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()})
def send(plant,kind,payload):
if not enabled(plant):return {'status':'disabled'}
token=os.getenv('PROGNOSIS_SERVICE_TOKEN','');base=os.environ['NETPLAN_V4_URL'].rstrip('/');url=urlparse(base)
if not token or url.scheme not in ('http','https') or url.username or url.password:raise ValueError('Private V4 transport not configured')
UUID=__import__('uuid').UUID;UUID(plant)
if kind not in ('forecast','tariffs','prices'):raise ValueError('Unsupported publisher input kind')
req=Request(base+'/internal/v2/prognosis/'+plant+'/planner/inputs/'+kind,
json.dumps(payload,allow_nan=False).encode(),{'Content-Type':'application/json','X-Enelix-Service-Token':token},method='POST')
with urlopen(req,timeout=5) as response:
data=response.read(65537)
if len(data)>65536:raise ValueError('Oversized service response')
return json.loads(data)
def publish_forecasts(config,forecasts):
plant=config['anlagen_id']
if not enabled(plant):return
try:
send(plant,'tariffs',tariff_payload(config));send(plant,'forecast',forecast_payload(forecasts))
except Exception as exc:logging.getLogger(__name__).warning('V4 shadow forecast for %s: %s',plant,type(exc).__name__)
def publish_tariffs(plant,config):
if not enabled(plant):return
try:send(plant,'tariffs',tariff_payload(config))
except Exception as exc:logging.getLogger(__name__).warning('V4 shadow tariffs for %s: %s',plant,type(exc).__name__)
def publish_ckw(label,rows,publication_timestamp=None,tariff_type='integrated'):
plants=[p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()]
if not os.getenv('NETPLAN_V4_URL') or not plants:return
try:
if tariff_type!='integrated':raise ValueError('Full integrated tariff required')
payload=ckw_payload(label,rows,publication_timestamp)
for plant in plants:send(plant,'prices',payload)
except Exception as exc:logging.getLogger(__name__).warning('V4 CKW price provenance: %s',type(exc).__name__)