"""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=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}