246 lines
12 KiB
Python
246 lines
12 KiB
Python
"""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}
|