Files
Enelix-EMS/services/netplan-v4/integrations/netplan_v4_publisher.py
T
dh 74a2d6c685
Tests / test (push) Successful in 1m1s
feat(workflow): publish verified forecast controls and consolidated docs
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.
2026-10-08 10:02:58 +00:00

140 lines
7.7 KiB
Python

"""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 UUID,uuid4
from urllib.parse import urlparse
from urllib.request import Request,urlopen
import json,logging,math,os,time
_enrollment_cache = {'key': None, 'checked': 0., 'plants': set()}
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 planning_plants():
"""Discover enrolled planning tenants; this registry has no actuator authority."""
base=os.getenv('NETPLAN_V4_URL','').rstrip('/');token=os.getenv('PROGNOSIS_SERVICE_TOKEN','')
if not base:return set()
explicit={p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()}
if not token:return explicit
url=urlparse(base)
if url.scheme not in ('http','https') or url.username or url.password:return explicit
key=(base,token);now=time.monotonic()
if _enrollment_cache['key']==key and now-_enrollment_cache['checked']<60:
return explicit|_enrollment_cache['plants']
plants=set()
try:
req=Request(base+'/internal/v2/planner/installations',headers={'X-Enelix-Service-Token':token})
with urlopen(req,timeout=5) as response:
raw=response.read(500001)
if len(raw)>500000:raise ValueError('Oversized enrollment response')
data=json.loads(raw)
values=data['installationIds']
if not isinstance(values,list) or len(values)>10000 or data.get('controlEnabled') is not False:
raise ValueError('Invalid planning enrollment response')
if any(not isinstance(p,str) or str(UUID(p))!=p for p in values):
raise ValueError('Invalid planning installation ID')
plants=set(values)
except Exception as exc:
logging.getLogger(__name__).warning('V4 planning enrollment unavailable: %s',type(exc).__name__)
_enrollment_cache.update(key=key,checked=now,plants=plants)
return explicit|plants
def enabled(plant):return plant in planning_plants()
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')
if str(UUID(plant))!=plant:raise ValueError('Canonical installation UUID required')
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=sorted(planning_plants())
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__)