Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/GalaxyEquipmentEventHistoryWireTests.cs
T
Joseph Doherty b61f3e0560
v2-ci / unit-tests (push) Has been cancelled
fix(galaxy): AVEVA priority 1 is the most urgent: Galaxy alarm Severity follows AVEVA's priority bands, live and history alike (#887)
GalaxyAlarmPriority, the one table live Galaxy alarms (MxAccessSeverityMapper)
and AVEVA A&E history (EventMapper, #886) map the AVEVA priority through, ranked
a higher number as more severe. AVEVA ranks priority 1 as the most urgent, so a
priority-1 alarm read 250 and a priority-999 system event 1000.

It now uses AVEVA's default priority ranges, most urgent first:

  AVEVA priority  AVEVA severity  bucket    OPC UA Severity
  1-250           1 Critical      Critical  1000
  251-500         2 High          High       750
  501-750         3 Medium        Medium     500
  751-999         4 Low           Low        250
  (history only)  0 not an alarm  -            1

Out-of-range priorities clamp (<= 0 Critical, > 999 Low). Evidence, vd03
Runtime.dbo.Events read-only: priority 1/100 alarms carry AVEVA severity 1,
400/500 severity 2, 600/700 severity 3, and every priority-999 System.* /
User.* row severity 0 with IsAlarm 0.

- Live and history now use the same number. GalaxyDriver passes the table's
  exact value as AlarmEventArgs.OpcUaSeverity. Before, it passed only the bucket,
  which NativeAlarmProjector re-mapped through its generic 200/500/700/900
  ladder, so live and history disagreed even before the direction fix.
- History: a row whose AVEVA severity is 0 (System.Start/Stop/Deploy,
  User.Write...) is not an alarm and reads Severity 1, below every alarm band.
  For alarms AVEVA's 1-4 severity is still never the value: the live feed
  carries only the priority, so it cannot use it, and history must agree.
- Docs: docs/Historian.md gains "Galaxy alarm priority -> Severity
  (lmxopcua#887)"; docs/AlarmTracking.md updated.
- Live re-run of the #801/#886 harness against vd03 HistorianGateway
  ea9f21c (read-only, network-only): 224 of 233 cases identical to the #886
  run, the 9 others differ only in Severity (priority-700 alarm rows 750 ->
  500, 19 system/user events 1000 -> 1). Record: aveva-history-801.md
  "#887 re-run", report aveva-gate-sdk2-887.json.gz.

Client-visible: every OPC UA client that ranks or filters Galaxy alarms by
Severity sees the order reversed (live and HistoryReadEvents). ScadaBridge has
no code that depends on the old values; its own mixed severity directions are
ScadaBridge#129.

Tests updated because they pinned the inverted table:
- Driver.Galaxy.Tests MxAccessSeverityMapperTests (rewritten: edges 1/250/251/
  500/501/750/751/999, 0/-100/int.MinValue/1000/int.MaxValue clamps,
  monotonic-decreasing sweep, equals GalaxyAlarmPriority for -2..1001)
- Driver.Galaxy.Tests GatewayGalaxyAlarmFeedTests (750 and 600 -> Medium)
- Driver.Historian.Gateway.Tests EventMapperTests (band table, priority 999
  -> 250, 1 -> 1000; system events -> 1)
- OpcUaServer.IntegrationTests GalaxyEquipmentEventHistoryWireTests (700 ->
  500, 100 -> 1000, System.Stop -> 1; the fake now stores AVEVA's real 0-4
  severity)

New tests:
- Runtime.Tests NativeAlarmRestatementEndToEndTests
  .Galaxy_live_condition_severity_is_the_AVEVA_priority_band (8 rows: the real
  feed -> real GalaxyDriver -> host -> projector; the same rows EventMapperTests
  pins on the history path)
- EventMapperTests.Aveva_severity_zero_marks_an_event_that_is_not_an_alarm
  (5 rows, incl. the priority-999 alarm positive control)
- GalaxyDriverAlarmSourceTests: OpcUaSeverity passes through

Mutation check, 8 mutants, all killed: the pre-#887 inverted table; the
driver dropping OpcUaSeverity; no not-an-alarm rule; each of the three band
edges off by one; history ignoring priority; a missing AVEVA severity read
as 0.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 12:14:49 -05:00

327 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime.CompilerServices;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Gitea #886 over a real SDK session, with the production Gateway backend on a deterministic AVEVA-shaped gateway: a
/// REAL equipment folder — whose id (<c>EqA</c>) is not a Galaxy object name — serves the AVEVA history of the Galaxy
/// objects behind its native conditions, here two (<c>Oven_007</c> and <c>OvenDoor_07</c>): each object's own events
/// and its attribute alarms, never a prefix sibling (<c>Oven_0070</c>), merged in time order across paging and reverse
/// reads, with the live condition's SourceNode / ConditionName and the live Galaxy severity, and only behind the
/// folder's own HistoryRead verdict.
/// </summary>
/// <remarks>
/// Topology, as the applier builds it from a Galaxy deployment: raw folder <c>Plant/Galaxy/Oven7</c> holds two native
/// conditions, <c>TempHi</c> (attributeRef <c>Oven_007.TempHiAlarm</c>) and <c>DoorOpen</c>
/// (<c>OvenDoor_07.DoorAlarm</c>), both wired to equipment <c>EqA</c>; raw folder <c>Plant/Galaxy/Line3</c> holds
/// <c>Jam</c> (<c>Line3_Conveyor.JamAlarm</c>), wired to <c>EqB</c>. The fake gateway filters a per-source read more
/// loosely than the real one (<c>Source_Object</c> begins with the source), so the sibling's rows reach OtOpcUa and
/// their absence is OtOpcUa's filter at work.
/// </remarks>
public sealed class GalaxyEquipmentEventHistoryWireTests
{
private const string OvenFolder = "Plant/Galaxy/Oven7";
private const string LineFolder = "Plant/Galaxy/Line3";
private const string TempHi = OvenFolder + "/TempHi";
private const string DoorOpen = OvenFolder + "/DoorOpen";
private const string Jam = LineFolder + "/Jam";
private static readonly DateTime Epoch = new(2026, 11, 10, 11, 0, 0, DateTimeKind.Utc);
/// <summary>The stored AVEVA rows. A null condition variable is an object-level system event.</summary>
private static readonly AvevaRow[] Stored =
[
new("o-stop", "Oven_007", "Oven_007", "System.Stop", 0, 999, null),
new("l-set", "Line3_Conveyor.Belt", "Line3_Conveyor", "Alarm.Set", 1, 300, "Line3_Conveyor.JamAlarm"),
new("o-temp-set", "Oven_007.Temp", "Oven_007", "Alarm.Set", 2, 500, "Oven_007.TempHiAlarm"),
new("d-set", "OvenDoor_07.Door", "OvenDoor_07", "Alarm.Set", 2, 700, "OvenDoor_07.DoorAlarm"), // a tie across objects
new("a-oven-tie", "Oven_007.Temp", "Oven_007", "Alarm.Ack", 2, 500, "Oven_007.TempHiAlarm"), // and within one, stored later
new("o-press-set", "Oven_007.Pressure", "Oven_007", "Alarm.Set", 3, 100, "Oven_007.PressureHiAlarm"), // no live condition here
new("s-set", "Oven_0070.Temp", "Oven_0070", "Alarm.Set", 4, 500, "Oven_0070.TempHiAlarm"), // prefix sibling
new("d-clear", "OvenDoor_07.Door", "OvenDoor_07", "Alarm.Clear", 5, 700, "OvenDoor_07.DoorAlarm"),
new("o-temp-clear", "Oven_007.Temp", "Oven_007", "Alarm.Clear", 6, 500, "oven_007.temphialarm"), // Galaxy names: any case
new("l-clear", "Line3_Conveyor.Belt", "Line3_Conveyor", "Alarm.Clear", 7, 300, "Line3_Conveyor.JamAlarm"),
new("o-start", "Oven_007", "Oven_007", "System.Start", 8, 999, null),
];
/// <summary>EqA's events forward: by time, then source (the folder's ordinal-sorted list: <c>OvenDoor_07</c> before
/// <c>Oven_007</c>), then the order the gateway returned them in — so <c>a-oven-tie</c> follows <c>o-temp-set</c>.</summary>
private static readonly string[] EqAEvents =
["o-stop", "d-set", "o-temp-set", "a-oven-tie", "o-press-set", "d-clear", "o-temp-clear", "o-start"];
/// <summary>EqA's events reverse: newest first; the Gateway's walked-back reverse read orders ties by EventId,
/// descending, as it did before #886.</summary>
private static readonly string[] EqAEventsReverse =
["o-start", "o-temp-clear", "d-clear", "o-press-set", "o-temp-set", "d-set", "a-oven-tie", "o-stop"];
[Fact]
public async Task An_equipment_folder_serves_the_aveva_history_of_every_galaxy_object_behind_it()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("GalaxyEqHistory", encrypted: false, ct);
var gateway = new AvevaShapedGateway();
rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger<GatewayHistorianDataSource>.Instance);
Materialise(rig);
rig.Enforce(false, [Key()]);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var all = await ReadAsync(session, Uns(session, EqA), forward: true, page: 0, ct);
all.Status.ShouldBe(StatusCodes.Good);
all.Rows.Select(r => r.Id).ShouldBe(EqAEvents);
gateway.Requested.ShouldBe(new[] { EqA, "OvenDoor_07", "Oven_007" },
"the folder's own source and one request per Galaxy object behind it (no multi-source request exists)");
gateway.Yielded.ShouldContain("s-set", "the sibling's rows reached OtOpcUa, so their absence is its filter's doing");
var byId = all.Rows.ToDictionary(r => r.Id);
// Each event keeps the source the historian reported.
byId["o-temp-set"].Source.ShouldBe("Oven_007.Temp");
byId["o-stop"].Source.ShouldBe("Oven_007");
// A row naming a condition that fires on this folder carries that live condition (any-case reference).
byId["o-temp-set"].SourceNode.ShouldBe(TempHi);
byId["o-temp-clear"].SourceNode.ShouldBe(TempHi);
byId["o-temp-set"].ConditionName.ShouldBe("TempHi");
byId["d-set"].SourceNode.ShouldBe(DoorOpen);
byId["d-set"].ConditionName.ShouldBe("DoorOpen");
// An alarm of the object with no condition wired here: served (object rule), but names no node.
byId["o-press-set"].SourceNode.ShouldBeNull();
byId["o-press-set"].ConditionName.ShouldBe("PressureHiAlarm");
// A system event has no condition at all.
byId["o-stop"].SourceNode.ShouldBeNull();
byId["o-stop"].ConditionName.ShouldBeNull();
// Severity follows the live Galaxy table, AVEVA priority 1 = most urgent (lmxopcua#887): 500 → 750 (High),
// 700 → 500 (Medium), 100 → 1000 (Critical); a system event (priority 999, AVEVA severity 0) is not an alarm → 1.
byId["o-temp-set"].Severity.ShouldBe((ushort)750);
byId["d-set"].Severity.ShouldBe((ushort)500);
byId["o-press-set"].Severity.ShouldBe((ushort)1000);
byId["o-stop"].Severity.ShouldBe((ushort)1);
// The raw device folder the conditions hang under reads the same objects (live: a condition fires on its parent).
var raw = await ReadAsync(session, Raw(session, OvenFolder), forward: true, page: 0, ct);
raw.Rows.Select(r => r.Id).ShouldBe(EqAEvents);
}
[Fact]
public async Task Paging_and_reverse_reads_walk_the_merged_sources_exactly_once()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("GalaxyEqHistoryPaging", encrypted: false, ct);
var gateway = new AvevaShapedGateway();
rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger<GatewayHistorianDataSource>.Instance);
Materialise(rig);
rig.Enforce(false, [Key()]);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var node = Uns(session, EqA);
foreach (var page in new uint[] { 1, 2, 3, 4 })
{
var paged = await ReadAsync(session, node, forward: true, page, ct);
paged.Rows.Select(r => r.Id).ShouldBe(EqAEvents, $"forward, {page} a page");
paged.Pages.ShouldBeGreaterThan(1);
var reverse = await ReadAsync(session, node, forward: false, page, ct);
reverse.Rows.Select(r => r.Id).ShouldBe(EqAEventsReverse, $"reverse, {page} a page (newest first, ties descending)");
}
var reverseWhole = await ReadAsync(session, node, forward: false, page: 0, ct);
reverseWhole.Rows.Select(r => r.Id).ShouldBe(EqAEventsReverse);
}
[Fact]
public async Task With_the_acl_on_a_denied_folder_reads_none_of_its_galaxy_objects()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("GalaxyEqHistoryAcl", encrypted: false, ct);
var gateway = new AvevaShapedGateway();
rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger<GatewayHistorianDataSource>.Instance);
Materialise(rig);
// Anonymous is granted EqB only: EqA, behind which sit Oven_007 and OvenDoor_07, is denied.
rig.Enforce(true, [Key()], EquipmentGrant(EqB, NodePermissions.ReadOnly));
using var session = await rig.ConnectAsync(anonymous: true, ct);
var denied = await ReadAsync(session, Uns(session, EqA), forward: true, page: 0, ct);
denied.Status.ShouldBe(StatusCodes.BadUserAccessDenied);
denied.HistoryDataIsNull.ShouldBeTrue("a denied folder discloses none of its events");
gateway.Requested.ShouldBeEmpty("the folder verdict is decided before any source is read (fail closed)");
// Positive control: the same session, on the folder it IS granted, gets that folder's Galaxy object.
var granted = await ReadAsync(session, Uns(session, EqB), forward: true, page: 0, ct);
granted.Status.ShouldBe(StatusCodes.Good);
granted.Rows.Select(r => (r.Id, r.SourceNode)).ShouldBe(new (string, string?)[] { ("l-set", Jam), ("l-clear", Jam) });
gateway.Requested.ShouldBe(new[] { EqB, "Line3_Conveyor" });
}
[Fact]
public async Task A_rebuild_forgets_the_galaxy_sources_until_the_conditions_are_bound_again()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("GalaxyEqHistoryRebuild", encrypted: false, ct);
var gateway = new AvevaShapedGateway();
rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger<GatewayHistorianDataSource>.Instance);
Materialise(rig);
rig.Enforce(false, [Key()]);
// The redeploy re-creates the same conditions and wiring, but (here) binds no Galaxy reference: the folder is back
// to its own source alone — a stale binding would keep reading the old objects.
rig.Sink.RebuildAddressSpace();
Materialise(rig, bind: false);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var read = await ReadAsync(session, Uns(session, EqA), forward: true, page: 0, ct);
read.Status.ShouldBe(StatusCodes.GoodNoData);
gateway.Requested.ShouldBe(new[] { EqA });
}
/// <summary>Materialises the topology the applier builds from the Galaxy deployment (see the class remarks).</summary>
private static void Materialise(ServerEventRig rig, bool bind = true)
{
rig.Sink.EnsureFolder(OvenFolder, parentNodeId: null, displayName: "Oven7", realm: AddressSpaceRealm.Raw);
rig.Sink.EnsureFolder(LineFolder, parentNodeId: null, displayName: "Line3", realm: AddressSpaceRealm.Raw);
rig.EnsureEquipment();
foreach (var (id, folder, name, reference, equipment) in new[]
{
(TempHi, OvenFolder, "TempHi", "Oven_007.TempHiAlarm", EqA),
(DoorOpen, OvenFolder, "DoorOpen", "OvenDoor_07.DoorAlarm", EqA),
(Jam, LineFolder, "Jam", "Line3_Conveyor.JamAlarm", EqB),
})
{
rig.Sink.MaterialiseAlarmCondition(id, folder, name, "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
if (bind) rig.Sink.BindNativeAlarmHistory(id, AddressSpaceRealm.Raw, reference);
rig.Sink.WireAlarmNotifiers(id, AddressSpaceRealm.Raw, [equipment], AddressSpaceRealm.Uns);
}
}
private static NodeId Uns(ISession session, string id) =>
new(id, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri));
private static NodeId Raw(ISession session, string id) =>
new(id, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
private static async Task<EventRead> ReadAsync(ISession session, NodeId node, bool forward, uint page, CancellationToken ct)
{
var filter = new EventFilter();
foreach (var leaf in new[] { "EventId", "SourceName", "SourceNode", "Severity", "ConditionName" })
{
var type = leaf == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType;
filter.SelectClauses += new SimpleAttributeOperand(type, new QualifiedName(leaf)) { AttributeId = Attributes.Value };
}
var (from, to) = (Epoch.AddMinutes(-1), Epoch.AddMinutes(1));
var details = new ReadEventDetails
{
StartTime = forward ? from : to, EndTime = forward ? to : from, NumValuesPerNode = page, Filter = filter,
};
var rows = new List<Row>();
ByteString point = default;
var pages = 0;
StatusCode first = StatusCodes.Good;
var historyDataIsNull = true;
do
{
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = point } }, ct);
var result = response.Results.ToList().ShouldHaveSingleItem();
if (pages++ == 0) { first = result.StatusCode; historyDataIsNull = result.HistoryData.IsNull; }
if (StatusCode.IsBad(result.StatusCode)) break;
if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he)
foreach (var e in he.Events.ToList())
{
var f = e.EventFields.ToList();
rows.Add(new Row(
System.Text.Encoding.UTF8.GetString(f[0].GetByteString().Span),
f[1].GetString(),
f[2].IsNull ? null : f[2].GetNodeId().IdentifierAsString,
f[3].GetUInt16(),
f[4].IsNull ? null : f[4].GetString()));
}
point = result.ContinuationPoint;
} while (!point.IsEmpty && pages < 60);
return new EventRead(first, historyDataIsNull, rows, pages);
}
private sealed record AvevaRow(string Id, string Source, string Object, string Type, int Second, int Priority, string? ConditionVariable);
private sealed record Row(string Id, string? Source, string? SourceNode, ushort Severity, string? ConditionName);
private sealed record EventRead(StatusCode Status, bool HistoryDataIsNull, List<Row> Rows, int Pages);
/// <summary>
/// A deterministic gateway answering ReadEvents with AVEVA-shaped rows — <c>SourceName</c> =
/// <c>Source_ProcessVariable</c> and lower-case properties as vd03's HistorianGateway returns them — filtered loosely
/// on <c>source_object</c>, in event-time order with no intra-timestamp order guarantee (here: storage order).
/// </summary>
private sealed class AvevaShapedGateway : IHistorianGatewayClient
{
private readonly List<string?> _requested = [];
private readonly List<string> _yielded = [];
public IReadOnlyList<string?> Requested { get { lock (_requested) return _requested.ToList(); } }
public IReadOnlyList<string> Yielded { get { lock (_yielded) return _yielded.ToList(); } }
public async IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, [EnumeratorCancellation] CancellationToken ct)
{
lock (_requested) _requested.Add(sourceName);
await Task.Yield();
foreach (var s in Stored.OrderBy(s => s.Second))
{
var at = Epoch.AddSeconds(s.Second);
if (at < startUtc || at > endUtc) continue; // the gateway's end is inclusive
if (sourceName is not null && !s.Object.StartsWith(sourceName, StringComparison.Ordinal)) continue;
lock (_yielded) _yielded.Add(s.Id);
var ts = Timestamp.FromDateTime(at);
var e = new HistorianEvent
{
Id = s.Id, SourceName = s.Source, Type = s.Type, Namespace = "Application Server", EventTime = ts, ReceivedTime = ts,
};
e.Properties["source_object"] = s.Object;
e.Properties["source_processvariable"] = s.Source;
e.Properties["priority"] = s.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture);
// AVEVA's own scale, as vd03 stores it: 0 for an event that is not an alarm, else the priority's 1–4 band.
// Only the 0 is read (not an alarm → Severity 1); an alarm's value comes from its priority.
e.Properties["severity"] = s.ConditionVariable is null ? "0"
: (s.Priority <= 250 ? "1" : s.Priority <= 500 ? "2" : s.Priority <= 750 ? "3" : "4");
if (s.ConditionVariable is not null)
{
e.Properties["source_conditionvariable"] = s.ConditionVariable;
e.Properties["alarm_id"] = $"guid-{s.ConditionVariable.ToLowerInvariant()}";
}
yield return e;
}
}
public Task<bool> TagExistsAsync(string tag, CancellationToken ct) => throw new NotSupportedException();
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) =>
throw new NotSupportedException();
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc,
RetrievalMode mode, TimeSpan interval, CancellationToken ct) => throw new NotSupportedException();
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct) =>
throw new NotSupportedException();
public Task<WriteAck> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorianLiveValue> values, CancellationToken ct) =>
throw new NotSupportedException();
public Task<WriteAck> SendEventAsync(HistorianEvent evt, CancellationToken ct) => throw new NotSupportedException();
public Task<TagOperationResults> EnsureTagsAsync(IReadOnlyList<HistorianTagDefinition> definitions, CancellationToken ct) =>
throw new NotSupportedException();
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
public Task<ConnectionStatus> GetConnectionStatusAsync(CancellationToken ct) => Task.FromResult(new ConnectionStatus());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}