109 lines
6.2 KiB
Python
109 lines
6.2 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 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__)
|