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
This commit is contained in:
Joseph Doherty
2026-11-12 13:58:28 -05:00
parent e36cd6055e
commit dd4f1c2aa3
39 changed files with 686 additions and 120 deletions
+1 -1
View File
@@ -345,7 +345,7 @@ scadabridge --url <url> template native-alarm-source add --template-id <int> --n
| `--filter` | no | Condition filter (omit to mirror all conditions under the source) |
| `--description` | no | Description |
| `--locked` | no | Lock the binding in derived templates |
| `--mes-reportable` | no | Every condition under this binding is MES-reportable (additive to the 900-999 severity band) |
| `--mes-reportable` | no | Every condition under this binding is MES-reportable (additive to the 900-999 priority band on `ScriptAlarm.Priority`) |
#### `template native-alarm-source list`
@@ -98,7 +98,7 @@ public sealed record AlarmSummaryRow(
/// Pure point-in-time roll-up over a set of <see cref="AlarmSummaryRow"/>s.
/// </summary>
/// <param name="TotalActive">Count of rows whose <see cref="AlarmStateChanged.State"/> is <see cref="AlarmState.Active"/>.</param>
/// <param name="WorstSeverity">Highest <see cref="Commons.Types.Alarms.AlarmConditionState.Severity"/> among active rows; 0 when none active.</param>
/// <param name="WorstSeverity">Highest <see cref="Commons.Types.Alarms.AlarmConditionState.Severity"/> among active rows — the most urgent, since Severity is higher = more urgent for every kind (Gitea #129); 0 when none active.</param>
/// <param name="UnackedCount">Active, unacknowledged native conditions (<c>Kind != Computed</c>).</param>
/// <param name="CountsByKind">Per-<see cref="AlarmKind"/> row counts (only kinds with at least one row appear).</param>
public sealed record AlarmRollup(
@@ -26,7 +26,7 @@ public class TemplateNativeAlarmSource
/// <summary>
/// When true, every condition mirrored under this binding is MES-reportable
/// (<c>AlarmStateChanged.IsMesReportable</c> / <c>ScriptAlarm.IsMesReportable</c>). Additive to the
/// 900-999 severity-band convention, which still flags a condition on its own. Default false.
/// 900-999 priority-band convention (on <c>ScriptAlarm.Priority</c>, Gitea #129), which still flags a condition on its own. Default false.
/// Overridable per instance via <c>InstanceNativeAlarmSourceOverride.IsMesReportableOverride</c>.
/// </summary>
public bool IsMesReportable { get; set; }
@@ -3,6 +3,17 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
/// <summary>An alarm's state change, as streamed site → central and kept in the Debug View snapshot.</summary>
/// <param name="InstanceUniqueName">The instance the alarm belongs to.</param>
/// <param name="AlarmName">The alarm's name (a native condition's source reference).</param>
/// <param name="State">Active or Normal.</param>
/// <param name="Priority">
/// The source's raw number (Gitea #129): the computed alarm's <c>PriorityLevel</c>, the AVEVA priority of an
/// MxAccess condition, the Part 9 Severity of an OPC UA condition — i.e. <c>Condition.Priority</c>. This is what
/// the stream's <c>AlarmStateUpdate.priority</c> carries (meaning unchanged since before #129). For ranking use
/// <see cref="Condition"/>.<see cref="AlarmConditionState.Severity"/>, which is higher = more urgent for every kind.
/// </param>
/// <param name="Timestamp">When the transition happened.</param>
public record AlarmStateChanged(
string InstanceUniqueName,
string AlarmName,
@@ -119,7 +130,7 @@ public record AlarmStateChanged(
/// snapshot and on every event it relays to the site stream. For a native condition it is
/// the binding-level flag (<c>ResolvedNativeAlarmSource.IsMesReportable</c>, Gitea #59), set by
/// the owning <c>NativeAlarmActor</c> — there is still no per-condition config row; the
/// 900-999 severity band remains an independent, additive signal. Carried to central on
/// 900-999 priority band (on <see cref="Priority"/>, Gitea #129) remains an independent, additive signal. Carried to central on
/// <c>AlarmStateUpdate</c> field 25 and <c>DebugAlarmStateDto</c> field 20 (Gitea #60);
/// a pre-#60 site sends the proto3 default. Additive; default false.
/// </summary>
@@ -8,17 +8,35 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
/// onto them, so they are modeled as independent flags plus a 0–1000 severity.
/// Computed alarms populate this from State + Priority; native alarms mirror it
/// from the source.
///
/// <para>
/// <b>One direction (Gitea #129):</b> <see cref="Severity"/> is always OPC UA Part 9 direction,
/// <b>higher = more urgent</b>, whatever the source. OPC UA rows carry the server's Part 9 Severity;
/// MxAccess Gateway rows and computed alarms carry their AVEVA-style priority (1 = most urgent)
/// mapped through <see cref="AlarmPriorityBands"/> (1–250 → 1000, 251–500 → 750, 501–750 → 500,
/// 751–999 → 250). Sort, filter and threshold on <see cref="Severity"/>, never on <see cref="Priority"/>.
/// </para>
/// </summary>
/// <param name="Active">Active vs Inactive.</param>
/// <param name="Acknowledged">Acked vs Unacked.</param>
/// <param name="Confirmed">Confirmed vs Unconfirmed; null when the condition is not confirmable.</param>
/// <param name="Shelve">Shelving sub-state.</param>
/// <param name="Suppressed">Whether the source has suppressed the condition.</param>
/// <param name="Severity">Severity on the unified 0–1000 scale.</param>
/// <param name="Severity">Severity on the unified 0–1000 scale, higher = more urgent for every source.</param>
/// <param name="Priority">
/// The source's own number, verbatim — exactly what <see cref="Severity"/> carried before Gitea #129:
/// the AVEVA alarm priority for MxAccess Gateway rows (clamped 0–1000; 1 = most urgent), the configured
/// <c>PriorityLevel</c> for computed alarms, and the Part 9 Severity again for OPC UA rows (OPC UA has
/// no separate priority). Kept so no information is lost and so the native MES 900–999 band selects the
/// same alarms it always did. Its direction depends on the source, so do not sort or threshold on it.
/// <c>null</c> only on a condition persisted before #129 or built without one; readers fall back to
/// <see cref="Severity"/>.
/// </param>
public record AlarmConditionState(
bool Active,
bool Acknowledged,
bool? Confirmed,
AlarmShelveState Shelve,
bool Suppressed,
int Severity);
int Severity,
int? Priority = null);
@@ -7,13 +7,32 @@ public static class AlarmConditionStateFactory
{
/// <summary>
/// Computed alarms have no native ack/shelve/suppress lifecycle: they are
/// auto-acked, never shelved or suppressed, not confirmable, and their
/// severity is the configured priority. Active mirrors the alarm State.
/// auto-acked, never shelved or suppressed, not confirmable. Their configured
/// priority (lower = more urgent) is banded onto Severity through
/// <see cref="AlarmPriorityBands"/> and kept verbatim on
/// <see cref="AlarmConditionState.Priority"/> (Gitea #129). Active mirrors the alarm State.
/// </summary>
/// <param name="state">The current alarm state used to derive the Active flag.</param>
/// <param name="priority">Configured priority mapped to the Severity field (0–1000).</param>
/// <param name="priority">Configured priority (0–1000, lower = more urgent).</param>
/// <returns>An <see cref="AlarmConditionState"/> reflecting the computed alarm's lifecycle (auto-acked, unshelved, not suppressed).</returns>
public static AlarmConditionState ForComputed(AlarmState state, int priority) =>
new(Active: state == AlarmState.Active, Acknowledged: true, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: priority);
Shelve: AlarmShelveState.Unshelved, Suppressed: false,
Severity: AlarmPriorityBands.ToSeverity(priority), Priority: priority);
/// <summary>
/// Derives the unified Severity from the raw priority an <c>AlarmStateChanged</c> carries on the
/// site stream (<c>AlarmStateUpdate.priority</c>), which is all central receives (Gitea #129).
/// OPC UA rows carry their Part 9 Severity there already; MxAccess Gateway and computed rows carry
/// an AVEVA-style priority that is banded. Because the wire field kept its pre-#129 meaning, this
/// is also correct for a site still running an older build.
/// </summary>
/// <param name="kind">The alarm's kind.</param>
/// <param name="priority">The raw priority from the wire.</param>
/// <returns>The severity on the 0–1000 scale, higher = more urgent.</returns>
public static int SeverityFromPriority(AlarmKind kind, int priority) => kind switch
{
AlarmKind.NativeOpcUa => Math.Clamp(priority, 0, 1000),
_ => AlarmPriorityBands.ToSeverity(priority),
};
}
@@ -0,0 +1,72 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
/// <summary>
/// Maps an alarm <b>priority</b> (AVEVA convention: 1 = most urgent … 999 = least) onto the unified
/// <see cref="AlarmConditionState.Severity"/> scale (OPC UA Part 9 direction: higher = more urgent).
/// Used for MxAccess Gateway native alarms and for computed alarms' <c>PriorityLevel</c> (Gitea #129).
/// </summary>
/// <remarks>
/// <para>
/// This is the same table OtOpcUa's <c>GalaxyAlarmPriority.Map</c> uses since lmxopcua#887 (commit
/// <c>b61f3e05</c>), value for value, so a Galaxy alarm reads the same Severity whether ScadaBridge
/// gets it through OtOpcUa (OPC UA adapter) or straight from mxaccessgw (MxGateway adapter). AVEVA's
/// default priority ranges, most urgent first:
/// </para>
/// <list type="table">
/// <listheader><term>Priority</term><description>AVEVA severity → Severity</description></listheader>
/// <item><term>1–250</term><description>1 Critical → 1000</description></item>
/// <item><term>251–500</term><description>2 High → 750</description></item>
/// <item><term>501–750</term><description>3 Medium → 500</description></item>
/// <item><term>751–999</term><description>4 Low → 250</description></item>
/// </list>
/// <para>
/// A priority outside 1–999 is clamped into it first, exactly as OtOpcUa does: 0, negatives and
/// <see cref="int.MinValue"/> read Critical (1000); anything above 999 reads Low (250). The mapping is
/// monotonic: a lower (more urgent) priority never yields a lower Severity.
/// </para>
/// <para>
/// OtOpcUa's table also has a history-only "not an alarm" row (AVEVA <i>severity</i> 0 → Severity 1, for
/// System.*/User.* events in A&amp;E history). It has no counterpart here: the MxGateway live feed carries
/// alarms only, and only their priority, and a computed alarm is always an alarm.
/// </para>
/// <para>
/// The raw priority is never thrown away: it stays on <see cref="AlarmConditionState.Priority"/> and
/// <c>AlarmStateChanged.Priority</c>, which is what the native MES 900–999 band keys off.
/// Change the table here only, and keep it in step with OtOpcUa's.
/// </para>
/// </remarks>
public static class AlarmPriorityBands
{
/// <summary>The last priority of AVEVA's default Critical range (1–250).</summary>
public const int CriticalMaxPriority = 250;
/// <summary>The last priority of AVEVA's default High range (251–500).</summary>
public const int HighMaxPriority = 500;
/// <summary>The last priority of AVEVA's default Medium range (501–750); 751–999 is Low.</summary>
public const int MediumMaxPriority = 750;
/// <summary>Severity of the Low band (priority 751–999).</summary>
public const int SeverityLow = 250;
/// <summary>Severity of the Medium band (priority 501–750).</summary>
public const int SeverityMedium = 500;
/// <summary>Severity of the High band (priority 251–500).</summary>
public const int SeverityHigh = 750;
/// <summary>Severity of the Critical band (priority 1–250).</summary>
public const int SeverityCritical = 1000;
/// <summary>Maps an AVEVA-style priority onto the unified severity.</summary>
/// <param name="priority">The priority (1 = most urgent … 999 = least; clamped into 1–999).</param>
/// <returns>The severity on the 0–1000 scale, 1000 = most urgent.</returns>
public static int ToSeverity(int priority) => priority switch
{
// The first arm also takes 0 and negatives, the last anything above 999: the clamp into 1–999.
<= CriticalMaxPriority => SeverityCritical,
<= HighMaxPriority => SeverityHigh,
<= MediumMaxPriority => SeverityMedium,
_ => SeverityLow,
};
}
@@ -161,7 +161,7 @@ public sealed record ResolvedNativeAlarmSource
public string Source { get; init; } = "Template";
/// <summary>
/// Gets whether every condition under this binding is MES-reportable (effective value after
/// the instance override). Additive to the 900-999 severity band.
/// the instance override). Additive to the 900-999 priority band (Gitea #129).
/// </summary>
public bool IsMesReportable { get; init; }
/// <summary>
@@ -35,7 +35,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
/// <param name="Confirmed">Confirmed vs unconfirmed; <c>null</c> when the condition is not confirmable.</param>
/// <param name="Shelved">Whether the source has shelved the condition (any shelve sub-state).</param>
/// <param name="Suppressed">Whether the source has suppressed the condition.</param>
/// <param name="Severity">Severity on the unified 0–1000 scale.</param>
/// <param name="Severity">
/// Severity on the unified 0–1000 scale, <b>higher = more urgent</b> for every kind (Gitea #129):
/// OPC UA Part 9 Severity verbatim; MxAccess and computed priorities banded 1–250 → 1000,
/// 251–500 → 750, 501–750 → 500, 751–999 → 250. Sort and threshold on this.
/// </param>
/// <param name="Kind">"Computed", "NativeOpcUa" or "NativeMxAccess".</param>
/// <param name="Message">Event/per-band operator message; may be empty.</param>
/// <param name="AlarmTypeName">Native alarm type (e.g. "AnalogLimitAlarm.HiHi"); empty for computed alarms.</param>
@@ -59,6 +63,13 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
/// True when the deployed configuration flags the alarm as MES-reportable (template
/// checkbox or instance override). For native conditions it is the source binding's flag (Gitea #59).
/// </param>
/// <param name="Priority">
/// The source's raw number, verbatim — what <paramref name="Severity"/> held before Gitea #129: the
/// AVEVA alarm priority for MxAccess (1 = most urgent), the configured <c>PriorityLevel</c> for computed
/// alarms, the Part 9 Severity for OPC UA (no separate priority). The native MES 900–999 band and the MES
/// API's <c>Severity</c>/<c>MinSeverity</c>/<c>MaxSeverity</c> (a 0–999 priority) key off this.
/// Its direction differs by kind; do not rank alarms by it.
/// </param>
public sealed record ScriptAlarm(
string Name,
string SourceReference,
@@ -81,4 +92,5 @@ public sealed record ScriptAlarm(
string CurrentValue,
string LimitValue,
bool IsConfiguredPlaceholder,
bool IsMesReportable = false);
bool IsMesReportable = false,
int Priority = 0);
@@ -1994,7 +1994,11 @@ public static class SiteCommandDtoMapper
AckTime = DtoOrNull(dto.AckTime)
};
return dto.Condition is null ? alarm : alarm with { Condition = FromProto(dto.Condition) };
// AlarmConditionStateDto carries Severity only; the raw priority (Gitea #129) travels as the
// row's own Priority, which the site always sets equal to Condition.Priority, so restore it here.
return dto.Condition is null
? alarm
: alarm with { Condition = FromProto(dto.Condition) with { Priority = dto.Priority } };
}
/// <summary>Projects an <see cref="AlarmConditionState"/> onto the wire.</summary>
@@ -575,43 +575,56 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
// proto3 defaults, which map to null / false.
SourceExecutionId: Guid.TryParse(evt.AttributeChanged.SourceExecutionId, out var traceSource) ? traceSource : null,
Trace: evt.AttributeChanged.Trace),
SiteStreamEvent.EventOneofCase.AlarmChanged => new AlarmStateChanged(
evt.AlarmChanged.InstanceUniqueName,
evt.AlarmChanged.AlarmName,
MapAlarmState(evt.AlarmChanged.State),
evt.AlarmChanged.Priority,
evt.AlarmChanged.Timestamp.ToDateTimeOffset())
{
Level = MapAlarmLevel(evt.AlarmChanged.Level),
Message = evt.AlarmChanged.Message ?? string.Empty,
// Native alarm enrichment (additive — computed alarms carry their default condition).
Kind = ParseAlarmKind(evt.AlarmChanged.Kind),
Condition = new AlarmConditionState(
Active: evt.AlarmChanged.Active,
Acknowledged: evt.AlarmChanged.Acknowledged,
Confirmed: evt.AlarmChanged.Confirmed,
Shelve: AlarmShelveStateCodec.Parse(evt.AlarmChanged.ShelveState),
Suppressed: evt.AlarmChanged.Suppressed,
Severity: evt.AlarmChanged.Priority),
SourceReference = evt.AlarmChanged.SourceReference ?? string.Empty,
AlarmTypeName = evt.AlarmChanged.AlarmTypeName ?? string.Empty,
Category = evt.AlarmChanged.Category ?? string.Empty,
OperatorUser = evt.AlarmChanged.OperatorUser ?? string.Empty,
OperatorComment = evt.AlarmChanged.OperatorComment ?? string.Empty,
OriginalRaiseTime = evt.AlarmChanged.OriginalRaiseTime?.ToDateTimeOffset(),
CurrentValue = evt.AlarmChanged.CurrentValue ?? string.Empty,
LimitValue = evt.AlarmChanged.LimitValue ?? string.Empty,
NativeSourceCanonicalName = evt.AlarmChanged.NativeSourceCanonicalName ?? string.Empty,
IsConfiguredPlaceholder = evt.AlarmChanged.IsConfiguredPlaceholder,
// MES alarm-status API §6.4: ack instant; an absent proto Timestamp (unacked
// condition, computed alarm, or a pre-AckTime site) stays null.
AckTime = evt.AlarmChanged.AckTime?.ToDateTimeOffset(),
// Gitea #60: a pre-#60 site leaves the field at its proto3 default (false).
IsMesReportable = evt.AlarmChanged.IsMesReportable
},
SiteStreamEvent.EventOneofCase.AlarmChanged => ConvertAlarmChanged(evt.AlarmChanged),
_ => null
};
/// <summary>
/// Maps one <see cref="AlarmStateUpdate"/> to the domain record. The wire carries only the raw
/// <c>priority</c> (its pre-#129 meaning, unchanged), so the unified, higher-is-more-urgent
/// Severity is derived here from the kind (Gitea #129): banded for MxAccess and computed rows,
/// verbatim for OPC UA. The raw value stays on <see cref="AlarmConditionState.Priority"/>.
/// </summary>
private static AlarmStateChanged ConvertAlarmChanged(AlarmStateUpdate update)
{
var kind = ParseAlarmKind(update.Kind);
return new AlarmStateChanged(
update.InstanceUniqueName,
update.AlarmName,
MapAlarmState(update.State),
update.Priority,
update.Timestamp.ToDateTimeOffset())
{
Level = MapAlarmLevel(update.Level),
Message = update.Message ?? string.Empty,
// Native alarm enrichment (additive — computed alarms carry their default condition).
Kind = kind,
Condition = new AlarmConditionState(
Active: update.Active,
Acknowledged: update.Acknowledged,
Confirmed: update.Confirmed,
Shelve: AlarmShelveStateCodec.Parse(update.ShelveState),
Suppressed: update.Suppressed,
Severity: AlarmConditionStateFactory.SeverityFromPriority(kind, update.Priority),
Priority: update.Priority),
SourceReference = update.SourceReference ?? string.Empty,
AlarmTypeName = update.AlarmTypeName ?? string.Empty,
Category = update.Category ?? string.Empty,
OperatorUser = update.OperatorUser ?? string.Empty,
OperatorComment = update.OperatorComment ?? string.Empty,
OriginalRaiseTime = update.OriginalRaiseTime?.ToDateTimeOffset(),
CurrentValue = update.CurrentValue ?? string.Empty,
LimitValue = update.LimitValue ?? string.Empty,
NativeSourceCanonicalName = update.NativeSourceCanonicalName ?? string.Empty,
IsConfiguredPlaceholder = update.IsConfiguredPlaceholder,
// MES alarm-status API §6.4: ack instant; an absent proto Timestamp (unacked
// condition, computed alarm, or a pre-AckTime site) stays null.
AckTime = update.AckTime?.ToDateTimeOffset(),
// Gitea #60: a pre-#60 site leaves the field at its proto3 default (false).
IsMesReportable = update.IsMesReportable
};
}
/// <summary>
/// Maps a proto <see cref="SiteStreamEvent"/> to a domain <see cref="AlarmStateChanged"/>,
/// returning <c>null</c> for any non-alarm event. Used by <see cref="SubscribeSiteAsync"/>
@@ -7,6 +7,7 @@ using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionK
// 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;
@@ -20,14 +21,35 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// </summary>
public static class MxGatewayAlarmMapper
{
/// <summary>Clamps the gateway severity onto the unified 0–1000 scale.</summary>
/// <param name="severity">Raw gateway severity value to clamp.</param>
/// <returns>The severity clamped to the range [0, 1000].</returns>
public static int NormalizeSeverity(int severity) => Math.Clamp(severity, 0, 1000);
/// <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">Raw gateway severity value; normalized to 0–1000.</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)
{
@@ -38,8 +60,7 @@ public static class MxGatewayAlarmMapper
ProtoConditionState.Inactive => (false, true),
_ => (false, true)
};
return new AlarmConditionState(active, acked, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
return BuildCondition(active, acked, severity);
}
/// <summary>Maps a gateway transition kind to the unified transition kind.</summary>
@@ -65,8 +86,7 @@ public static class MxGatewayAlarmMapper
ProtoTransitionKind.Clear => (false, true),
_ => (false, true)
};
return new AlarmConditionState(active, acked, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
return BuildCondition(active, acked, severity);
}
/// <summary>
@@ -63,11 +63,18 @@ public static class OpcUaAlarmMapper
/// <param name="shelve">The current shelving state of the alarm.</param>
/// <param name="suppressed">Whether the alarm is suppressed.</param>
/// <param name="severity">The raw OPC UA severity (1–1000); clamped via <see cref="NormalizeSeverity"/>.</param>
/// <returns>The composed <see cref="AlarmConditionState"/>.</returns>
/// <returns>
/// The composed <see cref="AlarmConditionState"/>. OPC UA Part 9 Severity is already the unified
/// direction (higher = more urgent), so it is used as-is; OPC UA has no separate priority, so
/// <see cref="AlarmConditionState.Priority"/> repeats it (Gitea #129).
/// </returns>
public static AlarmConditionState BuildCondition(
bool active, bool acked, bool? confirmed, AlarmShelveState shelve, bool suppressed, int severity) =>
new(Active: active, Acknowledged: acked, Confirmed: confirmed,
Shelve: shelve, Suppressed: suppressed, Severity: NormalizeSeverity(severity));
bool active, bool acked, bool? confirmed, AlarmShelveState shelve, bool suppressed, int severity)
{
var normalized = NormalizeSeverity(severity);
return new(Active: active, Acknowledged: acked, Confirmed: confirmed,
Shelve: shelve, Suppressed: suppressed, Severity: normalized, Priority: normalized);
}
/// <summary>
/// Derives the transition kind from the change in active/acked sub-states.
@@ -5,6 +5,7 @@ using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
@@ -92,11 +93,15 @@ public class AlarmActor : ReceiveActor
private readonly ISiteEventLogger? _siteEventLogger;
/// <summary>
/// Priority at or above which a computed-alarm raise is logged as
/// Severity at or above which a computed-alarm raise is logged as
/// <c>Error</c> to the site event log; below it, raises log as <c>Warning</c>.
/// Mirrors the 0–1000 alarm-severity scale.
/// Compared against the alarm's priority banded onto the unified severity
/// (<see cref="AlarmPriorityBands"/>, higher = more urgent; Gitea #129) — the same
/// threshold <c>NativeAlarmActor</c> applies — so priority 1–500 (Critical/High)
/// logs as Error and 501+ (Medium/Low) as Warning. Before #129 the raw priority was
/// compared, which ranked the least urgent alarms (700+) as errors.
/// </summary>
private const int ErrorPriorityThreshold = 700;
private const int ErrorSeverityThreshold = 700;
private AlarmState _currentState = AlarmState.Normal;
/// <summary>
@@ -471,13 +476,13 @@ public class AlarmActor : ReceiveActor
}
/// <summary>
/// Maps an alarm priority (0–1000) to a site-event severity for a
/// <i>raise</i> transition — <c>Error</c> at or above
/// <see cref="ErrorPriorityThreshold"/>, otherwise <c>Warning</c>. Clears and
/// Maps an alarm priority (0–1000, lower = more urgent) to a site-event severity
/// for a <i>raise</i> transition — <c>Error</c> when its banded severity is at or
/// above <see cref="ErrorSeverityThreshold"/>, otherwise <c>Warning</c>. Clears and
/// inter-band transitions always log as <c>Info</c>.
/// </summary>
private static string RaiseSeverity(int priority) =>
priority >= ErrorPriorityThreshold ? "Error" : "Warning";
internal static string RaiseSeverity(int priority) =>
AlarmPriorityBands.ToSeverity(priority) >= ErrorSeverityThreshold ? "Error" : "Warning";
/// <summary>
/// Fire-and-forget an <c>alarm</c> operational event to the optional
@@ -43,7 +43,9 @@ public class NativeAlarmActor : ReceiveActor
/// <summary>
/// Severity at or above which a native-alarm raise is logged as
/// <c>Error</c> to the site event log; below it, raises log as <c>Warning</c>.
/// Mirrors the 0–1000 condition-severity scale.
/// On the unified 0–1000 condition-severity scale, higher = more urgent for every
/// source (Gitea #129): an MxAccess alarm in AVEVA's Critical (priority 1–250 → 1000)
/// or High (251–500 → 750) band logs as Error, Medium/Low as Warning.
/// </summary>
private const int ErrorSeverityThreshold = 700;
@@ -217,6 +219,8 @@ public class NativeAlarmActor : ReceiveActor
continue;
}
condition = UpgradeLegacyCondition(condition, _nativeKind);
// Restore the persisted display metadata (UA4) so the rehydrated condition renders
// fully (type/category/message/values) until the first source snapshot replaces it.
// Null/malformed metadata falls back to empty strings (pre-UA4 rows behave as before).
@@ -481,7 +485,10 @@ public class NativeAlarmActor : ReceiveActor
_instanceName,
t.SourceReference,
condition.Active ? AlarmState.Active : AlarmState.Normal,
condition.Severity,
// Gitea #129: the record's Priority (and the stream's AlarmStateUpdate.priority) is the
// source's raw number — what Severity carried before #129 — so the MES 900-999 band and
// an older central keep reading the same value. Severity rides on Condition.
condition.Priority ?? condition.Severity,
t.TransitionTime)
{
Kind = _nativeKind,
@@ -497,7 +504,7 @@ public class NativeAlarmActor : ReceiveActor
LimitValue = t.LimitValue,
NativeSourceCanonicalName = _source.CanonicalName,
// Gitea #59: the binding-level flag ("every condition under this source is
// MES-reportable"). Additive to the 900-999 severity band the consumer applies.
// MES-reportable"). Additive to the 900-999 priority band the consumer applies.
IsMesReportable = _source.IsMesReportable,
// MES alarm-status API §6.4: carried verbatim from the transition — the DCL
// adapter already decided whether an ack instant applies (null while unacked,
@@ -515,6 +522,32 @@ public class NativeAlarmActor : ReceiveActor
}
}
/// <summary>
/// Upgrades a condition persisted in <c>native_alarm_state</c> by a pre-#129 build, whose
/// <see cref="AlarmConditionState.Severity"/> held the raw source number and which has no
/// <see cref="AlarmConditionState.Priority"/>. That number becomes the Priority; for an
/// MxAccess source it is an AVEVA priority, so Severity is re-derived through
/// <see cref="AlarmPriorityBands"/>. An OPC UA row's Severity was already Part 9 and is kept.
/// A current row (Priority present) is returned unchanged.
/// </summary>
/// <param name="condition">The deserialized persisted condition.</param>
/// <param name="kind">This actor's native alarm kind.</param>
/// <returns>The condition in the post-#129 shape.</returns>
internal static AlarmConditionState UpgradeLegacyCondition(AlarmConditionState condition, AlarmKind kind)
{
if (condition.Priority is not null)
{
return condition;
}
var raw = condition.Severity;
return condition with
{
Severity = AlarmConditionStateFactory.SeverityFromPriority(kind, raw),
Priority = raw,
};
}
/// <summary>
/// Fire-and-forget an <c>alarm</c> operational event mirroring a native
/// condition transition. An active condition is a raise (severity by the
@@ -1393,7 +1393,9 @@ public class ScriptRuntimeContext
CurrentValue: a.CurrentValue,
LimitValue: a.LimitValue,
IsConfiguredPlaceholder: a.IsConfiguredPlaceholder,
IsMesReportable: a.IsMesReportable);
IsMesReportable: a.IsMesReportable,
// Gitea #129: the raw source number (pre-#129 Severity) — the MES 900-999 band keys off it.
Priority: a.Priority);
/// <summary>
/// Site-local tracking-status API for cached operations.