"""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__)