Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.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

174 lines
8.9 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 Google.Protobuf.WellKnownTypes;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
public sealed class EventMapperTests
{
[Fact]
public void Carries_the_alarm_type_condition_name_and_alarm_id_the_writer_sent()
{
// The round trip of AlarmEventMapper's shape: Type = AlarmTypeName, Properties AlarmName / AlarmId.
var sent = AlarmEventMapper.ToHistorianEvent(new ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.AlarmHistorianEvent(
"Plant/Modbus/dev1/temp_hi", "Area/Line/Eq", "HiTemp", "LimitAlarm", ZB.MOM.WW.OtOpcUa.Core.Abstractions.AlarmSeverity.High,
"Activated", "high", "u", null, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
var h = EventMapper.ToHistoricalEvent(sent);
Assert.Equal("LimitAlarm", h.EventTypeName);
Assert.Equal("HiTemp", h.ConditionName);
Assert.Equal("Plant/Modbus/dev1/temp_hi", h.AlarmId);
// A gateway row without them (e.g. an event another writer produced) maps to nulls, never throws.
var bare = new HistorianEvent { Id = "E", EventTime = Ts(2026, 1, 1, 0, 0, 0), ReceivedTime = Ts(2026, 1, 1, 0, 0, 0) };
var b = EventMapper.ToHistoricalEvent(bare);
Assert.Null(b.EventTypeName);
Assert.Null(b.ConditionName);
Assert.Null(b.AlarmId);
}
[Fact]
public void Maps_core_fields_and_times()
{
var e = new HistorianEvent { Id = "E1", SourceName = "Pump1",
EventTime = Ts(2026, 1, 1, 0, 0, 0), ReceivedTime = Ts(2026, 1, 1, 0, 0, 5) };
e.Properties["Message"] = "High temp";
e.Properties["Severity"] = "700";
var h = EventMapper.ToHistoricalEvent(e);
Assert.Equal("E1", h.EventId);
Assert.Equal("Pump1", h.SourceName);
Assert.Equal("High temp", h.Message);
Assert.Equal((ushort)700, h.Severity);
Assert.Equal(DateTimeKind.Utc, h.EventTimeUtc.Kind);
}
[Theory]
[InlineData("Priority", "999", 250)] // a Galaxy priority maps through the live table (#886/#887: 999 = least urgent), not clamped as a severity
[InlineData("Priority", "1", 1000)] // AVEVA priority 1 is the most urgent (#887)
[InlineData("Severity", "0", 1)] // clamp to OPC UA min 1
[InlineData("Severity", "5000", 1000)] // clamp to OPC UA max 1000
[InlineData(null, null, 1)] // missing → default min severity
public void Severity_parsed_and_clamped(string? key, string? val, int expected)
{
var e = new HistorianEvent { Id = "E", EventTime = Ts(2026, 1, 1, 0, 0, 0), ReceivedTime = Ts(2026, 1, 1, 0, 0, 0) };
if (key is not null) e.Properties[key] = val!;
Assert.Equal((ushort)expected, EventMapper.ToHistoricalEvent(e).Severity);
}
// ---- Gitea #886: AVEVA-shaped rows (lower-case keys) -------------------------------------------------------------
/// <summary>An AVEVA A&amp;E alarm row as vd03's HistorianGateway returns it (2026-11-12 probe, trimmed).</summary>
private static HistorianEvent AvevaAlarmRow(string priority = "500")
{
var e = new HistorianEvent
{
Id = "001c0040-0025-0004-5b00-74003f005400", Type = "Alarm.Set", SourceName = "Oven_007.Temp",
Namespace = "Application Server", EventTime = Ts(2026, 11, 5, 15, 19, 52), ReceivedTime = Ts(2026, 11, 5, 15, 19, 53),
};
e.Properties["alarm_id"] = "001c0040-0025-0004-5b00-74003f005400";
e.Properties["alarm_state"] = "UNACK_ALM";
e.Properties["comment"] = "Oven 7 temperature";
e.Properties["priority"] = priority;
e.Properties["severity"] = "2"; // AVEVA's own 1..4 scale: never the OPC UA Severity
e.Properties["source_conditionvariable"] = "Oven_007.TempHiAlarm";
e.Properties["source_object"] = "Oven_007";
e.Properties["source_processvariable"] = "Oven_007.Temp";
return e;
}
[Fact]
public void An_aveva_alarm_row_maps_priority_alarm_id_and_its_condition_reference()
{
var h = EventMapper.ToHistoricalEvent(AvevaAlarmRow());
Assert.Equal((ushort)750, h.Severity); // priority 500 → the live Galaxy table's High bucket, not AVEVA's severity "2"
Assert.Equal("001c0040-0025-0004-5b00-74003f005400", h.AlarmId);
Assert.Equal("Oven_007.TempHiAlarm", h.ConditionReference);
Assert.Equal("TempHiAlarm", h.ConditionName); // the AVEVA alarm's name until the server resolves a live condition
Assert.Equal("Alarm.Set", h.Message); // no message property: the event type, as before
Assert.Equal("Oven_007.Temp", h.SourceName);
}
// lmxopcua#887: AVEVA's default priority bands, most urgent first. NativeAlarmRestatementEndToEndTests
// (Galaxy_live_condition_severity_is_the_AVEVA_priority_band) pins the same rows on the live path.
[Theory]
[InlineData("1", 1000)] // Critical 1–250
[InlineData("250", 1000)]
[InlineData("251", 750)] // High 251–500 (vd03: priority 500 rows carry AVEVA severity 2)
[InlineData("500", 750)]
[InlineData("501", 500)] // Medium 501–750
[InlineData("750", 500)]
[InlineData("751", 250)] // Low 751–999
[InlineData("999", 250)] // a priority-999 ALARM (AVEVA severity 4); a system event at 999 is the next test
[InlineData("0", 1000)] // out of range: clamped to 1
[InlineData("-5", 1000)]
[InlineData("1000", 250)] // out of range: clamped to 999
public void An_aveva_priority_maps_through_the_live_galaxy_table(string priority, int expected)
{
var h = EventMapper.ToHistoricalEvent(AvevaAlarmRow(priority));
Assert.Equal((ushort)expected, h.Severity);
// The live Galaxy path maps the same raw value through the same table, so history and live agree.
Assert.Equal(expected, ZB.MOM.WW.OtOpcUa.Core.Abstractions.GalaxyAlarmPriority.Map(int.Parse(priority)).OpcUaSeverity);
}
[Fact]
public void OtOpcUas_own_event_read_back_from_aveva_with_lower_case_keys_keeps_its_fields()
{
// AVEVA lower-cases the keys OtOpcUa writes (AlarmName → alarmname, …): seen on vd03 2026-11-12.
var e = new HistorianEvent { Id = "E", Type = "LimitAlarm", SourceName = "Area/Line/Eq",
EventTime = Ts(2026, 6, 27, 3, 43, 44), ReceivedTime = Ts(2026, 6, 27, 3, 43, 44) };
e.Properties["alarmid"] = "Plant/Modbus/dev1/temp_hi";
e.Properties["alarmname"] = "HiTemp";
e.Properties["eventkind"] = "Activated";
e.Properties["message"] = "Temperature high";
e.Properties["severity"] = "700";
var h = EventMapper.ToHistoricalEvent(e);
Assert.Equal("Plant/Modbus/dev1/temp_hi", h.AlarmId);
Assert.Equal("HiTemp", h.ConditionName);
Assert.Equal("Temperature high", h.Message);
Assert.Equal((ushort)700, h.Severity);
Assert.Null(h.ConditionReference);
}
[Fact]
public void An_aveva_system_event_has_no_condition()
{
var e = new HistorianEvent { Id = "S", Type = "System.Stop", SourceName = "Oven_007",
EventTime = Ts(2026, 11, 10, 11, 32, 35), ReceivedTime = Ts(2026, 11, 10, 11, 32, 35) };
e.Properties["priority"] = "999";
e.Properties["severity"] = "0";
e.Properties["source_object"] = "Oven_007";
var h = EventMapper.ToHistoricalEvent(e);
Assert.Null(h.ConditionName);
Assert.Null(h.ConditionReference);
Assert.Null(h.AlarmId);
Assert.Equal("System.Stop", h.Message);
// lmxopcua#887: AVEVA severity 0 = not an alarm — the lowest OPC UA Severity, below every alarm band.
Assert.Equal((ushort)1, h.Severity);
}
[Theory]
[InlineData("System.Start", "999", "0", 1)] // not an alarm (vd03: System.*, User.Write rows are priority 999 / severity 0)
[InlineData("User.Write", "999", "0", 1)]
[InlineData("Alarm.Set", "999", "4", 250)] // positive control: a priority-999 ALARM is Low, not "not an alarm"
[InlineData("Alarm.Set", "999", null, 250)] // no AVEVA severity: the priority alone decides
[InlineData("Alarm.Set", "100", "1", 1000)] // vd03: TableAlarms_*.CommunicationAlarm, priority 100 / severity 1
public void Aveva_severity_zero_marks_an_event_that_is_not_an_alarm(string type, string priority, string? avevaSeverity, int expected)
{
var e = new HistorianEvent { Id = "S", Type = type, SourceName = "Oven_007",
EventTime = Ts(2026, 11, 10, 11, 32, 35), ReceivedTime = Ts(2026, 11, 10, 11, 32, 35) };
e.Properties["priority"] = priority;
if (avevaSeverity is not null) e.Properties["severity"] = avevaSeverity;
Assert.Equal((ushort)expected, EventMapper.ToHistoricalEvent(e).Severity);
}
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
private static Timestamp Ts(int y, int mo, int d, int h, int mi, int s)
=> Timestamp.FromDateTime(new DateTime(y, mo, d, h, mi, s, DateTimeKind.Utc));
}