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
+245
View File
@@ -0,0 +1,245 @@
"""Auditable import-energy evidence. Pure calculations; no device/database writes.
Raw change archives are NOT proof of uninterrupted meter communication. Boundaries
without an exact counter record yield intervals, never silently interpolated
billing facts. Only a separately verified source and chronology can be promoted
into the existing strict planner evidence contract.
"""
from __future__ import annotations
from bisect import bisect_left, bisect_right
from dataclasses import dataclass
from datetime import datetime, timedelta
import hashlib
import json
from math import isfinite
from .domain import utc, month_key, quarter_start, ZURICH
@dataclass(frozen=True)
class Reading:
at: datetime
kwh: float
class CounterSeries:
def __init__(self, source_id: str, rows, *, max_bracket_seconds=180,
quantity='active_import', unit='kWh', timestamp_verified=False,
identity_verified=False, chronology_verified=False):
if quantity != 'active_import' or unit != 'kWh':
raise ValueError('Active import energy in kWh required')
if not isinstance(source_id, str) or not source_id:
raise ValueError('Nonempty stable source identity required')
if type(max_bracket_seconds) is not int or max_bracket_seconds < 1:
raise ValueError('Positive bracket age required')
self.source_id = source_id
self.max_gap = max_bracket_seconds
self.verified = all(x is True for x in
(timestamp_verified, identity_verified, chronology_verified))
values = {}
for row in rows:
at = utc(row.at)
value = row.kwh
if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value) or value < 0:
raise ValueError('Invalid cumulative active import value')
if at in values and abs(values[at] - value) > 1e-9:
raise ValueError('Conflicting duplicate timestamp')
values[at] = float(value)
self.times = sorted(values)
self.values = [values[t] for t in self.times]
self.resets = [self.times[i] for i in range(1,len(self.times))
if self.values[i] < self.values[i-1] - 1e-9]
def boundary(self, at):
at = utc(at)
pos = bisect_left(self.times, at)
if pos < len(self.times) and self.times[pos] == at:
value = self.values[pos]
return {'lowerKwh':value, 'upperKwh':value, 'exactRecord':True,
'before':at.isoformat(), 'after':at.isoformat()}
if pos == 0 or pos == len(self.times):
return None
before, after = self.times[pos-1], self.times[pos]
if (after-before).total_seconds() > self.max_gap:
return None
if self.values[pos] < self.values[pos-1] - 1e-9:
return None
return {'lowerKwh':self.values[pos-1], 'upperKwh':self.values[pos],
'exactRecord':False, 'before':before.isoformat(), 'after':after.isoformat()}
def energy(self, start, end):
start, end = utc(start), utc(end)
if end <= start:
raise ValueError('Positive interval required')
left, right = self.boundary(start), self.boundary(end)
if not left or not right:
return None
support_start = utc(left['before'])
support_end = utc(right['after'])
if any(support_start < reset <= support_end for reset in self.resets):
return None
lower = max(0., right['lowerKwh'] - left['upperKwh'])
upper = right['upperKwh'] - left['lowerKwh']
if upper < lower - 1e-9:
return None
exact = bool(left['exactRecord'] and right['exactRecord'])
return {'lowerKwh':lower, 'upperKwh':max(lower, upper),
'exactRecords':exact, 'billingEvidence':exact and self.verified}
def native_meter_id(sources):
"""Same ordered JSON and SHA256 as NetzfahrplanV4Bezugszaehler::identitaet."""
import re
if not isinstance(sources,list) or not 1<=len(sources)<=8:
raise ValueError('Explicit native import sources required')
normalized=[];ids=set();idents=set();parents=set()
for source in sources:
if set(source)!={'VariableID','ElternID','Ident','FaktorZuKWh','Messgroesse'}:
raise ValueError('Unknown native source fields')
sid,pid,ident,factor=(source[k] for k in ('VariableID','ElternID','Ident','FaktorZuKWh'))
if (type(sid) is not int or sid<=0 or type(pid) is not int or pid<=0
or sid in ids or ident in idents or not isinstance(ident,str)
or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]{0,63}',ident)
or isinstance(factor,bool) or not isinstance(factor,(float,int))
or not isfinite(factor) or not 0<factor<=1e6
or source['Messgroesse']!='WirkenergieBezug'):
raise ValueError('Invalid or duplicate native source')
ids.add(sid);idents.add(ident);parents.add(pid)
normalized.append({'VariableID':sid,'ElternID':pid,'Ident':ident,
'FaktorZuKWh':float(factor),'Messgroesse':'WirkenergieBezug'})
if len(parents)!=1:raise ValueError('T1 and T2 must share physical meter')
normalized.sort(key=lambda s:s['VariableID'])
text=json.dumps(normalized,separators=(',',':'),ensure_ascii=True,allow_nan=False)
# PHP preserves .0 on scientific floats and omits zero-padding in exponents.
def exponent(match):
mantissa=match.group(1)
if '.' not in mantissa:mantissa+='.0'
power=int(match.group(2))
return '"FaktorZuKWh":'+mantissa+'e'+('+' if power>=0 else '')+str(power)
text=re.sub(r'"FaktorZuKWh":([0-9]+(?:\.[0-9]+)?)e([+-]?[0-9]+)',exponent,text)
return 'symcon-active-import:'+hashlib.sha256(text.encode()).hexdigest()
class MeterEvidence:
def __init__(self, series, *, native_sources=None):
self.series = list(series)
if not self.series or len({s.source_id for s in self.series}) != len(self.series):
raise ValueError('Unique, nonempty set of counter sources required')
self.native_bound = native_sources is not None
if self.native_bound:
self.meter_id=native_meter_id(native_sources)
if {s.source_id for s in self.series}!={'symcon:'+str(s['VariableID']) for s in native_sources}:
raise ValueError('Evidence stream does not match native source set')
else:
signature = json.dumps(sorted(s.source_id for s in self.series),separators=(',',':'))
self.meter_id = 'audit-only:' + hashlib.sha256(signature.encode()).hexdigest()
def interval(self, start, end):
start, end = utc(start), utc(end)
parts = [s.energy(start,end) for s in self.series]
missing = [s.source_id for s,p in zip(self.series,parts) if p is None]
if missing:
return {'status':'missing', 'start':start.isoformat(),'end':end.isoformat(),
'missingSources':missing, 'billingEvidence':False}
lo = sum(p['lowerKwh'] for p in parts)
hi = sum(p['upperKwh'] for p in parts)
seconds = (end-start).total_seconds()
exact = all(p['exactRecords'] for p in parts)
return {'status':'exact_records' if exact else 'bounded_records',
'start':start.isoformat(),'end':end.isoformat(),'lowerKwh':lo,'upperKwh':hi,
'lowerAverageKw':lo*3600/seconds,'upperAverageKw':hi*3600/seconds,
'billingEvidence':all(p['billingEvidence'] for p in parts)}
def month(self, at):
at = utc(at)
local = at.astimezone(ZURICH)
begin = utc(local.replace(day=1,hour=0,minute=0,second=0,microsecond=0))
stop = quarter_start(at)
count = int((stop-begin).total_seconds())//900
covered = exact = verified = 0
lo = hi = 0.
gaps = []
quarters = []
for n in range(count):
start = begin + timedelta(minutes=15*n)
result = self.interval(start,start+timedelta(minutes=15))
quarters.append(result)
if result['status'] == 'missing':
if len(gaps)<8:gaps.append(start.isoformat())
continue
covered += 1
exact += result['status']=='exact_records'
verified += result['billingEvidence']
lo = max(lo,result['lowerAverageKw'])
hi = max(hi,result['upperAverageKw'])
complete = count>0 and covered==count
certified = count>0 and verified==count
return {'meterId':self.meter_id,'month':month_key(at),'completedQuarters':count,
'coveredQuarters':covered,'exactRecordQuarters':exact,'verifiedQuarters':verified,
'historyComplete':complete,'billingEvidence':certified,
'observedPeakLowerKw':lo if covered else None,
'observedPeakUpperKw':hi if covered else None,
'monthPeakUpperKw':hi if complete else None,
'verifiedMonthPeakKw':hi if certified else None,
'firstMissingQuarters':gaps,'quarters':quarters}
def strict_contract(self, at):
"""Fail closed; do not promote archive gaps, estimated boundaries or a cap."""
at = utc(at)
if not self.native_bound:
raise ValueError('Explicit native counter identity required')
result = self.month(at)
if not result['billingEvidence']:
raise ValueError('Full verified month history missing')
q = quarter_start(at)
past = None
if at != q:
partial = self.interval(q,at)
if not partial['billingEvidence']:
raise ValueError('Verified elapsed-quarter energy missing')
past = {'start':q.isoformat(),'measuredSeconds':int((at-q).total_seconds()),
'importKwh':partial['upperKwh']}
return {'version':1,'meterId':self.meter_id,'measuredAt':at.isoformat(),
'measuredPeaks':{month_key(at):{'kw':result['verifiedMonthPeakKw'],
'source':'verified_month_history'}},
'quarterPast':past}
def audit_capture(payload):
"""Read-only report from the dedicated Symcon export, never planner ingestion."""
if payload.get('schemaVersion') != 1 or payload.get('kind') != 'v4_meter_capture':
raise ValueError('Unknown capture contract')
now = utc(payload['capturedAt'])
channels = payload['channels']
series = []
summaries = []
for variable in ('59607','26620'):
channel = channels[variable]
rows = channel.get('history',[])
readings = [Reading(utc(datetime.fromtimestamp(r['TimeStamp'],now.tzinfo)),r['Value']) for r in rows]
# An explicitly captured current value is usable from its capture, not
# from an earlier last-change timestamp, and never before logging began.
current = channel.get('snapshot')
if current and channel.get('snapshotConsistent'):
readings.append(Reading(utc(current['capturedAt']),current['value']))
s = CounterSeries('symcon:'+variable, readings, timestamp_verified=False,
identity_verified=False, chronology_verified=False)
series.append(s)
summaries.append({'variableId':int(variable),'ident':channel['ident'],
'logging':channel.get('logging'), 'rows':len(rows),
'queryComplete':channel.get('queryComplete',False),
'firstRecord':s.times[0].isoformat() if s.times else None,
'lastRecord':s.times[-1].isoformat() if s.times else None,
'counterDecreases':[t.isoformat() for t in s.resets]})
meter = MeterEvidence(series)
month = meter.month(now)
current = None
q = quarter_start(now)
if now>q:current=meter.interval(q,now)
blockers=['Meter register identity/unit and acquisition timestamps still require verification.',
'Change-only archive does not prove uninterrupted meter acquisition.']
if any(not s['queryComplete'] for s in summaries):blockers.append('Archive export incomplete; do not infer complete history.')
if not month['historyComplete']:blockers.append('At least one completed billing quarter lacks bounded readings for every tariff.')
return {'capturedAt':now.isoformat(),'status':'audit_only','sourceSummary':summaries,
'month':{k:v for k,v in month.items() if k!='quarters'},
'recentQuarters':month['quarters'][-8:], 'currentQuarter':current,
'billingEvidence':False, 'liveEnabled':False,'blockers':blockers}