diff --git a/scripts/e2e/test-galaxy.ps1 b/scripts/e2e/test-galaxy.ps1 index b158856..f13d58e 100644 --- a/scripts/e2e/test-galaxy.ps1 +++ b/scripts/e2e/test-galaxy.ps1 @@ -193,8 +193,10 @@ $subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -R Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue # Any `=` followed by `(Good)` line after the initial subscribe-confirmation -# indicates at least one data-change tick arrived. -$changeLines = ($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" } +# indicates at least one data-change tick arrived. The `@(...)` forces an array +# so `.Count` works on the 0-match + single-match cases that Set-StrictMode +# -Version 3.0 otherwise flags as `property 'Count' cannot be found`. +$changeLines = @(($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" }) if ($changeLines.Count -gt 0) { Write-Pass "$($changeLines.Count) data-change events observed" $results += @{ Passed = $true } diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs index 13e0d06..50bcea7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -269,6 +269,15 @@ public sealed class ScriptedAlarmEngine : IDisposable AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct) { var inputs = BuildReadCache(state.Inputs); + + // Cold-start guard — skip the predicate when any referenced upstream tag has no + // cached value yet (the upstream subscription hasn't delivered its first push). + // Without this, predicates that cast `(double)ctx.GetTag(path).Value` throw NRE on + // every tick until the cache fills, spamming the log with identical stack traces. + // Bad quality is treated the same: the input isn't available at the predicate's + // expected type, so the only defensible move is to hold the prior condition state. + if (!AreInputsReady(inputs)) return seed; + var context = new AlarmPredicateContext(inputs, state.Logger, _clock); bool predicateTrue; @@ -305,6 +314,27 @@ public sealed class ScriptedAlarmEngine : IDisposable return d; } + /// + /// Returns true when every entry in has a non-null value + /// and a Good-quality . A false here lets + /// callers short-circuit script evaluation — predicates that unconditionally cast + /// ctx.GetTag(path).Value to a numeric type would otherwise throw + /// until the upstream subscription delivers + /// its first push. + /// + private static bool AreInputsReady(IReadOnlyDictionary cache) + { + foreach (var kv in cache) + { + if (kv.Value is null || kv.Value.Value is null) return false; + // OPC UA Part 4 StatusCode: bit 31 = severity 10 (Bad). Treat Good + Uncertain + // as "ready"; Uncertain carries a value the script can still inspect + make a + // qualified decision from. Only outright Bad is skipped. + if ((kv.Value.StatusCode & 0x80000000u) != 0) return false; + } + return true; + } + private void EmitEvent(AlarmState state, AlarmConditionState condition, EmissionKind kind) { // Suppressed kind means shelving ate the emission — we don't fire for subscribers diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs b/src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs index 3ff2b9d..dac6599 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs @@ -228,6 +228,14 @@ public sealed class VirtualTagEngine : IDisposable try { var ctxCache = BuildReadCache(state.Reads); + + // Cold-start guard — hold the prior value when any upstream input is still + // unset or Bad-quality. Evaluating with nulls would throw inside the script + // (scripts cast ctx.GetTag(path).Value directly) and produce a persistent + // BadInternalError result until the upstream cache fills. Keeping the prior + // snapshot is more honest: the virtual tag simply hasn't been computed yet. + if (!AreInputsReady(ctxCache)) return; + var context = new VirtualTagContext( ctxCache, (p, v) => OnScriptSetVirtualTag(p, v), @@ -278,6 +286,23 @@ public sealed class VirtualTagEngine : IDisposable return map; } + /// + /// Returns true when every entry in has a non-null value + /// and a Good/Uncertain-quality . Scripts + /// cast ctx.GetTag(path).Value unconditionally; short-circuiting before the + /// script runs keeps a not-yet-cached upstream from faulting the virtual tag with + /// BadInternalError. + /// + private static bool AreInputsReady(IReadOnlyDictionary cache) + { + foreach (var kv in cache) + { + if (kv.Value is null || kv.Value.Value is null) return false; + if ((kv.Value.StatusCode & 0x80000000u) != 0) return false; + } + return true; + } + private void OnScriptSetVirtualTag(string path, object? value) { if (!_tags.ContainsKey(path))