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))