Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs
T
Joseph Doherty dd4f1c2aa3 fix(alarms): Severity runs one direction, higher = more urgent; raw priority kept (#129)
AlarmConditionState.Severity mixed two conventions: OPC UA rows carried Part 9
Severity (higher = more severe) while MxGateway native alarms and computed
alarms carried a raw AVEVA-style priority (1 = most urgent). Every consumer
ranks higher as worse, so Worst Severity, the severity filter/sort and the
site event-log Error threshold picked the LEAST urgent MxGateway/computed
alarms.

- AlarmPriorityBands: the OtOpcUa GalaxyAlarmPriority table (lmxopcua#887,
  b61f3e05), value for value: 1-250 -> 1000, 251-500 -> 750, 501-750 -> 500,
  751-999 -> 250; <= 0 clamps to Critical, > 999 to Low.
- MxGatewayAlarmMapper bands the gateway severity (= AVEVA priority);
  AlarmConditionStateFactory.ForComputed bands PriorityLevel. OPC UA unchanged.
- AlarmConditionState gains trailing Priority (int?): the raw source number,
  exactly the pre-#129 Severity. AlarmStateChanged.Priority and the wire
  AlarmStateUpdate.priority keep that meaning (no proto change); central derives
  Severity by kind (SeverityFromPriority), so an older site decodes correctly.
- ScriptAlarm gains trailing Priority. The native MES 900-999 band and the MES
  API Severity/MinSeverity/MaxSeverity key off Priority; rig seed scripts moved
  from a.Severity to a.Priority, so MES output is unchanged.
- AlarmActor raise logs Error when the banded severity >= 700 (same threshold
  as NativeAlarmActor): priority 1-500 Error, 501+ Warning (was raw >= 700).
- NativeAlarmActor upgrades pre-#129 native_alarm_state rows on rehydrate.
- Debug snapshot decode restores Condition.Priority from the row's priority.
- Docs: Commons, DCL, SiteRuntime, Communication, CentralUI, InboundAPI,
  HighLevelReqs, MES alarm-status plan, CLAUDE.md.

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

208 lines
12 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.Globalization;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ProtoConditionState = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState;
using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind;
// Alias the Commons alarm types so their simple names bind here, unambiguous
// against the colliding gateway proto enums above.
using NativeAlarmTransition = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition;
using AlarmConditionState = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.AlarmConditionState;
using AlarmPriorityBands = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.AlarmPriorityBands;
using AlarmTransitionKind = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTransitionKind;
using AlarmShelveState = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmShelveState;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// Pure mapping from MxAccess Gateway alarm-feed proto messages to the
/// protocol-neutral <see cref="NativeAlarmTransition"/> shape. The gateway proto
/// enums (AlarmConditionState / AlarmTransitionKind) collide with the Commons
/// alarm enums, so they are aliased here. Unit-tested without a live gateway.
/// </summary>
public static class MxGatewayAlarmMapper
{
/// <summary>
/// The raw priority kept on <see cref="AlarmConditionState.Priority"/>: the gateway's
/// <c>severity</c> field — which mxaccessgw fills from the AVEVA alarm <b>priority</b>
/// (1 = most urgent … 999 = least) — clamped to 0–1000, i.e. exactly the value that
/// <see cref="AlarmConditionState.Severity"/> carried before Gitea #129. The native MES
/// 900–999 band keys off this value, so it must keep selecting the same alarms.
/// </summary>
/// <param name="rawPriority">The gateway's raw <c>severity</c> field (the AVEVA priority).</param>
/// <returns>The priority clamped to the range [0, 1000].</returns>
public static int NormalizePriority(int rawPriority) => Math.Clamp(rawPriority, 0, 1000);
/// <summary>
/// Maps the gateway's raw <c>severity</c> field (the AVEVA priority, lower = more urgent) onto the
/// unified <see cref="AlarmConditionState.Severity"/> (higher = more urgent) through
/// <see cref="AlarmPriorityBands"/> — the same table OtOpcUa applies to the same alarms
/// (lmxopcua#887), so the MxGateway and OPC UA paths agree (Gitea #129).
/// </summary>
/// <param name="rawPriority">The gateway's raw <c>severity</c> field (the AVEVA priority).</param>
/// <returns>The banded severity: 1000, 750, 500 or 250.</returns>
public static int MapSeverity(int rawPriority) => AlarmPriorityBands.ToSeverity(rawPriority);
/// <summary>Builds the condition for one gateway alarm: banded Severity + verbatim Priority.</summary>
private static AlarmConditionState BuildCondition(bool active, bool acked, int rawPriority) =>
new(active, acked, Confirmed: null, Shelve: AlarmShelveState.Unshelved, Suppressed: false,
Severity: MapSeverity(rawPriority), Priority: NormalizePriority(rawPriority));
/// <summary>Maps a gateway condition-state + severity to the unified condition.</summary>
/// <param name="state">Gateway proto condition state to map.</param>
/// <param name="severity">The gateway's raw <c>severity</c> field (the AVEVA priority); banded onto Severity, kept on Priority.</param>
/// <returns>The protocol-neutral <see cref="AlarmConditionState"/> equivalent.</returns>
public static AlarmConditionState MapConditionState(ProtoConditionState state, int severity)
{
var (active, acked) = state switch
{
ProtoConditionState.Active => (true, false),
ProtoConditionState.ActiveAcked => (true, true),
ProtoConditionState.Inactive => (false, true),
_ => (false, true)
};
return BuildCondition(active, acked, severity);
}
/// <summary>Maps a gateway transition kind to the unified transition kind.</summary>
/// <param name="kind">Gateway proto transition kind to map.</param>
/// <returns>The protocol-neutral <see cref="AlarmTransitionKind"/> equivalent.</returns>
public static AlarmTransitionKind MapKind(ProtoTransitionKind kind) => kind switch
{
ProtoTransitionKind.Raise => AlarmTransitionKind.Raise,
ProtoTransitionKind.Acknowledge => AlarmTransitionKind.Acknowledge,
ProtoTransitionKind.Clear => AlarmTransitionKind.Clear,
ProtoTransitionKind.Retrigger => AlarmTransitionKind.Retrigger,
_ => AlarmTransitionKind.StateChange
};
/// <summary>Derives the mirrored condition from a transition kind + severity.</summary>
private static AlarmConditionState ConditionFromKind(ProtoTransitionKind kind, int severity)
{
var (active, acked) = kind switch
{
ProtoTransitionKind.Raise => (true, false),
ProtoTransitionKind.Acknowledge => (true, true),
ProtoTransitionKind.Retrigger => (true, false),
ProtoTransitionKind.Clear => (false, true),
_ => (false, true)
};
return BuildCondition(active, acked, severity);
}
/// <summary>
/// Derives the ack timestamp mirrored onto <c>NativeAlarmTransition.AckTime</c>
/// (MES alarm-status API §6.4).
///
/// <para>
/// Unlike OPC UA A&amp;C, the MxAccess Gateway alarm feed carries NO dedicated ack
/// timestamp — an acknowledgement arrives as an <c>ACTIVE_ACKED</c> condition state on a
/// transition whose only time is <paramref name="transitionTime"/> (the gateway's
/// transition timestamp, or the DCL's own receipt time when the gateway omits it). That
/// observation time is what gets stamped: accurate to when the system saw the ack, never
/// fabricated, but not necessarily the operator's click.
/// </para>
///
/// <para>
/// Returns <c>null</c> unless the condition is active AND acknowledged — matching
/// <c>OpcUaAlarmMapper.DeriveAckTime</c>, so both native protocols agree on "null while
/// unacked, cleared on re-raise". This matters especially here because the gateway maps
/// INACTIVE to <c>acked = true</c>: without the active check, every return-to-normal
/// would report an ack it never observed.
/// </para>
/// </summary>
/// <param name="condition">The mirrored condition state for the transition.</param>
/// <param name="transitionTime">The transition's timestamp — the DCL's observation instant for the ack.</param>
/// <returns>The observed ack timestamp, or <c>null</c> when the condition is not an outstanding acknowledged alarm.</returns>
public static DateTimeOffset? DeriveAckTime(AlarmConditionState condition, DateTimeOffset transitionTime) =>
condition is { Active: true, Acknowledged: true } ? transitionTime : null;
/// <summary>
/// Converts an <see cref="MxValue"/> union to a display-only string using
/// <see cref="MxValueExtensions.ToClrValue"/> and invariant culture formatting,
/// so numeric values always use '.' as the decimal separator. Null or unset
/// values produce an empty string.
/// </summary>
/// <param name="mxVal">The gateway value union to convert, or null.</param>
/// <returns>The value formatted as an invariant-culture string, or an empty string if null or unset.</returns>
internal static string MxValueToString(MxValue? mxVal)
{
if (mxVal is null) return "";
var clr = mxVal.ToClrValue();
return clr is null ? "" : Convert.ToString(clr, CultureInfo.InvariantCulture) ?? "";
}
/// <summary>Maps a live <see cref="OnAlarmTransitionEvent"/> to a transition.</summary>
/// <param name="body">The gateway alarm transition event proto message to map.</param>
/// <returns>The protocol-neutral <see cref="NativeAlarmTransition"/>.</returns>
public static NativeAlarmTransition MapTransition(OnAlarmTransitionEvent body)
{
var condition = ConditionFromKind(body.TransitionKind, body.Severity);
var transitionTime = body.TransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow;
return new NativeAlarmTransition(
// Identify the condition by the object-relative reference (e.g.
// "Z28061.HeartbeatTimeoutAlarm") rather than the gateway's full provider
// reference ("Galaxy!<area>.<object>.<alarm>"). The area is preserved in
// Category; the object reference is globally unique within the galaxy and
// is the form operators expect. Falls back to the full reference only if
// the gateway omits the object reference.
SourceReference: string.IsNullOrEmpty(body.SourceObjectReference)
? body.AlarmFullReference : body.SourceObjectReference,
SourceObjectReference: body.SourceObjectReference,
AlarmTypeName: body.AlarmTypeName,
Kind: MapKind(body.TransitionKind),
Condition: condition,
Category: body.Category,
Description: body.Description,
Message: body.Description,
OperatorUser: body.OperatorUser,
OperatorComment: body.OperatorComment,
OriginalRaiseTime: body.OriginalRaiseTimestamp?.ToDateTimeOffset(),
TransitionTime: transitionTime,
CurrentValue: MxValueToString(body.CurrentValue),
LimitValue: MxValueToString(body.LimitValue),
// MES alarm-status API §6.4: the gateway supplies no ack timestamp, so an ack
// is stamped with the transition time it was observed at.
AckTime: DeriveAckTime(condition, transitionTime));
}
/// <summary>The end-of-snapshot sentinel transition (no condition payload).</summary>
/// <returns>A <see cref="NativeAlarmTransition"/> with <c>AlarmTransitionKind.SnapshotComplete</c>.</returns>
public static NativeAlarmTransition SnapshotComplete() => new(
"", "", "", AlarmTransitionKind.SnapshotComplete,
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
/// <summary>Maps one initial-snapshot <see cref="ActiveAlarmSnapshot"/> entry to a Snapshot transition.</summary>
/// <param name="snapshot">The active alarm snapshot proto message to map.</param>
/// <returns>A <see cref="NativeAlarmTransition"/> with <c>AlarmTransitionKind.Snapshot</c>.</returns>
public static NativeAlarmTransition MapSnapshot(ActiveAlarmSnapshot snapshot)
{
var condition = MapConditionState(snapshot.CurrentState, snapshot.Severity);
var transitionTime = snapshot.LastTransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow;
return new NativeAlarmTransition(
// See MapTransition: identify by the object-relative reference, not the
// full "Galaxy!<area>.<object>.<alarm>" provider reference.
SourceReference: string.IsNullOrEmpty(snapshot.SourceObjectReference)
? snapshot.AlarmFullReference : snapshot.SourceObjectReference,
SourceObjectReference: snapshot.SourceObjectReference,
AlarmTypeName: snapshot.AlarmTypeName,
Kind: AlarmTransitionKind.Snapshot,
Condition: condition,
Category: snapshot.Category,
Description: snapshot.Description,
Message: snapshot.Description,
OperatorUser: snapshot.OperatorUser,
OperatorComment: snapshot.OperatorComment,
OriginalRaiseTime: snapshot.OriginalRaiseTimestamp?.ToDateTimeOffset(),
TransitionTime: transitionTime,
CurrentValue: MxValueToString(snapshot.CurrentValue),
LimitValue: MxValueToString(snapshot.LimitValue),
// MES alarm-status API §6.4: an ACTIVE_ACKED snapshot entry reports the last
// transition time as its ack instant — the best the gateway feed supplies. A
// (re)subscribe snapshot therefore restores an ack time rather than losing it.
AckTime: DeriveAckTime(condition, transitionTime));
}
}