feat(runtime): EvaluationResult carries OpcUaQuality; VT bridge publishes it + historizes BadInternalError on Bad (02/S13)
This commit is contained in:
@@ -10,26 +10,34 @@
|
|||||||
{
|
{
|
||||||
"id": "R2-03-T2",
|
"id": "R2-03-T2",
|
||||||
"subject": "Add OpcUaQuality Quality (default Good) to VirtualTagActor.EvaluationResult; bridge passes result.Quality into AttributeValueUpdate; historize maps Bad to 0x80020000u BadInternalError (RED bridge tests then GREEN)",
|
"subject": "Add OpcUaQuality Quality (default Good) to VirtualTagActor.EvaluationResult; bridge passes result.Quality into AttributeValueUpdate; historize maps Bad to 0x80020000u BadInternalError (RED bridge tests then GREEN)",
|
||||||
"status": "pending",
|
"status": "completed",
|
||||||
"blockedBy": ["R2-03-T1"]
|
"blockedBy": [
|
||||||
|
"R2-03-T1"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "R2-03-T3",
|
"id": "R2-03-T3",
|
||||||
"subject": "VirtualTagActor failure/throw publishes Bad EvaluationResult once per transition, gated on all dependencyRefs arrived; recovery bypasses value-dedup; update Evaluator_failure_publishes_ScriptLogEntry_warning expectation (turns T1 green)",
|
"subject": "VirtualTagActor failure/throw publishes Bad EvaluationResult once per transition, gated on all dependencyRefs arrived; recovery bypasses value-dedup; update Evaluator_failure_publishes_ScriptLogEntry_warning expectation (turns T1 green)",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": ["R2-03-T2"]
|
"blockedBy": [
|
||||||
|
"R2-03-T2"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "R2-03-T4",
|
"id": "R2-03-T4",
|
||||||
"subject": "S13 transition-semantics unit guards: second consecutive failure publishes nothing; same-value recovery republishes Good; evaluator-threw path also degrades",
|
"subject": "S13 transition-semantics unit guards: second consecutive failure publishes nothing; same-value recovery republishes Good; evaluator-threw path also degrades",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": ["R2-03-T3"]
|
"blockedBy": [
|
||||||
|
"R2-03-T3"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "R2-03-T5",
|
"id": "R2-03-T5",
|
||||||
"subject": "S13 cold-start suppression guards: missing-dep failure publishes no Bad; failure after all deps arrived publishes Bad (inputs-ready gate)",
|
"subject": "S13 cold-start suppression guards: missing-dep failure publishes no Bad; failure after all deps arrived publishes Bad (inputs-ready gate)",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": ["R2-03-T3"]
|
"blockedBy": [
|
||||||
|
"R2-03-T3"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "R2-03-T6",
|
"id": "R2-03-T6",
|
||||||
@@ -47,31 +55,45 @@
|
|||||||
"id": "R2-03-T8",
|
"id": "R2-03-T8",
|
||||||
"subject": "S12 fix: retry-once on ObjectDisposedException in RoslynVirtualTagEvaluator.Evaluate (re-fetch via GetOrCompile; no retry when _disposed or second hit); update xmldoc (turns T7 green)",
|
"subject": "S12 fix: retry-once on ObjectDisposedException in RoslynVirtualTagEvaluator.Evaluate (re-fetch via GetOrCompile; no retry when _disposed or second hit); update xmldoc (turns T7 green)",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": ["R2-03-T6", "R2-03-T7"]
|
"blockedBy": [
|
||||||
|
"R2-03-T6",
|
||||||
|
"R2-03-T7"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "R2-03-T9",
|
"id": "R2-03-T9",
|
||||||
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
|
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": ["R2-03-T3"]
|
"blockedBy": [
|
||||||
|
"R2-03-T3"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "R2-03-T10",
|
"id": "R2-03-T10",
|
||||||
"subject": "P7 fix: gate the OnApply ClearCompiledScripts on HashSet.SetEquals of desired vs _planByVtag expression sets (ordinal), with explanatory comment (turns T9 green)",
|
"subject": "P7 fix: gate the OnApply ClearCompiledScripts on HashSet.SetEquals of desired vs _planByVtag expression sets (ordinal), with explanatory comment (turns T9 green)",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": ["R2-03-T9"]
|
"blockedBy": [
|
||||||
|
"R2-03-T9"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "R2-03-T11",
|
"id": "R2-03-T11",
|
||||||
"subject": "Full-suite sweep (Runtime.Tests whole project + RoslynVirtualTagEvaluatorTests + solution build) and STATUS.md / tasks.json bookkeeping",
|
"subject": "Full-suite sweep (Runtime.Tests whole project + RoslynVirtualTagEvaluatorTests + solution build) and STATUS.md / tasks.json bookkeeping",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": ["R2-03-T4", "R2-03-T5", "R2-03-T8", "R2-03-T10"]
|
"blockedBy": [
|
||||||
|
"R2-03-T4",
|
||||||
|
"R2-03-T5",
|
||||||
|
"R2-03-T8",
|
||||||
|
"R2-03-T10"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "R2-03-T12",
|
"id": "R2-03-T12",
|
||||||
"subject": "Live-/run gate on docker-dev (rebuild BOTH centrals): author working VT, break it, Client.CLI read shows StatusCode Bad with last value, fix and read Good; P7 no-recompile spot-check on identical redeploy",
|
"subject": "Live-/run gate on docker-dev (rebuild BOTH centrals): author working VT, break it, Client.CLI read shows StatusCode Bad with last value, fix and read Good; P7 no-recompile spot-check on identical redeploy",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"blockedBy": ["R2-03-T11"]
|
"blockedBy": [
|
||||||
|
"R2-03-T11"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"lastUpdated": "2026-07-12"
|
"lastUpdated": "2026-07-12"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Akka.Event;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||||
@@ -20,7 +21,13 @@ public sealed class VirtualTagActor : ReceiveActor
|
|||||||
public const string ScriptLogsTopic = "script-logs";
|
public const string ScriptLogsTopic = "script-logs";
|
||||||
|
|
||||||
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
|
||||||
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation);
|
|
||||||
|
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
|
||||||
|
/// OPC UA quality the node should read: <see cref="OpcUaQuality.Good"/> for a fresh computed value,
|
||||||
|
/// <see cref="OpcUaQuality.Bad"/> when the script failed/timed out (02/S13) — in which case
|
||||||
|
/// <paramref name="Value"/> is the last-known value (or null if none). Defaulting to Good keeps
|
||||||
|
/// every existing construction site source-compatible.</summary>
|
||||||
|
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good);
|
||||||
|
|
||||||
private readonly string _virtualTagId;
|
private readonly string _virtualTagId;
|
||||||
private readonly string _scriptId;
|
private readonly string _scriptId;
|
||||||
|
|||||||
@@ -176,16 +176,22 @@ public sealed class VirtualTagHostActor : ReceiveActor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
|
||||||
|
// failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
|
||||||
|
// sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
|
||||||
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
|
||||||
nodeId, result.Value, OpcUaQuality.Good, result.TimestampUtc));
|
nodeId, result.Value, result.Quality, result.TimestampUtc));
|
||||||
|
|
||||||
// Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so
|
// Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so
|
||||||
// no parallel map. The historian path key is the SAME folder-scoped NodeId we just published
|
// no parallel map. The historian path key is the SAME folder-scoped NodeId we just published
|
||||||
// to. For a computed value source == server, so both timestamps are the evaluation time.
|
// to. For a computed value source == server, so both timestamps are the evaluation time. Bad
|
||||||
|
// results record BadInternalError (0x80020000u) — the dormant VirtualTagEngine's Bad-snapshot
|
||||||
|
// status — so the historian trend can distinguish a degraded sample from a Good one.
|
||||||
if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
|
if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
|
||||||
{
|
{
|
||||||
|
var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
|
||||||
_history.Record(nodeId, new DataValueSnapshot(
|
_history.Record(nodeId, new DataValueSnapshot(
|
||||||
result.Value, 0u /* StatusCodes.Good */, result.TimestampUtc, result.TimestampUtc));
|
result.Value, status, result.TimestampUtc, result.TimestampUtc));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,48 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
|
|||||||
bad.TimestampUtc.ShouldBe(ts2);
|
bad.TimestampUtc.ShouldBe(ts2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>02/S13 bridge: a Bad-quality <see cref="VirtualTagActor.EvaluationResult"/> must bridge to
|
||||||
|
/// an <see cref="OpcUaPublishActor.AttributeValueUpdate"/> carrying <see cref="OpcUaQuality.Bad"/>
|
||||||
|
/// (not the old hard-coded Good).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Bad_quality_EvaluationResult_is_bridged_as_Bad()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
|
||||||
|
|
||||||
|
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
|
||||||
|
|
||||||
|
var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
host.Tell(new VirtualTagActor.EvaluationResult(
|
||||||
|
"vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad));
|
||||||
|
|
||||||
|
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
|
||||||
|
update.Quality.ShouldBe(OpcUaQuality.Bad);
|
||||||
|
update.Value.ShouldBe(42.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>02/S13 historize parity: a Bad result on a historized plan must record the dormant
|
||||||
|
/// engine's BadInternalError status (0x80020000u) in the snapshot, not the Good 0u.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Historized_Bad_result_is_recorded_with_BadInternalError_status()
|
||||||
|
{
|
||||||
|
var publish = CreateTestProbe();
|
||||||
|
var writer = new CapturingHistoryWriter();
|
||||||
|
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer));
|
||||||
|
|
||||||
|
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
|
||||||
|
new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) }));
|
||||||
|
|
||||||
|
var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
host.Tell(new VirtualTagActor.EvaluationResult(
|
||||||
|
"vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad));
|
||||||
|
|
||||||
|
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
|
||||||
|
AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
|
||||||
|
writer.Calls.TryPeek(out var captured).ShouldBeTrue();
|
||||||
|
captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Evaluator that returns <c>Ok(42.0)</c> on its first call and <c>Failure</c> thereafter —
|
/// <summary>Evaluator that returns <c>Ok(42.0)</c> on its first call and <c>Failure</c> thereafter —
|
||||||
/// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
|
/// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
|
||||||
/// evaluation thread.</summary>
|
/// evaluation thread.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user