feat(application): integrate measured-load ingestion training and planner source
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user