feat(abcip): ALMA (ALARM_ANALOG) alarm projection (#611 item 1)

Detect ALMA by declared *InAlarm members; one condition per tag keyed on RawPath; severity/message from
the most severe active limit; Raise/ActiveAcknowledged/Retrigger/Clear/Acknowledge; ack writes OperAckAll.
Unit-tested over fake runtimes only (no ab_server ALMA support, Z35162 has none).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 04:09:47 -04:00
parent 82750cccb9
commit b3838fcce9
4 changed files with 532 additions and 20 deletions
@@ -0,0 +1,257 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Gitea #611 item 1 — ALMA (<c>ALARM_ANALOG</c>) projection. Driven through fake tag runtimes only:
/// ab_server has no alarm instructions, and Z35162 has no ALMA/ALMD, so there is no wire-level gate.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipAlmaProjectionTests
{
private const string Device = "ab://10.0.0.5/1,0";
private const string Tag = "HighPress";
// Declaration order fixes the fake's byte offsets (DINT stride 4).
private static readonly string[] Layout =
[
"HHInAlarm", "HInAlarm", "LInAlarm", "LLInAlarm", "ROCPosInAlarm",
"HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked",
"HHSeverity", "HSeverity", "LSeverity", "LLSeverity", "ROCPosSeverity",
];
private static AbCipTagDefinition AlmaTag() => new(
Tag, Device, Tag, AbCipDataType.Structure,
Members: [.. Layout.Select(n => new AbCipStructureMember(n, AbCipDataType.DInt)),
new AbCipStructureMember("OperAckAll", AbCipDataType.DInt)]);
private static AbCipDriverOptions Options() => new()
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(AlmaTag()),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
EnableDeclarationOnlyUdtGrouping = true,
};
private static void Set(FakeAbCipTag t, string member, int v) => t.ValuesByOffset[Array.IndexOf(Layout, member) * 4] = v;
/// <summary>Seeds a quiet, ack-required ALMA: severities set, nothing in alarm, nothing acked.</summary>
private static void Quiet(FakeAbCipTag t)
{
foreach (var n in Layout) Set(t, n, 0);
Set(t, "HHSeverity", 900); Set(t, "HSeverity", 600); Set(t, "LSeverity", 300);
Set(t, "LLSeverity", 800); Set(t, "ROCPosSeverity", 400);
}
private sealed record Rig(AbCipDriver Drv, FakeAbCipTagFactory Factory, List<AlarmEventArgs> Events, IAlarmSubscriptionHandle Handle)
{
public FakeAbCipTag T => Factory.Tags[Tag];
public async Task StopAsync()
{
await Drv.UnsubscribeAlarmsAsync(Handle, CancellationToken.None);
await Drv.ShutdownAsync(CancellationToken.None);
}
}
private static async Task<Rig> StartAsync(Action<FakeAbCipTag>? seed = null)
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync([Tag], CancellationToken.None);
await WaitFor(() => factory.Tags.ContainsKey(Tag));
var rig = new Rig(drv, factory, events, handle);
Quiet(rig.T);
seed?.Invoke(rig.T);
return rig;
}
private static async Task WaitFor(Func<bool> cond)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline) { if (cond()) return; await Task.Delay(5); }
throw new TimeoutException("condition never met");
}
private static Task<AlarmEventArgs> Next(Rig r, Func<AlarmEventArgs, bool> match) =>
WaitFor(() => { lock (r.Events) return r.Events.Any(match); })
.ContinueWith(_ => { lock (r.Events) return r.Events.First(match); });
private static async Task Settled(Rig r)
{
var target = r.T.ReadCount + 6;
await WaitFor(() => r.T.ReadCount >= target);
}
/// <summary>The detector flags the six-limit signature and not an ALMD or a plain UDT.</summary>
[Fact]
public void Detector_Flags_Alma_And_Not_Almd()
{
AbCipAlarmDetector.IsAlma(AlmaTag()).ShouldBeTrue();
var almd = new AbCipTagDefinition("A", Device, "A", AbCipDataType.Structure, Members:
[new AbCipStructureMember("InFaulted", AbCipDataType.DInt), new AbCipStructureMember("Acked", AbCipDataType.DInt)]);
AbCipAlarmDetector.IsAlma(almd).ShouldBeFalse();
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
AbCipAlarmDetector.IsAlma(new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt)).ShouldBeFalse();
}
/// <summary>A limit entering alarm raises one ALMA condition keyed on the RawPath, naming the limit, with the limit's severity.</summary>
[Fact]
public async Task A_limit_in_alarm_raises_with_the_limits_name_and_severity()
{
var r = await StartAsync();
await Settled(r);
Set(r.T, "HInAlarm", 1);
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
await r.StopAsync();
e.ConditionId.ShouldBe(Tag);
e.SourceNodeId.ShouldBe(Tag);
e.AlarmType.ShouldBe("ALMA");
e.Message.ShouldBe($"ALMA {Tag} H alarm");
e.OpcUaSeverity.ShouldBe(600);
e.Severity.ShouldBe(AlarmSeverity.High);
}
/// <summary>With several limits active the most severe one names the condition and sets its severity.</summary>
[Fact]
public async Task The_most_severe_active_limit_wins()
{
var r = await StartAsync(t => { Set(t, "LInAlarm", 1); Set(t, "HHInAlarm", 1); Set(t, "ROCPosInAlarm", 1); });
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
await r.StopAsync();
e.Message.ShouldBe($"ALMA {Tag} HH alarm");
e.OpcUaSeverity.ShouldBe(900);
}
/// <summary>ROC limits are named with their sign.</summary>
[Fact]
public async Task A_rate_of_change_limit_is_named_with_its_sign()
{
var r = await StartAsync(t => Set(t, "ROCPosInAlarm", 1));
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
await r.StopAsync();
e.Message.ShouldBe($"ALMA {Tag} ROC+ alarm");
}
/// <summary>All limits returning to normal clears the one condition.</summary>
[Fact]
public async Task All_limits_clearing_fires_one_clear()
{
var r = await StartAsync(t => Set(t, "LLInAlarm", 1));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
Set(r.T, "LLInAlarm", 0);
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Clear);
await Settled(r);
await r.StopAsync();
e.ConditionId.ShouldBe(Tag);
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Clear).ShouldBe(1);
}
/// <summary>A newly entered limit while already active is a Retrigger (a new unacknowledged occurrence), not a second Raise.</summary>
[Fact]
public async Task A_newly_entered_limit_while_active_retriggers()
{
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
Set(r.T, "HHInAlarm", 1);
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger);
await Settled(r);
await r.StopAsync();
e.Message.ShouldBe($"ALMA {Tag} HH alarm");
e.OpcUaSeverity.ShouldBe(900);
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
}
/// <summary>A limit dropping out while another stays active updates message/severity without a Raise/Retrigger/Clear.</summary>
[Fact]
public async Task A_limit_dropping_out_updates_the_condition_without_re_raising()
{
var r = await StartAsync(t => { Set(t, "HInAlarm", 1); Set(t, "HHInAlarm", 1); });
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
Set(r.T, "HHInAlarm", 0);
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Unspecified);
await Settled(r);
await r.StopAsync();
e.Message.ShouldBe($"ALMA {Tag} H alarm");
e.OpcUaSeverity.ShouldBe(600);
lock (r.Events)
{
r.Events.Count(x => x.Kind is AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger or AlarmTransitionKind.Clear)
.ShouldBe(1);
}
}
/// <summary>The PLC acknowledging every active limit surfaces as one Acknowledge.</summary>
[Fact]
public async Task The_plc_acknowledging_the_active_limits_fires_acknowledge()
{
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
Set(r.T, "HAcked", 1);
await Next(r, x => x.Kind == AlarmTransitionKind.Acknowledge);
await Settled(r);
await r.StopAsync();
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1);
}
/// <summary>An alarm already active AND acked when first seen is told atomically, never Raise-then-Acknowledge.</summary>
[Fact]
public async Task An_already_acked_active_alarm_is_active_acknowledged()
{
var r = await StartAsync(t => { Set(t, "LInAlarm", 1); Set(t, "LAcked", 1); });
var e = await Next(r, x => x.Kind != AlarmTransitionKind.Unspecified);
await Settled(r);
await r.StopAsync();
e.Kind.ShouldBe(AlarmTransitionKind.ActiveAcknowledged);
lock (r.Events) r.Events.Count.ShouldBe(1);
}
/// <summary>A re-subscribe must not re-raise a still-active ALMA (it would un-acknowledge it).</summary>
[Fact]
public async Task A_resubscribe_does_not_re_raise_a_still_active_alma()
{
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
var sub2 = await r.Drv.SubscribeAlarmsAsync([Tag], CancellationToken.None);
await Settled(r);
await r.Drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
await r.StopAsync();
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
}
/// <summary>A Bad limit read judges nothing that tick: no event, and no false Clear for an active alarm.</summary>
[Fact]
public async Task A_bad_read_does_not_clear_an_active_alarm()
{
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
// A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so
// every re-created runtime must fail too (a fresh fake would read all-zero = "all clear").
var recreations = 0;
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
r.T.Status = (int)AbStatus.NotFound;
// Processed-count wait: the poll loop re-creates the failing runtime on tick after tick — six Bad ticks.
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
await r.StopAsync();
lock (r.Events) r.Events.Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
}
/// <summary>A client acknowledge writes the ALMA's OperAckAll input — never the ALMD-only <c>Acked</c> member.</summary>
[Fact]
public async Task Acknowledge_writes_OperAckAll_not_Acked()
{
var r = await StartAsync();
await r.Drv.AcknowledgeAsync([new AlarmAcknowledgeRequest(Tag, Tag, null)], CancellationToken.None);
await r.StopAsync();
r.Factory.Tags.ShouldContainKey($"{Tag}.OperAckAll");
r.Factory.Tags[$"{Tag}.OperAckAll"].WriteCount.ShouldBe(1);
r.Factory.Tags.ShouldNotContainKey($"{Tag}.Acked");
}
}