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
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# #801 real-AVEVA history gate (SDK 2.0.1 vs 1.5.378.182)
|
||||
|
||||
Harness: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs`.
|
||||
It boots the OtOpcUa server in-process (`ServerEventRig`), wires the **production** Gateway backend
|
||||
(`HistorianBackendRegistry` → `GatewayHistorianDataSource`) to a real HistorianGateway, and drives HistoryRead /
|
||||
HistoryUpdate over a real SDK client session. It skips unless `HISTGW_GATEWAY_ENDPOINT` and `HISTGW_GATEWAY_APIKEY`
|
||||
are set. It is read-only: the Gateway backend has no updater, and the only HistoryUpdate goes to a node bound to
|
||||
`HISTGW_WRITE_SANDBOX_TAG` and is refused by the server before any backend call.
|
||||
|
||||
Record of the 2026-11-12 run: [`docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md`](../../../docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md).
|
||||
|
||||
## Run (SDK 2, this tree)
|
||||
|
||||
```bash
|
||||
export HISTGW_GATEWAY_ENDPOINT=http://wonder-app-vd03.zmr.zimmer.com:5221
|
||||
export HISTGW_GATEWAY_APIKEY="$(cat /path/to/key-file)" # never on the command line
|
||||
export HISTGW_WRITE_SANDBOX_TAG=HistGW.LiveTest.Sandbox
|
||||
export OTOPCUA_AVEVA_GATE_OUT=/tmp/aveva-gate OTOPCUA_AVEVA_GATE_LABEL=sdk2-$(git rev-parse --short HEAD)
|
||||
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests -c Release \
|
||||
--filter "FullyQualifiedName~AvevaGatewayHistoryLiveGate"
|
||||
```
|
||||
|
||||
## Baseline (SDK 1.5.378.182)
|
||||
|
||||
```bash
|
||||
git worktree add --detach ../otopcua-sdk1 efb9197a # master's first parent before the SDK 2 merge b36e688f
|
||||
cp tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs \
|
||||
../otopcua-sdk1/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/
|
||||
(cd ../otopcua-sdk1 && patch -p1 < "$OLDPWD/scripts/validation/aveva-history-801/sdk1-baseline-port.patch")
|
||||
# then the same dotnet test command in ../otopcua-sdk1 with OTOPCUA_AVEVA_GATE_LABEL=sdk1-efb9197a
|
||||
```
|
||||
|
||||
`sdk1-baseline-port.patch` changes only SDK API spelling: collections in place of `ArrayOf`, `byte[]` in place of `ByteString`,
|
||||
`DataValue.Value` in place of `ClrValue()`. The queries, windows and comparisons are unchanged.
|
||||
|
||||
## Compare
|
||||
|
||||
```bash
|
||||
python3 -I diff_runs.py $OUT/aveva-gate-sdk2-*.json $OUT/aveva-gate-sdk1-efb9197a.json # every case, row and event
|
||||
python3 -I sql_raw_check.py $OUT/aveva-gate-sdk2-*.json # raw rows vs Runtime.dbo.History (Full, UTC); needs
|
||||
# HISTORIAN_SQL_USER / HISTORIAN_SQL_PASSWORD, read-only
|
||||
python3 -I aggregate_recompute.py $OUT/aveva-gate-sdk2-*.json # simple aggregates recomputed from the raw rows
|
||||
python3 -I hgcli_raw_check.py $OUT/aveva-gate-sdk2-*.json $OUT # vs histgw-cli read-raw output (hgcli-<tag>-<date>.jsonl)
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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')
|
||||
@@ -0,0 +1,28 @@
|
||||
import json, sys, re
|
||||
a = json.load(open(sys.argv[1])); b = json.load(open(sys.argv[2]))
|
||||
def key(c): return (c['Name'], c['Tag'], c['Window'], c['Path'])
|
||||
A = {key(c): c for c in a['Cases']}; B = {key(c): c for c in b['Cases']}
|
||||
print(f"A={a['Label']} sdk={a['SdkServerAssembly']} cases={len(A)} failures={len(a['Failures'])}")
|
||||
print(f"B={b['Label']} sdk={b['SdkServerAssembly']} cases={len(B)} failures={len(b['Failures'])}")
|
||||
only = set(A) ^ set(B)
|
||||
if only: print("cases only in one run:", sorted(only, key=str))
|
||||
diffs = 0; rows = 0; events = 0; same = 0
|
||||
def norm_note(n): return re.sub(r'elapsedMs=\d+', 'elapsedMs=*', n or '')
|
||||
for k in sorted(set(A) & set(B), key=str):
|
||||
x, y = A[k], B[k]; d = []
|
||||
if x['Status'] != y['Status']: d.append(f"status {x['Status']} vs {y['Status']}")
|
||||
if norm_note(x['Note']) != norm_note(y['Note']): d.append(f"note {x['Note']} vs {y['Note']}")
|
||||
if (x['Attributes'] or {}) != (y['Attributes'] or {}): d.append(f"attrs {x['Attributes']} vs {y['Attributes']}")
|
||||
xr, yr = x['Rows'] or [], y['Rows'] or []
|
||||
rows += len(xr)
|
||||
if len(xr) != len(yr): d.append(f"rows {len(xr)} vs {len(yr)}")
|
||||
else:
|
||||
for i, (p, q) in enumerate(zip(xr, yr)):
|
||||
if (p['V'], p['Status'], p['Src'], p['Srv'] is None) != (q['V'], q['Status'], q['Src'], q['Srv'] is None):
|
||||
d.append(f"row[{i}] {p} vs {q}"); break
|
||||
xe, ye = x['Events'] or [], y['Events'] or []
|
||||
events += len(xe)
|
||||
if xe != ye: d.append(f"events {len(xe)} vs {len(ye)}")
|
||||
if d: diffs += 1; print("DIFF", k, "; ".join(d)[:500])
|
||||
else: same += 1
|
||||
print(f"compared {same+diffs} cases: identical={same} differing={diffs}; rows compared={rows}, events compared={events} (ServerTimestamp compared for presence only: it is the read time)")
|
||||
@@ -0,0 +1,19 @@
|
||||
# Compares the gate's raw.all wire rows with HistorianGateway's own smoke CLI (`histgw-cli read-raw`, JSON lines),
|
||||
# an independent gRPC client. Usage: hgcli_raw_check.py <aveva-gate-*.json> <dir with hgcli-*.jsonl>
|
||||
import json, sys, glob, os
|
||||
from datetime import datetime
|
||||
rep = json.load(open(sys.argv[1]))
|
||||
def ts(s): return datetime.fromisoformat(s.replace('Z', '+00:00').replace('0000000+', '+'))
|
||||
for f in sorted(glob.glob(os.path.join(sys.argv[2], 'hgcli-*.jsonl'))):
|
||||
rows = [json.loads(l) for l in open(f)]
|
||||
tag, start = rows[0]['tag'], rows[0]['timestamp'][:19]
|
||||
c = next(c for c in rep['Cases'] if c['Name'] == 'raw.all' and c['Path'] == 'wire' and c['Tag'] == tag and c['Window'].startswith(start))
|
||||
w0, w1 = (ts(x) for x in c['Window'].split('/'))
|
||||
# AVEVA's synthetic Quality-133 row at the query start is not a stored sample (#687); the gateway's end is inclusive,
|
||||
# OtOpcUa's exclusive.
|
||||
stored = [r for r in rows if not (r['quality'] == 133 and ts(r['timestamp']) == w0) and ts(r['timestamp']) < w1]
|
||||
n = min(len(stored), len(c['Rows']))
|
||||
ok = all(abs((ts(a['timestamp']) - ts(b['Src'])).total_seconds()) < 5e-4 and float(a['value']) == float(b['V'])
|
||||
for a, b in zip(stored[:n], c['Rows'][:n]))
|
||||
short = ' (histgw-cli stopped at the stored NULL: its JSON writer cannot emit NaN)' if len(stored) < len(c['Rows']) else ''
|
||||
print(f"{tag:<16} {start} histgw-cli={len(stored)} wire={len(c['Rows'])} compared={n} {'MATCH' if ok else 'MISMATCH'}{short}")
|
||||
@@ -0,0 +1,211 @@
|
||||
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs
|
||||
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs
|
||||
@@ -15,7 +15,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
-/// Gitea #801 (SDK 2 live gate, "real AVEVA history"): the booted OtOpcUa server, with the <b>production</b>
|
||||
+/// Gitea #801 BASELINE PORT (SDK 1.5.378.182, master efb9197a) (SDK 2 live gate, "real AVEVA history"): the booted OtOpcUa server, with the <b>production</b>
|
||||
/// Gateway backend (<see cref="HistorianBackendRegistry"/> → <c>GatewayHistorianDataSource</c>) pointed at a REAL
|
||||
/// HistorianGateway in front of the real AVEVA Historian, serves HistoryRead over a real SDK client session.
|
||||
/// Every wire answer is compared with the same backend's direct <see cref="IHistorianDataSource"/> answer
|
||||
@@ -283,11 +283,11 @@
|
||||
{
|
||||
var t = DateTime.UtcNow.AddHours(-1);
|
||||
var insert = new UpdateDataDetails { NodeId = Node(sandboxNode), PerformInsertReplace = PerformUpdateType.Insert };
|
||||
- insert.UpdateValues += new DataValue(Variant.From(801.0), StatusCodes.Good, t, t);
|
||||
+ insert.UpdateValues.Add(new DataValue(new Variant(801.0), StatusCodes.Good, t, t));
|
||||
var del = new DeleteRawModifiedDetails { NodeId = Node(sandboxNode), IsDeleteModified = false, StartTime = t, EndTime = t.AddSeconds(1) };
|
||||
foreach (var (name, details) in new (string, IEncodeable)[] { ("insert", insert), ("deleteRaw", del) })
|
||||
{
|
||||
- var response = await session.HistoryUpdateAsync(null, new ExtensionObject[] { new(details) }, ct);
|
||||
+ var response = await session.HistoryUpdateAsync(null, new ExtensionObjectCollection { new ExtensionObject(details) }, ct);
|
||||
var result = response.Results.ToList().ShouldHaveSingleItem();
|
||||
var status = Name(result.StatusCode);
|
||||
report.Add(new Case($"historyUpdate.{name}", sandboxTag, null, "wire", status,
|
||||
@@ -316,7 +316,7 @@
|
||||
{
|
||||
StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = false, ReturnBounds = returnBounds,
|
||||
};
|
||||
- var (status, rows, cp) = await HistoryReadAsync(session, details, node, default, ct);
|
||||
+ var (status, rows, cp) = await HistoryReadAsync(session, details, node, null, ct);
|
||||
await ReleaseAsync(session, details, node, cp, ct);
|
||||
return new WireRead(status, rows, 1);
|
||||
}
|
||||
@@ -330,7 +330,7 @@
|
||||
};
|
||||
var all = new List<Row>();
|
||||
var pages = 0;
|
||||
- ByteString cp = default;
|
||||
+ byte[]? cp = null;
|
||||
string status;
|
||||
do
|
||||
{
|
||||
@@ -339,14 +339,14 @@
|
||||
pages++;
|
||||
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
|
||||
}
|
||||
- while (!cp.IsEmpty && pages < 10_000);
|
||||
+ while (cp is { Length: > 0 } && pages < 10_000);
|
||||
return new WireRead(status, all, pages);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadModifiedAsync(ISession session, NodeId node, DateTime start, DateTime end, CancellationToken ct)
|
||||
{
|
||||
var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = 0, IsReadModified = true, ReturnBounds = false };
|
||||
- var (status, _, cp) = await HistoryReadAsync(session, details, node, default, ct);
|
||||
+ var (status, _, cp) = await HistoryReadAsync(session, details, node, null, ct);
|
||||
await ReleaseAsync(session, details, node, cp, ct);
|
||||
return status;
|
||||
}
|
||||
@@ -357,12 +357,12 @@
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = start, EndTime = end, ProcessingInterval = IntervalMs,
|
||||
- AggregateType = new List<NodeId> { aggregate },
|
||||
+ AggregateType = new NodeIdCollection { aggregate },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
};
|
||||
var all = new List<Row>();
|
||||
var pages = 0;
|
||||
- ByteString cp = default;
|
||||
+ byte[]? cp = null;
|
||||
string status;
|
||||
do
|
||||
{
|
||||
@@ -371,36 +371,36 @@
|
||||
pages++;
|
||||
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
|
||||
}
|
||||
- while (!cp.IsEmpty && pages < 1000);
|
||||
+ while (cp is { Length: > 0 } && pages < 1000);
|
||||
return new WireRead(status, all, pages);
|
||||
}
|
||||
|
||||
private static async Task<WireRead> ReadAtTimeAsync(ISession session, NodeId node, IReadOnlyList<DateTime> times, CancellationToken ct)
|
||||
{
|
||||
- var details = new ReadAtTimeDetails { ReqTimes = times.Select(t => (DateTimeUtc)t).ToList(), UseSimpleBounds = true };
|
||||
- var (status, rows, cp) = await HistoryReadAsync(session, details, node, default, ct);
|
||||
+ var details = new ReadAtTimeDetails { ReqTimes = new DateTimeCollection(times), UseSimpleBounds = true };
|
||||
+ var (status, rows, cp) = await HistoryReadAsync(session, details, node, null, ct);
|
||||
await ReleaseAsync(session, details, node, cp, ct);
|
||||
return new WireRead(status, rows, 1);
|
||||
}
|
||||
|
||||
- private static async Task<(string Status, List<Row> Rows, ByteString Cp)> HistoryReadAsync(
|
||||
- ISession session, IEncodeable details, NodeId node, ByteString cp, CancellationToken ct)
|
||||
+ private static async Task<(string Status, List<Row> Rows, byte[]? Cp)> HistoryReadAsync(
|
||||
+ ISession session, IEncodeable details, NodeId node, byte[]? cp, CancellationToken ct)
|
||||
{
|
||||
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
||||
- new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
+ new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
var result = response.Results.ToList().ShouldHaveSingleItem();
|
||||
var rows = new List<Row>();
|
||||
- if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryData data)
|
||||
+ if (result.HistoryData?.Body is HistoryData data)
|
||||
rows.AddRange(data.DataValues.ToList().Select(dv => new Row(
|
||||
- Fmt(dv.ClrValue()), dv.StatusCode.Code, Ts(dv.SourceTimestamp), Ts(dv.ServerTimestamp))));
|
||||
+ Fmt(dv.Value), dv.StatusCode.Code, Ts(dv.SourceTimestamp), Ts(dv.ServerTimestamp))));
|
||||
return (Name(result.StatusCode), rows, result.ContinuationPoint);
|
||||
}
|
||||
|
||||
- private static async Task ReleaseAsync(ISession session, IEncodeable details, NodeId node, ByteString cp, CancellationToken ct)
|
||||
+ private static async Task ReleaseAsync(ISession session, IEncodeable details, NodeId node, byte[]? cp, CancellationToken ct)
|
||||
{
|
||||
- if (cp.IsEmpty) return;
|
||||
+ if (cp is not { Length: > 0 }) return;
|
||||
await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true,
|
||||
- new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
+ new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
}
|
||||
|
||||
private static async Task<EventRead> ReadEventsAsync(ISession session, NodeId node, DateTime start, DateTime end, uint page,
|
||||
@@ -408,33 +408,33 @@
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
foreach (var field in new[] { "EventId", "Time", "ReceiveTime", "SourceName", "Message", "Severity" })
|
||||
- filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(field)) { AttributeId = Attributes.Value };
|
||||
+ filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(field)) { AttributeId = Attributes.Value });
|
||||
var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = page, Filter = filter };
|
||||
var rows = new List<EventRow>();
|
||||
var pages = 0;
|
||||
- ByteString cp = default;
|
||||
+ byte[]? cp = null;
|
||||
string status;
|
||||
do
|
||||
{
|
||||
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
||||
- new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
+ new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
var result = response.Results.ToList().ShouldHaveSingleItem();
|
||||
status = Name(result.StatusCode);
|
||||
cp = result.ContinuationPoint;
|
||||
pages++;
|
||||
- if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he)
|
||||
+ if (result.HistoryData?.Body is HistoryEvent he)
|
||||
foreach (var e in he.Events.ToList())
|
||||
{
|
||||
var f = e.EventFields.ToList();
|
||||
rows.Add(new EventRow(
|
||||
- f[0].ClrValue() is byte[] id ? System.Text.Encoding.UTF8.GetString(id) : Fmt(f[0].ClrValue()),
|
||||
- f[1].ClrValue() as DateTime? ?? default, f[2].ClrValue() as DateTime? ?? default,
|
||||
- f[3].ClrValue() as string, f[4].ClrValue() is LocalizedText lt ? lt.Text : Fmt(f[4].ClrValue()),
|
||||
- Convert.ToUInt16(f[5].ClrValue() ?? 0, CultureInfo.InvariantCulture)));
|
||||
+ f[0].Value is byte[] id ? System.Text.Encoding.UTF8.GetString(id) : Fmt(f[0].Value),
|
||||
+ f[1].Value as DateTime? ?? default, f[2].Value as DateTime? ?? default,
|
||||
+ f[3].Value as string, f[4].Value is LocalizedText lt ? lt.Text : Fmt(f[4].Value),
|
||||
+ Convert.ToUInt16(f[5].Value ?? 0, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
|
||||
}
|
||||
- while (!cp.IsEmpty && pages < 10_000);
|
||||
+ while (cp is { Length: > 0 } && pages < 10_000);
|
||||
return new EventRead(status, rows, pages);
|
||||
}
|
||||
|
||||
@@ -446,9 +446,9 @@
|
||||
("UserAccessLevel", Attributes.UserAccessLevel), ("DataType", Attributes.DataType),
|
||||
};
|
||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither,
|
||||
- ids.Select(a => new ReadValueId { NodeId = node, AttributeId = a.Id }).ToArray(), ct);
|
||||
+ new ReadValueIdCollection(ids.Select(a => new ReadValueId { NodeId = node, AttributeId = a.Id })), ct);
|
||||
var results = response.Results.ToList();
|
||||
- return ids.Select((a, i) => (a.Name, Value: Fmt(results[i].ClrValue())))
|
||||
+ return ids.Select((a, i) => (a.Name, Value: Fmt(results[i].Value)))
|
||||
.ToDictionary(x => x.Name, x => x.Value);
|
||||
}
|
||||
|
||||
@@ -460,9 +460,9 @@
|
||||
if (vars.Count > 0)
|
||||
{
|
||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither,
|
||||
- vars.Select(r => new ReadValueId { NodeId = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris), AttributeId = Attributes.Value }).ToArray(), ct);
|
||||
+ new ReadValueIdCollection(vars.Select(r => new ReadValueId { NodeId = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris), AttributeId = Attributes.Value })), ct);
|
||||
var results = response.Results.ToList();
|
||||
- for (var i = 0; i < vars.Count; i++) caps[vars[i].BrowseName.Name!] = Fmt(results[i].ClrValue());
|
||||
+ for (var i = 0; i < vars.Count; i++) caps[vars[i].BrowseName.Name!] = Fmt(results[i].Value);
|
||||
}
|
||||
var aggs = await BrowseAsync(session, ObjectIds.HistoryServerCapabilities_AggregateFunctions, ReferenceTypeIds.Organizes, ct);
|
||||
caps["AggregateFunctions"] = string.Join(",", aggs.Select(r => r.BrowseName.Name).OrderBy(n => n, StringComparer.Ordinal));
|
||||
@@ -472,9 +472,9 @@
|
||||
private static async Task<List<ReferenceDescription>> BrowseAsync(ISession session, NodeId node, NodeId referenceType, CancellationToken ct)
|
||||
{
|
||||
var response = await session.BrowseAsync(null, null, 0,
|
||||
- new BrowseDescription[]
|
||||
+ new BrowseDescriptionCollection
|
||||
{
|
||||
- new()
|
||||
+ new BrowseDescription
|
||||
{
|
||||
NodeId = node, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = referenceType,
|
||||
IncludeSubtypes = true, NodeClassMask = 0, ResultMask = (uint)BrowseResultMask.All,
|
||||
@@ -483,7 +483,7 @@
|
||||
return response.Results[0].References.ToList();
|
||||
}
|
||||
|
||||
- private static DateTime? Ts(DateTimeUtc t) => (DateTime)t == DateTime.MinValue ? null : DateTime.SpecifyKind((DateTime)t, DateTimeKind.Utc);
|
||||
+ private static DateTime? Ts(DateTime t) => t == DateTime.MinValue ? null : DateTime.SpecifyKind(t, DateTimeKind.Utc);
|
||||
|
||||
private static string Name(StatusCode code) =>
|
||||
code.SymbolicId is { Length: > 0 } s ? s : $"0x{code.Code:X8}";
|
||||
@@ -0,0 +1,41 @@
|
||||
# Compares the gate's raw.all wire rows with direct read-only SQL on the AVEVA Runtime DB (History, Full, UTC).
|
||||
import json, sys, os, subprocess, math
|
||||
from datetime import datetime, timezone
|
||||
rep = json.load(open(sys.argv[1]))
|
||||
def sql(q):
|
||||
env = dict(os.environ, SQLCMDPASSWORD=os.environ['HISTORIAN_SQL_PASSWORD'])
|
||||
out = subprocess.run(['sqlcmd', '-S', 'wonder-sql-vd03.zmr.zimmer.com,1433', '-U', os.environ['HISTORIAN_SQL_USER'], '-C',
|
||||
'-d', 'Runtime', '-h', '-1', '-W', '-s', '|', '-Q', 'SET NOCOUNT ON; ' + q],
|
||||
env=env, capture_output=True, text=True, timeout=120)
|
||||
if out.returncode: raise SystemExit(out.stderr + out.stdout)
|
||||
return [l.split('|') for l in out.stdout.splitlines() if l.strip()]
|
||||
def iso(s): # '2026-11-10 11:30:01.6200000' -> datetime
|
||||
return datetime.strptime(s[:23], '%Y-%m-%d %H:%M:%S.%f').replace(tzinfo=timezone.utc)
|
||||
def wts(s): return datetime.fromisoformat(s.replace('Z', '+00:00'))
|
||||
total = bad = 0
|
||||
for c in rep['Cases']:
|
||||
if c['Name'] != 'raw.all' or c['Path'] != 'wire' or c['Tag'].startswith('OtOpcUa.'): continue
|
||||
start, end = c['Window'].split('/')
|
||||
s0, e0 = wts(start), wts(end)
|
||||
rows = sql(f"SELECT CONVERT(varchar(27), DateTime, 121), CONVERT(varchar(40), Value, 3), Quality, QualityDetail, OPCQuality "
|
||||
f"FROM dbo.History WHERE TagName = '{c['Tag']}' AND wwRetrievalMode = 'Full' AND wwTimeZone = 'UTC' "
|
||||
f"AND DateTime >= '{s0:%Y-%m-%d %H:%M:%S}' AND DateTime < '{e0:%Y-%m-%d %H:%M:%S}' ORDER BY DateTime")
|
||||
# AVEVA's synthetic boundary row (Quality 133) at the query start is not a stored sample (#687): OtOpcUa drops it.
|
||||
stored = [r for r in rows if not (r[2] == '133' and iso(r[0]) == s0)]
|
||||
wire = c['Rows']
|
||||
mism = []
|
||||
if len(stored) != len(wire): mism.append(f"count sql={len(stored)} wire={len(wire)}")
|
||||
for i, (r, w) in enumerate(zip(stored, wire)):
|
||||
t_ok = abs((iso(r[0]) - wts(w['Src'])).total_seconds()) < 0.0005
|
||||
sv = None if r[1] == 'NULL' else float(r[1])
|
||||
wv = None if w['V'] is None else float(w['V'])
|
||||
v_ok = (sv is None and wv is None) or (sv is not None and wv is not None and sv == wv)
|
||||
good = r[4] == '192'
|
||||
q_ok = (w['Status'] & 0xC0000000) == (0 if good else 0x80000000)
|
||||
if not (t_ok and v_ok and q_ok): mism.append(f"[{i}] sql={r} wire={w}"); break
|
||||
total += 1; bad += bool(mism)
|
||||
q = {}
|
||||
for r in stored: q[(r[2], r[3], r[4])] = q.get((r[2], r[3], r[4]), 0) + 1
|
||||
print(f"{c['Tag']:<30} {start[:19]} sql_stored={len(stored)} (boundary rows dropped={len(rows)-len(stored)}) wire={len(wire)} "
|
||||
f"quality(Q,QD,OPC)={q} -> {'MATCH' if not mism else 'MISMATCH ' + '; '.join(mism)}")
|
||||
print(f"{total} tag-windows compared, {bad} mismatching")
|
||||
Reference in New Issue
Block a user