b61f3e0560
v2-ci / unit-tests (push) Has been cancelled
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
174 lines
8.9 KiB
C#
174 lines
8.9 KiB
C#
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&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));
|
||
}
|