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
@@ -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"/>