Files
lmxopcua/scripts/validation/aveva-history-801/aggregate_recompute.py
T
Joseph Doherty f607ee7502 test(historian): #801 real-AVEVA history gate over the SDK wire (env-gated)
AvevaGatewayHistoryLiveGateTests boots the server in-process, wires the production
Gateway backend (HistorianBackendRegistry -> GatewayHistorianDataSource) to a real
HistorianGateway and drives HistoryRead over a real SDK session: raw (all, paged,
reverse, bounds), modified, the ten served aggregates and seven refused ones, AtTime,
events (all, 1-event pages, reverse), a missing tag, HistoryServerCapabilities and
node metadata, and a HistoryUpdate that must be refused (sandbox-bound node only).
Every wire answer is compared with the backend's direct answer; the run is dumped as
JSON for cross-SDK and SQL comparison. Skips unless HISTGW_GATEWAY_ENDPOINT and
HISTGW_GATEWAY_APIKEY are set; read-only by construction.

scripts/validation/aveva-history-801: run/compare instructions, the SDK 1.5.378
baseline port patch (API spelling only), and the diff / SQL / aggregate / histgw-cli
comparison scripts.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 10:02:21 -05:00

26 lines
1.9 KiB
Python

# Independent recomputation of the simple per-interval aggregates from the wire raw rows (Good samples only, [t, t+60s)).
import json, sys, math
from datetime import datetime, timedelta
rep = json.load(open(sys.argv[1])); C = rep['Cases']
def ts(s): return datetime.fromisoformat(s.replace('Z', '+00:00'))
def get(name, tag, win): return next(c for c in C if c['Name'] == name and c['Tag'] == tag and c['Window'] == win and c['Path'] == 'wire')
checked = bad = 0
for raw in [c for c in C if c['Name'] == 'raw.all' and c['Path'] == 'wire' and not c['Tag'].startswith('OtOpcUa.')]:
tag, win = raw['Tag'], raw['Window']
samples = [(ts(r['Src']), float(r['V'])) for r in raw['Rows'] if r['V'] is not None and r['Status'] & 0xC0000000 == 0]
nongood = [ts(r['Src']) for r in raw['Rows'] if r['V'] is None or r['Status'] & 0xC0000000 != 0]
for agg in ['Count', 'Minimum', 'Maximum', 'Average', 'Range', 'Start', 'End', 'Delta']:
p = get(f'processed.{agg}', tag, win)
for row in p['Rows']:
w0 = ts(win.split('/')[0]); k = int((ts(row['Src']) - w0).total_seconds() // 60)
t0 = w0 + timedelta(seconds=60 * k); t1 = t0 + timedelta(seconds=60) # Start/End carry the sample's own time
xs = [v for (t, v) in samples if t0 <= t < t1]
if not xs or any(t0 <= t < t1 for t in nongood): continue # empty / outage intervals: status semantics are compared against SDK1, not recomputed here
exp = {'Count': len(xs), 'Minimum': min(xs), 'Maximum': max(xs), 'Average': sum(xs) / len(xs),
'Range': max(xs) - min(xs), 'Start': xs[0], 'End': xs[-1], 'Delta': xs[-1] - xs[0]}[agg]
got = float(str(row['V']).split(':')[-1])
checked += 1
if not math.isclose(got, exp, rel_tol=1e-12, abs_tol=1e-9):
bad += 1; print('MISMATCH', tag, win[:19], agg, row, 'expected', exp)
print(f'{checked} non-empty interval values recomputed from raw, {bad} mismatching')