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:
@@ -113,7 +113,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
- Site nodes carry **two Akka roles**: the base `Site` plus a site-specific `site-{SiteId}` (`AkkaHostedService.BuildRoles`). Singletons scope to the **site-specific** role.
|
||||
- **The gRPC boundary is authenticated (PSK) as of 2026-07-22; Akka remoting still is not, and nothing is encrypted.** Akka remoting sets no `enable-ssl`, no secure cookie, no `trusted-selection-paths` — so intra-cluster Akka remoting remains open to anyone who can reach the remoting port, and that boundary still assumes a trusted network. The gRPC listener stays **h2c**, but `SiteStreamService` is no longer open: `ControlPlaneAuthInterceptor` (`Host/ControlPlaneAuthInterceptor.cs`) gates `/sitestream.SiteStreamService/` — including the `PullAuditEvents`/`PullSiteCalls` RPCs that return audit rows — against a **per-site preshared key**, fail-closed, constant-time compared, alongside the separate `LocalDbSyncAuthInterceptor` on `/localdb_sync.v1.LocalDbSync/` with its own separate key. **Site side:** `ScadaBridge:Communication:GrpcPsk`, in production `${secret:SB-GRPC-PSK-<siteId>}`, and **`StartupValidator` refuses to boot a site node without it** (an unset key would leave the node healthy-looking but serving nothing). **Central side:** `SitePskProvider` resolves `SB-GRPC-PSK-{siteId}` from the secrets store at channel-build time (sites are added at runtime, so no boot-time expansion is possible), with `ScadaBridge:Communication:SitePsks:{siteId}` as an override for hosts running without a master key — the docker rig uses the latter. One key per site, never fleet-wide. A bearer token over h2c is readable and replayable on-path; TLS is the follow-on hardening and needs no change to this design. Introduced by Phase 0 of the ClusterClient→gRPC migration (`docs/plans/2026-07-22-clusterclient-to-grpc-plan.md`).
|
||||
- gRPC streaming channel — **note the direction is inverted from the data flow**: data moves site→central, but each **site node hosts the gRPC server** (`SiteStreamGrpcServer`, Kestrel h2c, port 8083, mapped **only in the Site branch** of `Program.cs`) and **central is the client**, dialling in. Central creates per-site `SiteStreamGrpcClient` via `SiteStreamGrpcClientFactory`, keyed **`(siteId, endpoint)`** — the key was widened from site-only to fix an arch-review High where one session's NodeA→NodeB flip disposed a channel another session was still using. Proto evolution is **additive only** and field numbers are never reused (`AlarmStateUpdate` grew 7→23 fields for the native-alarm mirror, then 24 for `AckTime`, then 25 for `IsMesReportable` — Gitea #60). Generated C# is **vendored** under `Communication/SiteStreamGrpc/` with the `<Protobuf>` include commented out — regeneration is a manual toggle-build-copy-untoggle, automated by `docker/regen-proto.sh [sitestream|centralcontrol|sitecommand|all]` (it always restores the csproj; an active `<Protobuf>` item must never be committed — it breaks the Docker build). **Stream events are BATCHED as of R2 (2026-08-15)** — a site emitting ~37.5k events/s used to pay one gRPC message per event. Additive wire shape, no new RPC: `batching_supported` on the request (`InstanceStreamRequest` field 3 / `SiteStreamRequest` field 2) plus a `SiteStreamEvent.batch` oneof case (**field 4**) carrying a new `SiteStreamEventBatch { repeated SiteStreamEvent events = 1 }`. **The proto3 default of that flag IS the negotiation, and it is load-bearing** — a batch frame reaches a pre-R2 central as `EventOneofCase.None`, whose `ConvertToDomainEvent` returns null, so the whole batch would vanish with no error anywhere; an old central cannot set the flag, so it never gets one, and an old site ignores the unknown request field and keeps sending per-event frames the new client's `ForEachEvent` unpack handles as the single-event case. Server side is `SiteStreamEventBatcher`, a per-subscriber pump **downstream of `StreamRelayActor`'s bounded DropOldest channel** — so it changes framing only and **does NOT move the burst ceiling** (deferred register row 31; that ceiling lives in the shared publish stage upstream of the BroadcastHub). It never delays a lone event: it drains the already-queued backlog for free and lingers only once a backlog is proven, then emits a single-event buffer as a plain frame. `GrpcStreamBatchMaxEvents` (100, `1` disables) and `GrpcStreamBatchWindow` (25 ms, validated **strictly under 250 ms** = the load test's end-to-end P99 threshold); measured worst case P99 25.4 ms. **Benchmark-proven at target scale 2026-08-18** (the LoadHarness wire path — real Kestrel h2c + real `SiteStreamGrpcClient`, `--wire-probes-per-site`): 899,807 events in 12,462 frames (mean 72.2 ev/frame), wire P99 43.4 ms (5.8× inside the 250 ms budget — a 75-event burst never hits the 100 cap, so every batch pays the full 25 ms window: this workload's worst-case latency posture), zero drops/faults; results doc §8. Histogram `scadabridge.site.stream.batch_size`, recorded only on negotiated streams (`ScadaBridgeTelemetry.MeterName` is already in the `ObservedMeters` allowlist). **The client reconnects on graceful (OK-status) stream completion, not just on fault (arch-review remediation, commit `34a3f4bb`)** — Kestrel's `MaxStreamLifetime`/`Grpc.AspNetCore.Server` max-connection-age periodically ends a healthy stream with a normal completion, which the client used to treat as terminal (no reconnect attempt), silently killing a site's live feed until the next process restart (observed up to ~4h on the rig); live-probed at a 2-minute forced lifetime, reconnect lands within one reconcile tick and `IsLive` reflects the gap in between.
|
||||
- Native alarms are a **read-only** mirror of OPC UA Alarms & Conditions and MxAccess Gateway alarms — **no ack-back, no central tables**; state lives in the site's `native_alarm_state`, survives failover, and is cleared on redeploy/undeploy (mirrors static overrides). Central's per-site live alarm cache (`ISiteAlarmLiveCache`) is **transient in-memory only** — there is deliberately no persisted central alarm store, so the 15s poll remains the NotReporting authority behind the live stream. See `Component-DataConnectionLayer.md` / `Component-CentralUI.md` for the model and the authoring surface. Computed alarms carry an `IsMesReportable` flag (template checkbox, instance-overridable via a nullable override) exposed to scripts as `ScriptAlarm.IsMesReportable` / `Alarms.MesReportableAsync()` (2026-09-11); native alarms have no per-alarm row and keep the 900–999 MES severity band.
|
||||
- Native alarms are a **read-only** mirror of OPC UA Alarms & Conditions and MxAccess Gateway alarms — **no ack-back, no central tables**; state lives in the site's `native_alarm_state`, survives failover, and is cleared on redeploy/undeploy (mirrors static overrides). Central's per-site live alarm cache (`ISiteAlarmLiveCache`) is **transient in-memory only** — there is deliberately no persisted central alarm store, so the 15s poll remains the NotReporting authority behind the live stream. See `Component-DataConnectionLayer.md` / `Component-CentralUI.md` for the model and the authoring surface. Computed alarms carry an `IsMesReportable` flag (template checkbox, instance-overridable via a nullable override) exposed to scripts as `ScriptAlarm.IsMesReportable` / `Alarms.MesReportableAsync()` (2026-09-11); native alarms have no per-alarm row and keep the 900–999 MES **priority** band. **Severity runs one direction (Gitea #129, 2026-11-12):** `AlarmConditionState.Severity` is higher = more urgent for every kind — OPC UA Part 9 verbatim; MxAccess and computed priorities (lower = more urgent) banded 1–250→1000, 251–500→750, 501–750→500, 751–999→250 (`AlarmPriorityBands`, = OtOpcUa `GalaxyAlarmPriority`, lmxopcua#887). The raw number lives on `AlarmConditionState.Priority` / `AlarmStateChanged.Priority` / `ScriptAlarm.Priority` (= pre-#129 Severity; the wire `AlarmStateUpdate.priority` is unchanged and central derives Severity from it by kind); the MES band and the MES API `Severity`/`MinSeverity`/`MaxSeverity` read `Priority`, never `Severity`.
|
||||
- **`AckTime` mirror enrichment + the `Alarms` script accessor (MES alarm-status API Phase 1, 2026-08-01).** `AlarmStateChanged` carries an additive `AckTime` (`DateTimeOffset?`), mirrored on the vendored `AlarmStateUpdate` proto as **field 24** and persisted inside `native_alarm_state`'s `metadata_json` — deliberately NOT a new column, because that table is `RegisterReplicated` and LocalDb builds its CDC triggers from the column list at registration time. Set only while a condition is active AND acknowledged (so it is null while unacked and cleared on re-raise); the DCL stamps the source's own ack instant for OPC UA (new SelectClause **index 18** = `AckedState/TransitionTime`) and its observation time of the ack transition for MxGateway, which supplies none. Site `Call` scripts read alarms via the new **`Alarms.CurrentAsync()`** accessor (`ScriptRuntimeContext` + `ScriptGlobals`, local Ask on `GetAlarmSnapshotRequest`, returns `Commons.Types.Scripts.ScriptAlarm`), mirrored on `ScriptCompileSurface` AND the Central UI `SandboxScriptHost` editor surface. The trust model needed no change — it is a deny-list over API roots, not an allow-list of context members. Plan: `docs/plans/2026-06-30-mes-alarm-status-api.md` (Phases 2–4 are deployed config, not repo).
|
||||
- OPC UA cert trust is **site-local and not persisted centrally** (follow-up): the verify-endpoint probe captures an untrusted server cert but **NEVER trusts it**, and DeploymentManager broadcasts `TrustServerCertCommand`/`RemoveServerCertCommand` to **BOTH** site nodes — `CertStoreActor` runs on every site node, not as a singleton, so PKI stores stay consistent across failover.
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ print(next((str(c["id"]) for c in (t.get(coll) or []) if c.get("name")==name), "
|
||||
# (AlarmConditionStateFactory.ForComputed) — there is no ack-back for them — so StatusCode
|
||||
# reads "Triggered.Acked" and AckDT is null on this demo. Native (mirrored) conditions are
|
||||
# where the ack fields carry real source data.
|
||||
# MES `Severity` / `MinSeverity` / `MaxSeverity` are the raw 0-999 priority, read from
|
||||
# `ScriptAlarm.Priority` — NOT `ScriptAlarm.Severity`, which since Gitea #129 is the banded,
|
||||
# higher-is-more-urgent severity (priority 1-250 -> 1000 ... 751-999 -> 250). Priority keeps the
|
||||
# value Severity carried before #129, so MES sees exactly the same numbers and filters as before.
|
||||
read -r -d '' SITE_SCRIPT_CODE <<'CS' || true
|
||||
var code = Parameters["MachineCode"]?.ToString() ?? "";
|
||||
var flaggedOnly = Parameters.Get<bool?>("FlaggedOnly") ?? false;
|
||||
@@ -79,8 +83,8 @@ try {
|
||||
var alarms = flaggedOnly ? await Alarms.MesReportableAsync() : await Alarms.CurrentAsync();
|
||||
var infos = alarms
|
||||
.Where(a => a.Active && !a.IsConfiguredPlaceholder)
|
||||
.Where(a => minSev == null || a.Severity >= minSev)
|
||||
.Where(a => maxSev == null || a.Severity <= maxSev)
|
||||
.Where(a => minSev == null || a.Priority >= minSev)
|
||||
.Where(a => maxSev == null || a.Priority <= maxSev)
|
||||
.Where(a => string.IsNullOrEmpty(nameFilter) || a.Name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(a => includeAck || !a.Acknowledged)
|
||||
.Select(a => new {
|
||||
@@ -88,7 +92,7 @@ try {
|
||||
HierarchicalName = code + "." + a.Name,
|
||||
Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message,
|
||||
IsFlaggedForMES = a.IsMesReportable,
|
||||
Severity = a.Severity,
|
||||
Severity = a.Priority, // MES Severity = the raw 0-999 priority (ScriptAlarm.Priority, #129)
|
||||
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
|
||||
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp).ToString("o"),
|
||||
AckDT = a.AckTime?.ToString("o"),
|
||||
|
||||
@@ -302,6 +302,10 @@ CS
|
||||
# The MES alarm-status projection (same as docker/seed-mes-alarm-demo.sh). Computed alarms report
|
||||
# Acknowledged=true / AckTime=null by construction, so StatusCode reads "Triggered.Acked" and
|
||||
# IncludeAcked=false drops every computed alarm (it only bites on mirrored native conditions).
|
||||
# MES `Severity` / `MinSeverity` / `MaxSeverity` are the raw 0-999 priority, read from
|
||||
# `ScriptAlarm.Priority` — NOT `ScriptAlarm.Severity`, which since Gitea #129 is the banded,
|
||||
# higher-is-more-urgent severity (priority 1-250 -> 1000 ... 751-999 -> 250). Priority keeps the
|
||||
# value Severity carried before #129, so MES sees exactly the same numbers and filters as before.
|
||||
read -r -d '' ALARM_CODE <<'CS' || true
|
||||
var code = Parameters["MachineCode"]?.ToString() ?? "";
|
||||
var flaggedOnly = Parameters.Get<bool?>("FlaggedOnly") ?? false;
|
||||
@@ -313,8 +317,8 @@ try {
|
||||
var alarms = flaggedOnly ? await Alarms.MesReportableAsync() : await Alarms.CurrentAsync();
|
||||
var infos = alarms
|
||||
.Where(a => a.Active && !a.IsConfiguredPlaceholder)
|
||||
.Where(a => minSev == null || a.Severity >= minSev)
|
||||
.Where(a => maxSev == null || a.Severity <= maxSev)
|
||||
.Where(a => minSev == null || a.Priority >= minSev)
|
||||
.Where(a => maxSev == null || a.Priority <= maxSev)
|
||||
.Where(a => string.IsNullOrEmpty(nameFilter) || a.Name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(a => includeAck || !a.Acknowledged)
|
||||
.Select(a => new {
|
||||
@@ -322,7 +326,7 @@ try {
|
||||
HierarchicalName = code + "." + a.Name,
|
||||
Description = !string.IsNullOrEmpty(a.Message) ? a.Message : !string.IsNullOrEmpty(a.AlarmTypeName) ? a.AlarmTypeName : a.Name,
|
||||
IsFlaggedForMES = a.IsMesReportable,
|
||||
Severity = a.Severity,
|
||||
Severity = a.Priority, // MES Severity = the raw 0-999 priority (ScriptAlarm.Priority, #129)
|
||||
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
|
||||
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp).ToString("o"),
|
||||
AckDT = a.AckTime?.ToString("o"),
|
||||
|
||||
@@ -164,8 +164,10 @@ The override **routes left vs right off the SAPID suffix** — `_A` ⇒ Left, `_
|
||||
|
||||
```csharp
|
||||
// CvdReactor.SimpleAlarmStatus (sketch)
|
||||
// Q1 DECIDED: MES relevance = dedicated severity band. Galaxy alarm priorities for
|
||||
// Q1 DECIDED: MES relevance = dedicated band. Galaxy alarm priorities for
|
||||
// MES-relevant alarms are configured into 900-999; nothing else may use that band.
|
||||
// Gitea #129: the band is the RAW priority, ScriptAlarm.Priority. ScriptAlarm.Severity is
|
||||
// banded (higher = more urgent) since #129 and never lands in 900-999 for these alarms.
|
||||
const int MesBandMin = 900;
|
||||
const int MesBandMax = 999;
|
||||
try {
|
||||
@@ -189,13 +191,13 @@ try {
|
||||
.Where(a => a.Active && !a.IsConfiguredPlaceholder) // only triggered
|
||||
.Where(a => InScope(a.NativeSourceCanonicalName)) // _A => Left*, _B => Right*, + ReactorAlarms
|
||||
// SimpleAlarmStatus semantics: flagged-only (= MES band, Q1) + acked always included
|
||||
.Where(a => a.Severity >= MesBandMin && a.Severity <= MesBandMax)
|
||||
.Where(a => a.Priority >= MesBandMin && a.Priority <= MesBandMax)
|
||||
.Select(a => new {
|
||||
Name = a.Name,
|
||||
HierarchicalName = code + "." + a.Name,
|
||||
Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message, // Q3 DECIDED
|
||||
IsFlaggedForMES = a.Severity >= MesBandMin && a.Severity <= MesBandMax, // Q1 DECIDED: real predicate
|
||||
Severity = a.Severity,
|
||||
IsFlaggedForMES = a.Priority >= MesBandMin && a.Priority <= MesBandMax, // Q1 DECIDED: real predicate
|
||||
Severity = a.Priority, // MES Severity = raw 0-999 priority (#129)
|
||||
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
|
||||
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp),
|
||||
AckDT = a.AckTime, // Q4 DECIDED: enriched mirror
|
||||
@@ -214,7 +216,7 @@ try {
|
||||
|
||||
## 6. Key design decisions — ALL DECIDED 2026-08-01 (design review with user)
|
||||
|
||||
1. **Superseded for COMPUTED alarms 2026-09-11** by `2026-09-11-mes-reportable-alarm-flag-design.md` — still the rule for native alarms. **`IsFlaggedForMES` for native alarms — DECIDED: severity band.** MES relevance is encoded in the alarm severity itself: MES-relevant alarms are configured (in the Galaxy alarm priority) into a **dedicated band 900–999** reserved exclusively for MES-relevant alarms, so it cannot collide with ordinary criticality tuning. Scripts carry `MesBandMin = 900` / `MesBandMax = 999` as named constants; `IsFlaggedForMES = (Severity in band)` is reported honestly per row; `SimpleAlarmStatus` (always flagged-only) and `AlarmStatus` with `FlaggedOnly=true` filter by the band predicate. Since `Severity` is returned verbatim, MES's own `MinSeverity`/`MaxSeverity` filters compose naturally with the band. No new tables, code flags, or allow-lists. **Operational prerequisite:** the Galaxy alarm priorities for MES-relevant CvdReactor alarms must be set into 900–999 before the endpoints are meaningful.
|
||||
1. **Superseded for COMPUTED alarms 2026-09-11** by `2026-09-11-mes-reportable-alarm-flag-design.md` — still the rule for native alarms. **`IsFlaggedForMES` for native alarms — DECIDED: severity band.** MES relevance is encoded in the alarm severity itself: MES-relevant alarms are configured (in the Galaxy alarm priority) into a **dedicated band 900–999** reserved exclusively for MES-relevant alarms, so it cannot collide with ordinary criticality tuning. Scripts carry `MesBandMin = 900` / `MesBandMax = 999` as named constants; `IsFlaggedForMES = (Priority in band)` is reported honestly per row (**Gitea #129, 2026-11-12:** the band and the MES `Severity` field read `ScriptAlarm.Priority`, the raw priority — `ScriptAlarm.Severity` is now the banded, higher-is-more-urgent severity; deployed scripts that test `a.Severity` must switch to `a.Priority`); `SimpleAlarmStatus` (always flagged-only) and `AlarmStatus` with `FlaggedOnly=true` filter by the band predicate. Since `Severity` is returned verbatim, MES's own `MinSeverity`/`MaxSeverity` filters compose naturally with the band. No new tables, code flags, or allow-lists. **Operational prerequisite:** the Galaxy alarm priorities for MES-relevant CvdReactor alarms must be set into 900–999 before the endpoints are meaningful.
|
||||
|
||||
2. **MESReceiver version — DECIDED: dropped entirely.** With no native alarm sources there is no live state; a config-only catalog answer was judged not worth shipping. Only CvdReactor implements the scripts; other machines get the router's clean "not supported on machine" error (§5.1). No BTDB `MachineAlarm` dependency remains.
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ Both enriched `AlarmStateChanged` events (live, via the gRPC stream) and the ini
|
||||
|
||||
Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms, and the alarm itself for computed alarms):
|
||||
- **Kind badge** — distinguishes **Computed** alarms from native ones (**OPC UA** or **MxAccess**), driven by the event's `AlarmKind` discriminator.
|
||||
- **Sev** — the unified **0–1000 severity** (`AlarmConditionState.Severity`). Computed rows surface their integer priority on the same scale.
|
||||
- **Sev** — the unified **0–1000 severity** (`AlarmConditionState.Severity`), **higher = more urgent for every kind** (Gitea #129). OPC UA rows show their Part 9 Severity; MxAccess and computed rows show their priority banded: AVEVA priority 1–250 → 1000, 251–500 → 750, 501–750 → 500, 751–999 → 250 (`AlarmPriorityBands`, the same table as OtOpcUa `GalaxyAlarmPriority`, lmxopcua#887). The Alarm Summary's worst-severity tile (max), severity-threshold filter (`>=`) and severity sort (descending default) all read this one direction. The raw priority stays on `AlarmConditionState.Priority` / `AlarmStateChanged.Priority`.
|
||||
- **Source reference subtitle** — for native rows, the `SourceReference` (e.g. `Tank01.Level.HiHi`) renders as a monospace subtitle. Computed rows have no subtitle.
|
||||
- **State badges** — orthogonal condition sub-states: **Unacked**, **Shelved**, and **Suppressed** appear only when the corresponding `AlarmConditionState` flag is set. Computed alarms are auto-acked and never shelved/suppressed.
|
||||
- **Row tooltip** — surfaces native metadata not warranting its own column: `AlarmTypeName`, category, operator user and comment, original raise time, current/limit value.
|
||||
|
||||
@@ -37,7 +37,7 @@ Commons must define shared primitive and utility types used across multiple comp
|
||||
- **`AlarmKind` enum**: Computed, NativeOpcUa, NativeMxAccess. Discriminates how an alarm's state is produced — evaluated at the site by an `AlarmActor` from attribute triggers (`Computed`) vs. mirrored read-only from a native source (OPC UA Alarms & Conditions / MxAccess Gateway).
|
||||
- **`AlarmShelveState` enum**: Unshelved, OneShotShelved, TimedShelved, PermanentShelved. OPC UA Part 9 shelving sub-state of an alarm condition; mirrored read-only from the source. Computed alarms are always `Unshelved`.
|
||||
- **`AlarmTransitionKind` enum**: Snapshot, SnapshotComplete, Raise, Acknowledge, Clear, Retrigger, StateChange. Classifies a `NativeAlarmTransition`; `Snapshot`/`SnapshotComplete` carry the initial active-condition replay produced on every (re)subscribe so consumers can re-seed state.
|
||||
- **`AlarmConditionState`**: The unified, read-only alarm condition state. The OPC UA Part 9 sub-conditions are orthogonal (and MxAccess's ACTIVE / ACTIVE_ACKED / INACTIVE map cleanly onto them), so they are modeled as independent flags — `Active`, `Acknowledged`, `Confirmed` (`bool?`; null when not confirmable), `Shelve` (`AlarmShelveState`), `Suppressed` — plus a `Severity` on the unified 0–1000 scale. Computed alarms populate it from State + Priority; native alarms mirror it from the source. (Helper `AlarmConditionStateFactory` builds the computed-alarm projection.)
|
||||
- **`AlarmConditionState`**: The unified, read-only alarm condition state. The OPC UA Part 9 sub-conditions are orthogonal (and MxAccess's ACTIVE / ACTIVE_ACKED / INACTIVE map cleanly onto them), so they are modeled as independent flags — `Active`, `Acknowledged`, `Confirmed` (`bool?`; null when not confirmable), `Shelve` (`AlarmShelveState`), `Suppressed` — plus a `Severity` on the unified 0–1000 scale, **higher = more urgent for every source** (Gitea #129), and a trailing `Priority` (`int?`). OPC UA rows carry their Part 9 Severity (and repeat it as `Priority` — OPC UA has no separate priority); MxAccess Gateway rows and computed alarms band their AVEVA-style priority (lower = more urgent) onto `Severity` — AVEVA priority 1–250 → 1000, 251–500 → 750, 501–750 → 500, 751–999 → 250 (`AlarmPriorityBands`, the same table as OtOpcUa `GalaxyAlarmPriority`, lmxopcua#887) — and keep the raw value on `Priority`, which is exactly what `Severity` held before #129 and what the native MES 900–999 band keys off. Never sort or threshold on `Priority`; its direction depends on the source. `Priority` is null only on a pre-#129 persisted row (the native mirror upgrades it on rehydrate). (Helper `AlarmConditionStateFactory` builds the computed-alarm projection and `SeverityFromPriority(kind, priority)` for central's stream decode.)
|
||||
- **`NativeAlarmTransition`**: The protocol-neutral alarm transition emitted by an `IAlarmSubscribableConnection` adapter — `SourceReference` (stable per-condition key), `SourceObjectReference` (owning source object, used for instance routing), `AlarmTypeName`, `Kind` (`AlarmTransitionKind`), `Condition` (`AlarmConditionState`), source taxonomy/operator/value metadata (`Category`, `Description`, `Message`, `OperatorUser`, `OperatorComment`, `CurrentValue`, `LimitValue`), and `OriginalRaiseTime?` / `TransitionTime`.
|
||||
- **`ConnectionHealth` enum**: Connected, Disconnected, Connecting, Error.
|
||||
- **`TrackedOperationId`**: A GUID identifying a tracked store-and-forward operation (`ExternalSystem.CachedCall`, `Database.CachedWrite`, `Notify.Send`). Generated caller-side at the site at call time, returned to the script as a tracking handle, and reused as the idempotency key for telemetry sent to central. The notification domain's existing `NotificationId` is the notification-specific name for this same concept.
|
||||
|
||||
@@ -251,7 +251,7 @@ read the traffic". TLS is follow-on hardening and does not change this design.
|
||||
|
||||
- **Server-side mapping (`StreamRelayActor.HandleAlarmStateChanged`)**: maps the enriched domain `AlarmStateChanged` event — `Kind` + `AlarmConditionState` + native metadata — out to the proto `AlarmStateUpdate`. The nullable `original_raise_time` is emitted only when present, and `shelve_state` is mapped from the domain shelve enum to its wire string via a new **`AlarmShelveStateCodec`** (string↔enum, defaulting to `Unshelved`). The domain `Confirmed` (`bool?`) is collapsed to a definite bool for field 11.
|
||||
- **Placeholder rows are dropped at the relay**: `is_configured_placeholder` (field 23) is a **Debug View snapshot-only** concept emitted by `InstanceActor.BuildAlarmStatesSnapshot` for quiet bindings — it is never a real alarm transition (its timestamp may be `DateTimeOffset.MinValue`, the Protobuf `Timestamp` lower boundary). `StreamRelayActor.HandleAlarmStateChanged` therefore returns early — **never relaying a placeholder row to the live gRPC stream** — so field 23 is always `false` on the live stream and only ever carries `true` in the snapshot path.
|
||||
- **Client-side mapping (`SiteStreamGrpcClient.ConvertToDomainEvent`)**: reconstructs the domain `AlarmStateChanged` from the proto — `Kind` is parsed via `ParseAlarmKind`, the `Condition` is rebuilt with `severity` taken from the existing wire `priority`, and native metadata is repopulated from fields 8–23 (`native_source_canonical_name` → `NativeSourceCanonicalName`, `is_configured_placeholder` → `IsConfiguredPlaceholder`) — so central-side consumers receive the same domain event the site emitted.
|
||||
- **Client-side mapping (`SiteStreamGrpcClient.ConvertToDomainEvent`)**: reconstructs the domain `AlarmStateChanged` from the proto — `Kind` is parsed via `ParseAlarmKind`, the `Condition` is rebuilt from the existing wire `priority`, which keeps its pre-#129 meaning (the raw source number): `Condition.Priority` = that value and `Condition.Severity` = `AlarmConditionStateFactory.SeverityFromPriority(kind, priority)` — banded for `NativeMxAccess`/`Computed`, verbatim for `NativeOpcUa` (Gitea #129; correct for an un-upgraded site too, since the wire is unchanged). The debug-snapshot `DebugAlarmStateDto` carries `Condition.Severity` explicitly and the decoder restores `Condition.Priority` from the row's `priority`; and native metadata is repopulated from fields 8–23 (`native_source_canonical_name` → `NativeSourceCanonicalName`, `is_configured_placeholder` → `IsConfiguredPlaceholder`) — so central-side consumers receive the same domain event the site emitted.
|
||||
|
||||
> **Regeneration is manual (macOS-only).** `sitestream.proto` is **not** auto-compiled: the `<Protobuf>` include is commented out in the `.csproj`, and the generated C# is **vendored** under `SiteStreamGrpc/`. To regenerate after editing the proto: toggle the `<Protobuf>` include on, build so `Grpc.Tools` regenerates the C#, copy the generated files into `SiteStreamGrpc/`, then re-comment the include. Adding `AlarmStateUpdate` fields 8–23 and the four unary RPCs (`IngestAuditEvents`, `IngestCachedTelemetry`, `PullAuditEvents`, `PullSiteCalls`) plus their message types followed this process, as did WP2.3's two additive fields — `PullAuditEventsRequest.after_id` (field 3, `sitestream.proto`) and `DebugSnapshotRequestDto.alarms_only` (field 3, `site_command.proto`) — and R2's batching additions (`InstanceStreamRequest.batching_supported` field 3, `SiteStreamRequest.batching_supported` field 2, `SiteStreamEvent.batch` field 4 and the new `SiteStreamEventBatch` message). The same applies to `site_command.proto`/`SiteCommandGrpc/` and `central_control.proto`/`CentralControlGrpc/`; `docker/regen-proto.sh [sitestream|centralcontrol|sitecommand|all]` automates the toggle-build-copy-untoggle and always restores the csproj.
|
||||
|
||||
|
||||
@@ -355,7 +355,9 @@ All defined in Commons so the feed is identical across protocols:
|
||||
| Type | Shape |
|
||||
|------|-------|
|
||||
| `NativeAlarmTransition` | `SourceReference`, `SourceObjectReference`, `AlarmTypeName`, `Kind`, `Condition`, `Category`, `Description`, `Message`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `TransitionTime`, `CurrentValue`, `LimitValue`, `AckTime?` |
|
||||
| `AlarmConditionState` | `Active`, `Acknowledged`, `Confirmed?` (null when not confirmable), `Shelve`, `Suppressed`, `Severity` (0–1000) |
|
||||
| `AlarmConditionState` | `Active`, `Acknowledged`, `Confirmed?` (null when not confirmable), `Shelve`, `Suppressed`, `Severity` (0–1000, higher = more urgent), `Priority?` (raw source number) |
|
||||
|
||||
**Severity direction (Gitea #129).** `Severity` is OPC UA Part 9 direction for both adapters. `OpcUaAlarmMapper` clamps the server's Severity to 0–1000 and repeats it as `Priority`. `MxGatewayAlarmMapper` reads the gateway's `severity` field — which mxaccessgw fills from the AVEVA alarm **priority** (1 = most urgent) — bands it onto `Severity` (AVEVA priority 1–250 → 1000, 251–500 → 750, 501–750 → 500, 751–999 → 250 (`AlarmPriorityBands`, the same table as OtOpcUa `GalaxyAlarmPriority`, lmxopcua#887); out-of-range clamps: ≤ 0 → 1000, > 999 → 250) and keeps it on `Priority` clamped to 0–1000, i.e. the value `Severity` carried before #129. A Galaxy alarm therefore reads the same `Severity` through OtOpcUa and straight from the gateway.
|
||||
| `AlarmTransitionKind` (enum) | `Snapshot`, `SnapshotComplete`, `Raise`, `Acknowledge`, `Clear`, `Retrigger`, `StateChange`, `SnapshotStart` (appended) |
|
||||
|
||||
`OperatorUser` / `OperatorComment` and `CurrentValue` / `LimitValue` are display-only mirrors from the source.
|
||||
|
||||
@@ -254,7 +254,7 @@ Inbound API scripts **cannot** call shared scripts directly — shared scripts a
|
||||
|
||||
> **Reading alarm state via a routed script.** There is deliberately **no** `Route.To(...).GetAlarms(...)` verb. An inbound method that needs a machine's current alarms resolves the instance and calls a **site script** that uses the Site Runtime `Alarms.CurrentAsync()` accessor — `Route.To(code).Call("SimpleAlarmStatus", new { ... })`. Alarm state is per-instance and lives on the site's Instance Actor; keeping the read inside a site script means the filtering/shaping happens where the data is, and central stays a thin router. This is the shape the MES alarm-status endpoints use (`docs/plans/2026-06-30-mes-alarm-status-api.md` §5.1); see [Component-SiteRuntime.md](Component-SiteRuntime.md) → *Alarm State (read-only)* for the accessor.
|
||||
>
|
||||
> **Example — `AlarmStatus`.** The rig demo template `MesAlarmDemo`'s site script (`docker/seed-mes-alarm-demo.sh`) answers `Route.To(MachineCode).Call("AlarmStatus", new { AlarmFilter = new { FlaggedOnly, IncludeAcked, MinSeverity, MaxSeverity, NameFilter } })` with the legacy `AlarmStatusResponse { WasSuccessful, ErrorText, Alarms: AlarmInfo[] }`, setting `IsFlaggedForMES = a.IsMesReportable` per row for **computed** alarms (the template's `TemplateAlarm.IsMesReportable` flag, instance-overridable) and the **900–999 severity band** for **native** alarms, which carry no per-alarm `IsMesReportable` row. `SimpleAlarmStatus` is the same shape with `FlaggedOnly` hard-wired true.
|
||||
> **Example — `AlarmStatus`.** The rig demo template `MesAlarmDemo`'s site script (`docker/seed-mes-alarm-demo.sh`) answers `Route.To(MachineCode).Call("AlarmStatus", new { AlarmFilter = new { FlaggedOnly, IncludeAcked, MinSeverity, MaxSeverity, NameFilter } })` with the legacy `AlarmStatusResponse { WasSuccessful, ErrorText, Alarms: AlarmInfo[] }`, setting `IsFlaggedForMES = a.IsMesReportable` per row for **computed** alarms (the template's `TemplateAlarm.IsMesReportable` flag, instance-overridable) and the **900–999 priority band** (on `ScriptAlarm.Priority`) for **native** alarms, which carry no per-alarm `IsMesReportable` row. The MES `Severity` field and `MinSeverity`/`MaxSeverity` filters are that raw 0–999 priority (`ScriptAlarm.Priority`), not the unified `ScriptAlarm.Severity`, which since Gitea #129 is banded and runs higher = more urgent. `SimpleAlarmStatus` is the same shape with `FlaggedOnly` hard-wired true.
|
||||
|
||||
#### Input/Output
|
||||
- **Input parameters** are available as defined in the method definition.
|
||||
|
||||
@@ -477,7 +477,9 @@ The `AlarmStateChanged` message published by both Alarm Actors and Native Alarm
|
||||
- **`Kind`** (`AlarmKind`): `Computed` for ScadaBridge-evaluated alarms; `NativeOpcUa` / `NativeMxAccess` for mirrored native alarms.
|
||||
- **`Condition`** (`AlarmConditionState`): the unified condition view. Computed alarms supply a computed default; native alarms carry the mirrored source condition.
|
||||
- **Native metadata** (populated for native alarms; defaulted/empty for computed): `SourceReference`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime`, `CurrentValue`, `LimitValue`.
|
||||
- **Computed-alarm projection**: computed alarms are surfaced as **auto-acknowledged** with `Severity = Priority`, so a single enriched shape carries both computed and native alarms onto the stream and into the DebugView snapshot.
|
||||
- **Computed-alarm projection**: computed alarms are surfaced as **auto-acknowledged** with `Severity` = their priority banded through `AlarmPriorityBands` (AVEVA priority 1–250 → 1000, 251–500 → 750, 501–750 → 500, 751–999 → 250 (`AlarmPriorityBands`, the same table as OtOpcUa `GalaxyAlarmPriority`, lmxopcua#887); Gitea #129 — `PriorityLevel` is lower = more urgent) and `Condition.Priority` = the raw priority, so a single enriched shape carries both computed and native alarms onto the stream and into the DebugView snapshot. `AlarmStateChanged.Priority` is the raw number for every kind (a native row's `Condition.Priority`).
|
||||
- **`alarm` site-event severity**: a raise logs `Error` when its unified `Severity` is ≥ 700 (Critical/High: priority 1–500, or OPC UA Severity ≥ 700), else `Warning` — the same threshold in `AlarmActor` and `NativeAlarmActor`. Before #129 the computed path compared the raw priority, so priority 700+ (the least urgent) logged as `Error`.
|
||||
- **Pre-#129 persisted native rows**: `native_alarm_state.condition_json` written by an older build has the raw number in `Severity` and no `Priority`; `NativeAlarmActor` rehydration moves it to `Priority` and re-derives `Severity` (banded for MxAccess, kept for OPC UA).
|
||||
- **`NativeSourceCanonicalName`** (`string?`): the canonical name of the native alarm source binding a native condition belongs to (e.g. `Motor1.NativeAlarms`). `null` for computed alarms. Stamped by the `NativeAlarmActor` on every emitted event; also carried on idle-binding placeholder rows emitted by the Instance Actor.
|
||||
- **`IsConfiguredPlaceholder`** (`bool`): `true` for a synthetic row emitted by the Instance Actor to represent a configured native source binding that currently has no live conditions. These rows are never persisted or forwarded as operational alarms — they exist solely so the Debug View can render every configured binding as a tree node. `false` on all other rows.
|
||||
- **`AckTime`** (`DateTimeOffset?`): the instant the condition was acknowledged; `null` while unacknowledged, `null` on computed alarms (auto-acked but with no operator ack event), and cleared on re-raise. Stamped by the DCL adapter — the source's own ack instant where the protocol supplies one (OPC UA A&C), the DCL's observation time of the ack transition where it does not (MxAccess Gateway) — and carried verbatim by the `NativeAlarmActor`, which never invents or suppresses one. See [Component-DataConnectionLayer.md](Component-DataConnectionLayer.md) → *Ack Timestamp*.
|
||||
@@ -556,12 +558,12 @@ Available to all script runs and alarm on-trigger runs:
|
||||
|
||||
### Alarm State (read-only)
|
||||
- `Alarms.CurrentAsync()` — Returns a snapshot of the instance's **current** alarm conditions as `IReadOnlyList<ScriptAlarm>` (`Commons.Types.Scripts`), covering computed alarms and mirrored native (OPC UA A&C / MxAccess Gateway) conditions alike. Served by a **local Ask** (`GetAlarmSnapshotRequest`/`GetAlarmSnapshotResponse`) to the script's own Instance Actor — the same mechanism attribute reads use, never a cross-cluster hop.
|
||||
- `Alarms.MesReportableAsync()` — `CurrentAsync()` filtered to rows where `ScriptAlarm.IsMesReportable` is true (2026-09-11). Native alarms carry no per-alarm config row, so they never satisfy the filter — they still convey MES relevance via the 900–999 severity band (see `docs/plans/2026-06-30-mes-alarm-status-api.md`).
|
||||
- `Alarms.MesReportableAsync()` — `CurrentAsync()` filtered to rows where `ScriptAlarm.IsMesReportable` is true (2026-09-11). Native alarms carry no per-alarm config row, so they never satisfy the filter — they still convey MES relevance via the 900–999 **priority** band — tested on `ScriptAlarm.Priority`, never on `Severity` (Gitea #129; see `docs/plans/2026-06-30-mes-alarm-status-api.md`).
|
||||
- Available to **every** script, unlike the `Alarm` global, which exists only inside an on-trigger handler and describes the single alarm that fired. This accessor is what lets an inbound-API-routed `Call` script answer "what is in alarm on this machine right now?"; before it, native mirrored conditions were reachable only from the Debug View.
|
||||
- **Read-only by design** — native alarms are a read-only mirror of the source (no ack-back), so the accessor exposes no acknowledge/shelve operation.
|
||||
- **Not scope-prefixed**, unlike `Attributes`: alarm identity is not a scope-relative attribute name (computed alarms are keyed by configured name, native conditions by a source-supplied reference), so every scope sees the same list rather than a silently truncated one.
|
||||
- The snapshot is built by the same `BuildAlarmStatesSnapshot()` the Debug View uses, so it **includes placeholder rows** for configured-but-quiet native source bindings (`IsConfiguredPlaceholder`). Callers enumerating real alarms filter on `Active && !IsConfiguredPlaceholder`.
|
||||
- `ScriptAlarm` is a flat projection of the retained `AlarmStateChanged`: `Name`, `SourceReference`, `NativeSourceCanonicalName`, `Active`, `Acknowledged`, `Confirmed?`, `Shelved` (all shelve sub-states collapse to one boolean), `Suppressed`, `Severity` (0–1000), `Kind`, `Message`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `Timestamp`, `AckTime?`, `CurrentValue`, `LimitValue`, `IsConfiguredPlaceholder`, `IsMesReportable` (trailing, 2026-09-11 — always `false` on native rows). The `Condition` is the authority for active/acked/severity, so computed and native alarms read identically from one filter expression.
|
||||
- `ScriptAlarm` is a flat projection of the retained `AlarmStateChanged`: `Name`, `SourceReference`, `NativeSourceCanonicalName`, `Active`, `Acknowledged`, `Confirmed?`, `Shelved` (all shelve sub-states collapse to one boolean), `Suppressed`, `Severity` (0–1000, higher = more urgent for every kind — Gitea #129), `Kind`, `Message`, `AlarmTypeName`, `Category`, `OperatorUser`, `OperatorComment`, `OriginalRaiseTime?`, `Timestamp`, `AckTime?`, `CurrentValue`, `LimitValue`, `IsConfiguredPlaceholder`, `IsMesReportable` (trailing, 2026-09-11 — always `false` on native rows), `Priority` (trailing, Gitea #129 — the raw source number: AVEVA priority for MxAccess, `PriorityLevel` for computed, Part 9 Severity for OPC UA; the pre-#129 `Severity` value, which the MES 900–999 band and the MES API's `Severity`/`MinSeverity`/`MaxSeverity` read). The `Condition` is the authority for active/acked/severity, so computed and native alarms read identically from one filter expression.
|
||||
- **`IsMesReportable` is stamped at two points.** `InstanceActor.BuildAlarmStatesSnapshot()` sets `AlarmStateChanged.IsMesReportable` from `ResolvedAlarm.IsMesReportable` on every row it produces — retained (fired/active) rows and the fallback/idle rows alike — and `InstanceActor.HandleAlarmStateChanged` stamps the Alarm Actor's own live events before storing and publishing them, so `Alarms.CurrentAsync()`/`MesReportableAsync()`, the Debug View snapshot and the live stream always agree. Since ScadaBridge#60 the flag rides the wire as `AlarmStateUpdate.is_mes_reportable` (25) and `DebugAlarmStateDto.is_mes_reportable` (20); `DebugAlarmStateDto` also carries `ack_time` (21, ScadaBridge#84). Both are additive — an older peer reads `false`/null.
|
||||
- The type lives in Commons so the runtime accessor and the Script Analysis compile-only surface (`ScriptCompileSurface.CompileAlarmsAccessor`) project to the **same** type — a script binding at the design-time gate binds identically at the site. Parity is pinned by `CompileSurfaceParityTests`.
|
||||
- Design record: `docs/plans/2026-06-30-mes-alarm-status-api.md` §5.2.
|
||||
|
||||
@@ -133,7 +133,7 @@ Sections 3.4 and 3.4.1 describe **computed alarms** — alarms ScadaBridge evalu
|
||||
|
||||
Because the source reference typically varies per physical instance (e.g. `Tank01` vs `Tank02`), an instance may **override** a non-locked binding's connection, source reference, and/or condition filter individually — a null override field keeps the inherited value. Bindings cannot be added or removed at the instance level, only retargeted.
|
||||
|
||||
**Unified condition model.** Computed and native alarms share one **condition state** so they present uniformly: orthogonal **Active**, **Acknowledged**, **Confirmed** (when confirmable), **Shelved**, and **Suppressed** flags plus a **0–1000 severity**. An **alarm kind** discriminator distinguishes `Computed`, `NativeOpcUa`, and `NativeMxAccess`. Computed alarms project onto this model as auto-acknowledged with severity equal to their priority; native alarms mirror the source condition directly.
|
||||
**Unified condition model.** Computed and native alarms share one **condition state** so they present uniformly: orthogonal **Active**, **Acknowledged**, **Confirmed** (when confirmable), **Shelved**, and **Suppressed** flags plus a **0–1000 severity** in one direction for every kind — **higher = more urgent** (OPC UA Part 9) — and the source's raw **priority** alongside it. An **alarm kind** discriminator distinguishes `Computed`, `NativeOpcUa`, and `NativeMxAccess`. Computed alarms project onto this model as auto-acknowledged with their priority (lower = more urgent) banded onto severity with AVEVA's default ranges (1–250 → 1000, 251–500 → 750, 501–750 → 500, 751–999 → 250); MxAccess native alarms band their AVEVA priority the same way; OPC UA native alarms mirror the source Severity directly.
|
||||
|
||||
**Runtime behavior:**
|
||||
- Native alarm sources are **discovered and subscribed at runtime** — at deploy time the site subscribes each resolved binding through the Data Connection Layer, which opens **one alarm feed per connection** and routes transitions to instances by source-object reference. A site-side actor (peer to the computed alarm actor) mirrors each source binding.
|
||||
|
||||
@@ -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&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.
|
||||
|
||||
@@ -32,11 +32,12 @@ public class AlarmSummaryRenderTests : BunitContext
|
||||
/// <summary>Initializes the test class and configures the faked services and the bUnit context.</summary>
|
||||
public AlarmSummaryRenderTests()
|
||||
{
|
||||
// Two rows whose default sort (severity DESC) orders Zeta (900) before
|
||||
// Alpha (500); an ascending sort by instance name flips them.
|
||||
// Two computed rows whose default sort (severity DESC) orders Zeta before Alpha:
|
||||
// priority 100 bands to Severity 1000, priority 500 to 750 (Gitea #129 — a lower
|
||||
// priority is more urgent). An ascending sort by instance name flips them.
|
||||
var rows = new List<AlarmSummaryRow>
|
||||
{
|
||||
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
|
||||
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
|
||||
new("Alpha", new AlarmStateChanged("Alpha", "A-alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)),
|
||||
};
|
||||
|
||||
@@ -267,7 +268,7 @@ public class AlarmSummaryRenderTests : BunitContext
|
||||
.Returns(ci => Task.FromResult(new AlarmSummaryResult(
|
||||
new List<AlarmSummaryRow>
|
||||
{
|
||||
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
|
||||
new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
|
||||
},
|
||||
new[] { "OfflineInstance" })));
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ public class AlarmSummaryServiceTests
|
||||
new("inst-b", NativeAlarm("inst-b", "MtrTrip", AlarmState.Active, 300, active: true, acked: false, kind: AlarmKind.NativeMxAccess)),
|
||||
// computed normal (not active) → ignored by active/unacked, severity 200 not counted
|
||||
new("inst-a", ComputedAlarm("inst-a", "PumpFault", AlarmState.Normal, 200)),
|
||||
// computed active sev 999 — active, but computed so NOT unacked
|
||||
// computed active priority 999 = AVEVA Low → Severity 250 (#129) — active, but computed so NOT unacked
|
||||
new("inst-c", ComputedAlarm("inst-c", "HiHi", AlarmState.Active, 999)),
|
||||
};
|
||||
|
||||
@@ -148,8 +148,8 @@ public class AlarmSummaryServiceTests
|
||||
|
||||
// Active = the 3 native actives + the active computed = 4
|
||||
Assert.Equal(4, rollup.TotalActive);
|
||||
// Worst severity among active = 999 (the active computed)
|
||||
Assert.Equal(999, rollup.WorstSeverity);
|
||||
// Worst severity among active = 800 (TankHi); the computed priority 999 is the LEAST urgent
|
||||
Assert.Equal(800, rollup.WorstSeverity);
|
||||
// Unacked = active && !acked && kind != Computed = TankHi + MtrTrip = 2
|
||||
Assert.Equal(2, rollup.UnackedCount);
|
||||
// Kind counts: NativeOpcUa 2, NativeMxAccess 1, Computed 2
|
||||
@@ -158,6 +158,36 @@ public class AlarmSummaryServiceTests
|
||||
Assert.Equal(2, rollup.CountsByKind[AlarmKind.Computed]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #129: "Worst Severity" is the most URGENT alarm across every kind. AVEVA priority 1 (computed or
|
||||
/// MxAccess) bands to 1000 and outranks priority 999 (250) and an OPC UA Severity of 900.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ComputeRollup_WorstSeverity_IsTheMostUrgentPriority()
|
||||
{
|
||||
var mx = new AlarmStateChanged("inst-a", "MtrTrip", AlarmState.Active, 1, Now)
|
||||
{
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false,
|
||||
AlarmPriorityBands.ToSeverity(1), Priority: 1),
|
||||
};
|
||||
var rows = new List<AlarmSummaryRow>
|
||||
{
|
||||
new("inst-a", ComputedAlarm("inst-a", "Minor", AlarmState.Active, 999)),
|
||||
new("inst-b", NativeAlarm("inst-b", "TankHi", AlarmState.Active, 900, active: true, acked: false)),
|
||||
new("inst-a", mx),
|
||||
};
|
||||
|
||||
Assert.Equal(1000, CreateSut().ComputeRollup(rows).WorstSeverity);
|
||||
|
||||
var computedOnly = new List<AlarmSummaryRow>
|
||||
{
|
||||
new("inst-a", ComputedAlarm("inst-a", "Minor", AlarmState.Active, 999)),
|
||||
new("inst-a", ComputedAlarm("inst-a", "Major", AlarmState.Active, 1)),
|
||||
};
|
||||
Assert.Equal(1000, CreateSut().ComputeRollup(computedOnly).WorstSeverity);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a rollup with no active rows has a worst severity of zero.</summary>
|
||||
[Fact]
|
||||
public void ComputeRollup_NoActiveRows_WorstSeverityZero()
|
||||
|
||||
+4
-2
@@ -13,7 +13,8 @@ public class AlarmStateChangedEnrichmentTests
|
||||
var m = new AlarmStateChanged("inst", "HiAlarm", AlarmState.Active, 700, DateTimeOffset.UnixEpoch);
|
||||
Assert.Equal(AlarmKind.Computed, m.Kind);
|
||||
Assert.True(m.Condition.Acknowledged); // computed = auto-acked
|
||||
Assert.Equal(700, m.Condition.Severity); // severity defaults to Priority
|
||||
Assert.Equal(500, m.Condition.Severity); // priority 700 banded: Medium → 500 (#129)
|
||||
Assert.Equal(700, m.Condition.Priority); // raw priority kept verbatim
|
||||
Assert.True(m.Condition.Active); // derived from State
|
||||
Assert.Equal("", m.SourceReference);
|
||||
}
|
||||
@@ -25,7 +26,8 @@ public class AlarmStateChangedEnrichmentTests
|
||||
var c = AlarmConditionStateFactory.ForComputed(AlarmState.Normal, priority: 250);
|
||||
Assert.False(c.Active);
|
||||
Assert.True(c.Acknowledged);
|
||||
Assert.Equal(250, c.Severity);
|
||||
Assert.Equal(1000, c.Severity); // priority 250 is AVEVA Critical (#129)
|
||||
Assert.Equal(250, c.Priority);
|
||||
}
|
||||
|
||||
// ── MES alarm-status API §6.4: additive AckTime ──
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the AVEVA priority → unified Severity table (Gitea #129). It must equal OtOpcUa's
|
||||
/// <c>GalaxyAlarmPriority.Map</c> (lmxopcua#887, <c>b61f3e05</c>) value for value — the edge rows below
|
||||
/// are copied from OtOpcUa's <c>MxAccessSeverityMapperTests</c> — so a Galaxy alarm reads the same
|
||||
/// Severity through the MxGateway adapter as through OtOpcUa.
|
||||
/// </summary>
|
||||
public sealed class AlarmPriorityBandsTests
|
||||
{
|
||||
/// <summary>The band table at every edge, plus the out-of-range clamps.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, 1000)] // AVEVA Critical 1–250
|
||||
[InlineData(250, 1000)]
|
||||
[InlineData(251, 750)] // AVEVA High 251–500
|
||||
[InlineData(500, 750)]
|
||||
[InlineData(501, 500)] // AVEVA Medium 501–750
|
||||
[InlineData(750, 500)]
|
||||
[InlineData(751, 250)] // AVEVA Low 751–999
|
||||
[InlineData(999, 250)]
|
||||
[InlineData(0, 1000)] // below range: clamped to priority 1
|
||||
[InlineData(-100, 1000)]
|
||||
[InlineData(int.MinValue, 1000)]
|
||||
[InlineData(1000, 250)] // above range: clamped to priority 999
|
||||
[InlineData(int.MaxValue, 250)]
|
||||
public void ToSeverity_assigns_the_AVEVA_priority_band(int priority, int expectedSeverity) =>
|
||||
Assert.Equal(expectedSeverity, AlarmPriorityBands.ToSeverity(priority));
|
||||
|
||||
/// <summary>
|
||||
/// A more urgent (lower) priority never yields a lower Severity, and the ends differ — so neither the
|
||||
/// pre-#129 identity mapping (which rises with priority) nor a constant mapping passes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToSeverity_never_ranks_a_less_urgent_priority_above_a_more_urgent_one()
|
||||
{
|
||||
var previous = AlarmPriorityBands.ToSeverity(1);
|
||||
for (var priority = 2; priority <= 999; priority++)
|
||||
{
|
||||
var current = AlarmPriorityBands.ToSeverity(priority);
|
||||
Assert.True(current <= previous, $"priority {priority} must not be more severe than {priority - 1}");
|
||||
previous = current;
|
||||
}
|
||||
|
||||
Assert.True(AlarmPriorityBands.ToSeverity(1) > AlarmPriorityBands.ToSeverity(999));
|
||||
}
|
||||
|
||||
/// <summary>The constants are the OtOpcUa values, so a drift on either side is caught here.</summary>
|
||||
[Fact]
|
||||
public void Constants_match_OtOpcUa_GalaxyAlarmPriority()
|
||||
{
|
||||
Assert.Equal(250, AlarmPriorityBands.CriticalMaxPriority);
|
||||
Assert.Equal(500, AlarmPriorityBands.HighMaxPriority);
|
||||
Assert.Equal(750, AlarmPriorityBands.MediumMaxPriority);
|
||||
Assert.Equal(1000, AlarmPriorityBands.SeverityCritical);
|
||||
Assert.Equal(750, AlarmPriorityBands.SeverityHigh);
|
||||
Assert.Equal(500, AlarmPriorityBands.SeverityMedium);
|
||||
Assert.Equal(250, AlarmPriorityBands.SeverityLow);
|
||||
}
|
||||
|
||||
/// <summary>Computed alarms band their PriorityLevel onto Severity and keep it verbatim on Priority.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, 1000)]
|
||||
[InlineData(300, 750)]
|
||||
[InlineData(700, 500)]
|
||||
[InlineData(900, 250)]
|
||||
public void ForComputed_bands_Severity_and_keeps_the_raw_Priority(int priority, int expectedSeverity)
|
||||
{
|
||||
var c = AlarmConditionStateFactory.ForComputed(AlarmState.Active, priority);
|
||||
Assert.Equal(expectedSeverity, c.Severity);
|
||||
Assert.Equal(priority, c.Priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Central derives Severity from the wire's raw priority: banded for MxAccess and computed rows,
|
||||
/// verbatim (clamped) for OPC UA, whose wire value is already Part 9 Severity.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AlarmKind.NativeMxAccess, 1, 1000)]
|
||||
[InlineData(AlarmKind.NativeMxAccess, 950, 250)]
|
||||
[InlineData(AlarmKind.Computed, 400, 750)]
|
||||
[InlineData(AlarmKind.Computed, 600, 500)]
|
||||
[InlineData(AlarmKind.NativeOpcUa, 950, 950)]
|
||||
[InlineData(AlarmKind.NativeOpcUa, 1, 1)]
|
||||
[InlineData(AlarmKind.NativeOpcUa, 1500, 1000)]
|
||||
public void SeverityFromPriority_bands_all_but_OPC_UA(AlarmKind kind, int priority, int expectedSeverity) =>
|
||||
Assert.Equal(expectedSeverity, AlarmConditionStateFactory.SeverityFromPriority(kind, priority));
|
||||
}
|
||||
@@ -96,7 +96,8 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.False(alarm.Condition.Acknowledged);
|
||||
Assert.Equal(AlarmShelveState.TimedShelved, alarm.Condition.Shelve);
|
||||
Assert.True(alarm.Condition.Suppressed);
|
||||
Assert.Equal(850, alarm.Condition.Severity);
|
||||
Assert.Equal(850, alarm.Condition.Severity); // OPC UA: wire value is Part 9 Severity already
|
||||
Assert.Equal(850, alarm.Condition.Priority);
|
||||
Assert.Equal("T01.Hi", alarm.SourceReference);
|
||||
Assert.Equal("AnalogLimit.Hi", alarm.AlarmTypeName);
|
||||
Assert.Equal("Process", alarm.Category);
|
||||
@@ -107,6 +108,41 @@ public class SiteStreamGrpcClientTests
|
||||
Assert.Equal("100", alarm.LimitValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #129: the wire carries the raw priority. MxAccess and computed rows band it onto a
|
||||
/// higher-is-more-urgent Severity (AVEVA priority 1 → 1000); the raw value stays on Priority.
|
||||
/// The wire meaning is unchanged, so this is also right for a site on an older build.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("NativeMxAccess", 1, 1000)]
|
||||
[InlineData("NativeMxAccess", 400, 750)]
|
||||
[InlineData("NativeMxAccess", 950, 250)]
|
||||
[InlineData("Computed", 100, 1000)]
|
||||
[InlineData("Computed", 700, 500)]
|
||||
[InlineData("NativeOpcUa", 950, 950)]
|
||||
public void ConvertToDomainEvent_AlarmChanged_SeverityIsOneDirection(string kind, int wirePriority, int expectedSeverity)
|
||||
{
|
||||
var evt = new SiteStreamEvent
|
||||
{
|
||||
AlarmChanged = new AlarmStateUpdate
|
||||
{
|
||||
InstanceUniqueName = "Site1.Motor01",
|
||||
AlarmName = "A",
|
||||
State = AlarmStateEnum.AlarmStateActive,
|
||||
Priority = wirePriority,
|
||||
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UnixEpoch),
|
||||
Kind = kind,
|
||||
Active = true
|
||||
}
|
||||
};
|
||||
|
||||
var alarm = Assert.IsType<AlarmStateChanged>(SiteStreamGrpcClient.ConvertToDomainEvent(evt));
|
||||
|
||||
Assert.Equal(expectedSeverity, alarm.Condition.Severity);
|
||||
Assert.Equal(wirePriority, alarm.Condition.Priority);
|
||||
Assert.Equal(wirePriority, alarm.Priority);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown stream event converts to null.</summary>
|
||||
[Fact]
|
||||
public void ConvertToDomainEvent_UnknownEvent_ReturnsNull()
|
||||
|
||||
@@ -436,7 +436,8 @@ public class SiteCommandDtoMapperGoldenTests
|
||||
[Fact]
|
||||
public void NativeAlarm_KeepsItsExplicitCondition()
|
||||
{
|
||||
var condition = new AlarmConditionState(true, false, true, AlarmShelveState.PermanentShelved, true, 999);
|
||||
// Priority equals the row's own Priority, as NativeAlarmActor always emits it (Gitea #129).
|
||||
var condition = new AlarmConditionState(true, false, true, AlarmShelveState.PermanentShelved, true, 999, Priority: 500);
|
||||
var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
@@ -450,6 +451,25 @@ public class SiteCommandDtoMapperGoldenTests
|
||||
Assert.Equal(condition, restored.Condition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The debug snapshot's condition DTO carries Severity only; the raw priority (Gitea #129) is
|
||||
/// restored from the row's Priority so the MES band keeps reading it on central too.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NativeAlarm_RestoresTheRawPriorityOnItsCondition()
|
||||
{
|
||||
var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 950, DateTimeOffset.UtcNow)
|
||||
{
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 250, Priority: 950)
|
||||
};
|
||||
|
||||
var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original));
|
||||
|
||||
Assert.Equal(250, restored.Condition.Severity);
|
||||
Assert.Equal(950, restored.Condition.Priority);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Group classification + rejection
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -301,7 +301,7 @@ internal static class SiteCommandSamples
|
||||
Level = AlarmLevel.None,
|
||||
Message = "Level high",
|
||||
Kind = AlarmKind.NativeOpcUa,
|
||||
Condition = new AlarmConditionState(true, false, false, AlarmShelveState.OneShotShelved, true, 850),
|
||||
Condition = new AlarmConditionState(true, false, false, AlarmShelveState.OneShotShelved, true, 850, Priority: 850),
|
||||
SourceReference = "Tank01.Level.HiHi",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
Category = "Process",
|
||||
|
||||
@@ -33,7 +33,8 @@ public class MxGatewayAlarmMapperTests
|
||||
Assert.Equal(CommonsTransitionKind.Acknowledge, t.Kind);
|
||||
Assert.True(t.Condition.Active);
|
||||
Assert.True(t.Condition.Acknowledged);
|
||||
Assert.Equal(600, t.Condition.Severity);
|
||||
Assert.Equal(500, t.Condition.Severity); // AVEVA priority 600 = Medium → 500 (#129)
|
||||
Assert.Equal(600, t.Condition.Priority); // raw priority kept
|
||||
Assert.Equal("operator1", t.OperatorUser);
|
||||
Assert.Equal("Tank01", t.SourceObjectReference);
|
||||
}
|
||||
@@ -45,7 +46,8 @@ public class MxGatewayAlarmMapperTests
|
||||
var c = MxGatewayAlarmMapper.MapConditionState(ProtoConditionState.ActiveAcked, severity: 600);
|
||||
Assert.True(c.Active);
|
||||
Assert.True(c.Acknowledged);
|
||||
Assert.Equal(600, c.Severity);
|
||||
Assert.Equal(500, c.Severity);
|
||||
Assert.Equal(600, c.Priority);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an active, unacknowledged snapshot maps to the snapshot kind.</summary>
|
||||
@@ -58,7 +60,7 @@ public class MxGatewayAlarmMapperTests
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.Hi",
|
||||
CurrentState = ProtoConditionState.Active,
|
||||
Severity = 1500 // out of range — must clamp
|
||||
Severity = 1500 // out of range — Priority clamps, Severity reads AVEVA Low
|
||||
};
|
||||
|
||||
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
|
||||
@@ -66,7 +68,8 @@ public class MxGatewayAlarmMapperTests
|
||||
Assert.Equal(CommonsTransitionKind.Snapshot, t.Kind);
|
||||
Assert.True(t.Condition.Active);
|
||||
Assert.False(t.Condition.Acknowledged);
|
||||
Assert.Equal(1000, t.Condition.Severity);
|
||||
Assert.Equal(250, t.Condition.Severity);
|
||||
Assert.Equal(1000, t.Condition.Priority);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the source reference is object-relative rather than the full provider reference.</summary>
|
||||
@@ -334,4 +337,63 @@ public class MxGatewayAlarmMapperTests
|
||||
// The end-of-snapshot sentinel has no condition payload at all.
|
||||
Assert.Null(MxGatewayAlarmMapper.SnapshotComplete().AckTime);
|
||||
}
|
||||
|
||||
// ── Gitea #129: one Severity direction, raw priority kept ──
|
||||
|
||||
/// <summary>
|
||||
/// The gateway's <c>severity</c> field is the AVEVA priority (1 = most urgent). Severity uses OtOpcUa's
|
||||
/// band table (lmxopcua#887), so priority 1 is the MOST severe — on a live transition and a snapshot alike.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(1, 1000)]
|
||||
[InlineData(250, 1000)]
|
||||
[InlineData(251, 750)]
|
||||
[InlineData(500, 750)]
|
||||
[InlineData(501, 500)]
|
||||
[InlineData(750, 500)]
|
||||
[InlineData(751, 250)]
|
||||
[InlineData(999, 250)]
|
||||
[InlineData(0, 1000)]
|
||||
[InlineData(-100, 1000)]
|
||||
[InlineData(1000, 250)]
|
||||
[InlineData(int.MaxValue, 250)]
|
||||
public void Severity_follows_the_AVEVA_priority_bands(int rawPriority, int expectedSeverity)
|
||||
{
|
||||
var live = MxGatewayAlarmMapper.MapTransition(new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Galaxy!Area.Tank01.Hi", SourceObjectReference = "Tank01.Hi",
|
||||
TransitionKind = ProtoTransitionKind.Raise, Severity = rawPriority
|
||||
});
|
||||
var snap = MxGatewayAlarmMapper.MapSnapshot(new ActiveAlarmSnapshot
|
||||
{
|
||||
AlarmFullReference = "Galaxy!Area.Tank01.Hi", SourceObjectReference = "Tank01.Hi",
|
||||
CurrentState = ProtoConditionState.Active, Severity = rawPriority
|
||||
});
|
||||
|
||||
Assert.Equal(expectedSeverity, live.Condition.Severity);
|
||||
Assert.Equal(expectedSeverity, snap.Condition.Severity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The MES 900–999 band must select exactly the alarms it selected before #129, which tested the old
|
||||
/// Severity — the gateway value clamped to 0–1000. Priority carries exactly that value for every input.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Priority_is_the_pre_129_Severity_so_the_MES_band_is_unchanged()
|
||||
{
|
||||
foreach (var raw in new[] { int.MinValue, -1, 0, 1, 250, 251, 750, 751, 899, 900, 950, 999, 1000, 1001, 1500, int.MaxValue })
|
||||
{
|
||||
var t = MxGatewayAlarmMapper.MapTransition(new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "A", SourceObjectReference = "A",
|
||||
TransitionKind = ProtoTransitionKind.Raise, Severity = raw
|
||||
});
|
||||
var preFixSeverity = Math.Clamp(raw, 0, 1000);
|
||||
Assert.Equal(preFixSeverity, t.Condition.Priority);
|
||||
|
||||
var inBandBefore = preFixSeverity is >= 900 and <= 999;
|
||||
var inBandNow = t.Condition.Priority is >= 900 and <= 999;
|
||||
Assert.Equal(inBandBefore, inBandNow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ public class OpcUaAlarmMapperTests
|
||||
Assert.True(c.Active);
|
||||
Assert.False(c.Acknowledged);
|
||||
Assert.Equal(700, c.Severity);
|
||||
Assert.Equal(700, c.Priority); // OPC UA has no separate priority (#129)
|
||||
Assert.Equal(AlarmShelveState.Unshelved, c.Shelve);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,8 +121,9 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
|
||||
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(AlarmKind.Computed, msg.Kind); // computed origin
|
||||
Assert.Equal(msg.Priority, msg.Condition.Severity); // severity tracks priority
|
||||
Assert.Equal(700, msg.Condition.Severity);
|
||||
Assert.Equal(700, msg.Priority); // raw PriorityLevel on the record
|
||||
Assert.Equal(700, msg.Condition.Priority); // ...and on the condition (#129)
|
||||
Assert.Equal(500, msg.Condition.Severity); // banded: 501–750 = Medium → 500
|
||||
Assert.True(msg.Condition.Active); // active alarm → condition active
|
||||
Assert.True(msg.Condition.Acknowledged); // computed alarms are auto-acked
|
||||
Assert.Equal(string.Empty, msg.SourceReference); // no native source reference
|
||||
@@ -935,7 +936,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||
PriorityLevel = 800
|
||||
PriorityLevel = 100
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
@@ -954,7 +955,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
var rows = siteLog.OfType("alarm");
|
||||
Assert.Single(rows);
|
||||
var row = rows[0];
|
||||
Assert.Equal("Error", row.Severity); // priority 800 → Error
|
||||
Assert.Equal("Error", row.Severity); // priority 100 = AVEVA Critical → Error (#129)
|
||||
Assert.Equal("Pump1", row.InstanceId);
|
||||
Assert.Equal("AlarmActor:HighTemp", row.Source);
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
@@ -972,7 +973,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
CanonicalName = "MinorTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Warn\"}",
|
||||
PriorityLevel = 100
|
||||
PriorityLevel = 800
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
@@ -989,7 +990,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
{
|
||||
var rows = siteLog.OfType("alarm");
|
||||
Assert.Single(rows);
|
||||
Assert.Equal("Warning", rows[0].Severity); // priority 100 → Warning
|
||||
Assert.Equal("Warning", rows[0].Severity); // priority 800 = AVEVA Low → Warning (#129)
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
@@ -1005,7 +1006,7 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||
PriorityLevel = 800
|
||||
PriorityLevel = 100
|
||||
};
|
||||
|
||||
var instanceProbe = CreateTestProbe();
|
||||
@@ -1030,6 +1031,22 @@ public class AlarmActorTests : TestKit, IDisposable
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #129: a computed raise's event-log severity follows the priority banded onto the unified
|
||||
/// Severity (lower priority = more urgent), with the same 700 threshold NativeAlarmActor uses.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0, "Error")]
|
||||
[InlineData(1, "Error")]
|
||||
[InlineData(250, "Error")]
|
||||
[InlineData(500, "Error")]
|
||||
[InlineData(501, "Warning")]
|
||||
[InlineData(700, "Warning")]
|
||||
[InlineData(999, "Warning")]
|
||||
[InlineData(1000, "Warning")]
|
||||
public void RaiseSeverity_FollowsTheBandedPriority(int priority, string expected) =>
|
||||
Assert.Equal(expected, AlarmActor.RaiseSeverity(priority));
|
||||
|
||||
/// <summary>Verifies that a malformed trigger configuration does not crash the alarm actor.</summary>
|
||||
[Fact]
|
||||
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
||||
|
||||
@@ -668,6 +668,80 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
Assert.Null(emitted.AckTime); // …with no ack time invented
|
||||
}
|
||||
|
||||
// ── Gitea #129: one Severity direction, raw priority kept ──
|
||||
|
||||
private IActorRef SpawnKind(IActorRef instanceActor, IActorRef dclManager, AlarmKind kind,
|
||||
IServiceProvider? serviceProvider = null) =>
|
||||
ActorOf(Props.Create(() => new NativeAlarmActor(
|
||||
Source(), "inst", instanceActor, dclManager, _storage, _options,
|
||||
NullLogger<NativeAlarmActor>.Instance, kind, serviceProvider)));
|
||||
|
||||
/// <summary>
|
||||
/// An MxAccess condition as the DCL now builds it (AVEVA priority banded onto Severity, raw kept on
|
||||
/// Priority): the record's Priority is the raw priority — the value the MES band and the stream's
|
||||
/// <c>AlarmStateUpdate.priority</c> carry — and the event log ranks priority 1 as an Error, 950 as a Warning.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(1, 1000, "Error")]
|
||||
[InlineData(400, 750, "Error")]
|
||||
[InlineData(600, 500, "Warning")]
|
||||
[InlineData(950, 250, "Warning")]
|
||||
public void MxAccessRaise_CarriesRawPriority_AndLogsByBandedSeverity(int priority, int severity, string logSeverity)
|
||||
{
|
||||
var siteLog = new FakeSiteEventLogger();
|
||||
var instance = CreateTestProbe();
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = SpawnKind(instance.Ref, dcl.Ref, AlarmKind.NativeMxAccess, new SingleServiceProvider(siteLog));
|
||||
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, severity, Priority: priority))));
|
||||
var emitted = instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
||||
|
||||
Assert.Equal(priority, emitted.Priority);
|
||||
Assert.Equal(priority, emitted.Condition.Priority);
|
||||
Assert.Equal(severity, emitted.Condition.Severity);
|
||||
AwaitAssert(() => Assert.Equal(logSeverity, Assert.Single(siteLog.OfType("alarm")).Severity),
|
||||
TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A row persisted by a pre-#129 build holds the raw number in Severity and no Priority. Rehydration
|
||||
/// moves it to Priority and re-derives Severity: banded for an MxAccess source, kept for OPC UA.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AlarmKind.NativeMxAccess, 950, 250)]
|
||||
[InlineData(AlarmKind.NativeMxAccess, 1, 1000)]
|
||||
[InlineData(AlarmKind.NativeOpcUa, 800, 800)]
|
||||
public async Task Rehydration_OfAPre129Row_MovesTheRawValueToPriority(AlarmKind kind, int legacySeverity, int expectedSeverity)
|
||||
{
|
||||
var legacyJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
Active = true, Acknowledged = false, Confirmed = (bool?)null,
|
||||
Shelve = AlarmShelveState.Unshelved, Suppressed = false, Severity = legacySeverity
|
||||
});
|
||||
await _storage.UpsertNativeAlarmAsync(
|
||||
"inst", Source().CanonicalName, "ref-129", legacyJson, DateTimeOffset.UtcNow, null);
|
||||
|
||||
var instance = CreateTestProbe();
|
||||
SpawnKind(instance.Ref, CreateTestProbe().Ref, kind);
|
||||
|
||||
var emitted = instance.FishForMessage<AlarmStateChanged>(
|
||||
m => m.SourceReference == "ref-129", TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(expectedSeverity, emitted.Condition.Severity);
|
||||
Assert.Equal(legacySeverity, emitted.Condition.Priority);
|
||||
Assert.Equal(legacySeverity, emitted.Priority);
|
||||
}
|
||||
|
||||
/// <summary>A post-#129 row (Priority present) is left exactly as persisted.</summary>
|
||||
[Fact]
|
||||
public void UpgradeLegacyCondition_LeavesACurrentRowAlone()
|
||||
{
|
||||
var current = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 250, Priority: 950);
|
||||
Assert.Same(current, NativeAlarmActor.UpgradeLegacyCondition(current, AlarmKind.NativeMxAccess));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a lost subscribe response causes the subscribe to be resent.</summary>
|
||||
[Fact]
|
||||
public void LostSubscribeResponse_ResendsSubscribe()
|
||||
|
||||
@@ -42,7 +42,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Kind = AlarmKind.NativeMxAccess,
|
||||
Condition = new AlarmConditionState(
|
||||
Active: true, Acknowledged: true, Confirmed: null,
|
||||
Shelve: AlarmShelveState.TimedShelved, Suppressed: true, Severity: 950),
|
||||
// AVEVA priority 950 (inside the MES 900-999 band) banded to Low = 250 (#129).
|
||||
Shelve: AlarmShelveState.TimedShelved, Suppressed: true, Severity: 250, Priority: 950),
|
||||
SourceReference = "Z28061.HeartbeatTimeoutAlarm",
|
||||
NativeSourceCanonicalName = "ReactorAlarms",
|
||||
AlarmTypeName = "DiscreteAlarm",
|
||||
@@ -108,7 +109,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.Null(alarm.Confirmed);
|
||||
Assert.True(alarm.Shelved); // TimedShelved collapses to a single boolean
|
||||
Assert.True(alarm.Suppressed);
|
||||
Assert.Equal(950, alarm.Severity);
|
||||
Assert.Equal(250, alarm.Severity);
|
||||
Assert.Equal(950, alarm.Priority); // the MES band keys off the raw priority (#129)
|
||||
Assert.Equal("NativeMxAccess", alarm.Kind);
|
||||
Assert.Equal("Heartbeat lost", alarm.Message);
|
||||
Assert.Equal("DiscreteAlarm", alarm.AlarmTypeName);
|
||||
@@ -166,7 +168,8 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
Assert.True(alarm.Active);
|
||||
Assert.True(alarm.Acknowledged); // computed alarms are auto-acked…
|
||||
Assert.Null(alarm.AckTime); // …but carry no ack instant
|
||||
Assert.Equal(700, alarm.Severity);
|
||||
Assert.Equal(500, alarm.Severity); // PriorityLevel 700 banded: Medium → 500 (#129)
|
||||
Assert.Equal(700, alarm.Priority);
|
||||
Assert.Equal(string.Empty, alarm.NativeSourceCanonicalName);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user