feat(application): integrate measured-load ingestion training and planner source
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
use InvalidArgumentException;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/** Pure protocol/control helper. No IPS calls, no device output. */
|
||||
final class NetzfahrplanV4
|
||||
{
|
||||
private static function stamp(string $value): int
|
||||
{
|
||||
if (!preg_match('/(?:Z|[+-]\d{2}:\d{2})$/', $value)) {
|
||||
throw new InvalidArgumentException('Explicit timezone required');
|
||||
}
|
||||
return (new DateTimeImmutable($value))->getTimestamp();
|
||||
}
|
||||
private static function number(mixed $value): float
|
||||
{
|
||||
if ((!is_float($value) && !is_int($value)) || !is_finite((float)$value)) {
|
||||
throw new InvalidArgumentException('Finite numeric value required');
|
||||
}
|
||||
return (float)$value;
|
||||
}
|
||||
public static function currentPoint(array $plan, int $now, int $revision, bool $shadowInspection=false): ?array
|
||||
{
|
||||
if (($plan['schemaVersion']??null)!==2 || ($plan['configRevision']??null)!==$revision
|
||||
|| ($plan['executable']??false)!==true || !is_array($plan['points']??null)
|
||||
|| !in_array($plan['status']??'', ['optimal','feasible_time_limit'], true)
|
||||
|| !is_string($plan['planId']??null) || $plan['planId']==='') return null;
|
||||
if ($shadowInspection ? ($plan['runMode']??'')!=='shadow'
|
||||
: (($plan['runMode']??'')!=='live' || ($plan['liveEnabled']??false)!==true)) return null;
|
||||
try {
|
||||
$age=$now-self::stamp($plan['generatedAt']);
|
||||
if ($age < -30 || $age>900 || self::stamp($plan['validFrom'])>$now || self::stamp($plan['validUntil'])<=$now) return null;
|
||||
$selected=null; $previousEnd=null;
|
||||
foreach ($plan['points'] as $p) {
|
||||
$start=self::stamp($p['time']); $end=self::stamp($p['validUntil']);
|
||||
self::number($p['gridTargetW']);
|
||||
if ($end<=$start || $end-$start>300 || ($previousEnd!==null && $previousEnd!==$start)) return null;
|
||||
$previousEnd=$end;
|
||||
if ($start<=$now && $now<$end) $selected=$p;
|
||||
}
|
||||
return $selected;
|
||||
} catch (\Throwable $e) { return null; }
|
||||
}
|
||||
public static function correction(array $point, float $grid, float $battery, float $chargeAvailable, float $dischargeAvailable, ?float $hardImportLimit=null): array
|
||||
{
|
||||
foreach ([$grid,$battery,$chargeAvailable,$dischargeAvailable] as $v) self::number($v);
|
||||
if ($chargeAvailable<0 || $dischargeAvailable<0) throw new InvalidArgumentException('Invalid available power');
|
||||
$target=self::number($point['gridTargetW']??null);
|
||||
if ($hardImportLimit!==null) {
|
||||
if (self::number($hardImportLimit)<0) throw new InvalidArgumentException('Negative hard limit');
|
||||
$target=min($target,$hardImportLimit);
|
||||
}
|
||||
$raw=$battery+$target-$grid;
|
||||
if (abs($target)>1) {
|
||||
$raw=match ($point['intent']??'') {
|
||||
'gridCharge'=>max(0.,$raw),
|
||||
'pvCharge'=>max(0.,min($battery-$grid,$raw)),
|
||||
'discharge','export'=>min(0.,$raw),
|
||||
'hold'=>0.,
|
||||
default=>throw new InvalidArgumentException('Unknown plan intent'),
|
||||
};
|
||||
}
|
||||
if ($hardImportLimit!==null) $raw=min($raw,$battery+$hardImportLimit-$grid);
|
||||
$wanted=min($chargeAvailable,max(-$dischargeAvailable,$raw));
|
||||
$expectedGrid=$grid+$wanted-$battery;
|
||||
return ['batteryTargetW'=>$wanted,'gridTargetW'=>$target,'limited'=>abs($wanted-$raw)>1,
|
||||
'expectedGridW'=>$expectedGrid,'trackingErrorW'=>$expectedGrid-$target];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Scoped opt-in proxy. Never intercept the existing V1 schedule. */
|
||||
export function createPlannerV4Bridge({configuredPrognosisPlant,roleAllowed,bodyJson,json,
|
||||
deviceActivation,checkDeviceRate,ownedLicenseState,serviceToken,
|
||||
upstreamUrl='http://netplan-v4:9100',fetchImpl=fetch}){
|
||||
const base=new URL(upstreamUrl);
|
||||
if(!['http:','https:'].includes(base.protocol)||base.username||base.password)throw new Error('Invalid V4 URL');
|
||||
async function forward(req,res,installation,suffix,method){
|
||||
if(!serviceToken){json(res,503,{error:'V4 ist nicht angebunden.'});return;}
|
||||
const url=new URL(`/internal/v2/prognosis/${encodeURIComponent(installation)}/planner${suffix}`,base);
|
||||
let response;
|
||||
const payload=method==='GET'?undefined:await bodyJson(req);
|
||||
try{response=await fetchImpl(url,{method,headers:{'X-Enelix-Service-Token':serviceToken,'Content-Type':'application/json'},body:payload===undefined?undefined:JSON.stringify(payload),signal:AbortSignal.timeout(10000),redirect:'error'});}
|
||||
catch{json(res,503,{error:'V4 nicht erreichbar. Bestehende Regelung bleibt unveraendert.'});return;}
|
||||
const data=await response.json().catch(()=>null);
|
||||
if(!response.ok){const code=[400,401,403,404,409,413].includes(response.status)?response.status:503;json(res,code,{error:code===409?'Versionskonflikt: Ansicht neu laden.':'V4-Anfrage abgelehnt oder Dienst nicht verfuegbar.'});return;}
|
||||
if(!data||typeof data!=='object'){json(res,503,{error:'Ungueltige V4-Antwort.'});return;}
|
||||
json(res,200,data);
|
||||
}
|
||||
return async function(req,res,url){
|
||||
const c=url.pathname.match(/^\/api\/plants\/([0-9a-f-]{36})\/prognosis\/planner-v4(?:\/(settings|replan))?$/i);
|
||||
if(c){
|
||||
const action=c[2]||'',method={'':'GET',settings:'PUT',replan:'POST'}[action];
|
||||
if(req.method!==method){json(res,405,{error:'Methode nicht erlaubt.'});return true;}
|
||||
const access=configuredPrognosisPlant(req,res,c[1],method!=='GET');if(!access)return true;
|
||||
if(Number(access.license.quantities.grid_schedule||0)<1){json(res,403,{error:'Netzfahrplan nicht lizenziert.'});return true;}
|
||||
if(method!=='GET'&&!roleAllowed(access.session,['owner','admin','operator'])){json(res,403,{error:'Keine Aenderungsberechtigung.'});return true;}
|
||||
await forward(req,res,access.plant.installation_id,action?`/${action}`:'',method);return true;
|
||||
}
|
||||
const d=url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/planner-v4(?:\/(operation|ack|measurements))?$/i);
|
||||
if(d){
|
||||
const action=d[2]||'';if(req.method!==(action?'POST':'GET')){json(res,405,{error:'Methode nicht erlaubt.'});return true;}
|
||||
if(!checkDeviceRate(req,d[1],'planner-v4')){json(res,429,{error:'Zu viele Anfragen.'});return true;}
|
||||
const activation=deviceActivation(req,res,d[1]);if(!activation)return true;
|
||||
if(Number(ownedLicenseState(activation.plant_id).quantities.grid_schedule||0)<1){json(res,403,{error:'Netzfahrplan nicht lizenziert.'});return true;}
|
||||
await forward(req,res,d[1],action==='operation'?'/inputs/operation':action==='measurements'?'/measurements':action?'/ack':'',req.method);return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -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__)
|
||||
Reference in New Issue
Block a user