diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
index fc6f79d5..6bc2cb7c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs
@@ -2,6 +2,8 @@ using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
@@ -33,6 +35,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
private readonly IActorRef _publishActor;
private readonly IActorRef? _mux;
private readonly IVirtualTagEvaluator _evaluator;
+ // Sink for historized VirtualTag results (plans with Historize=true). NullHistoryWriter when no
+ // durable historian is wired, so OnResult always has a non-null target.
+ private readonly IHistoryWriter _history;
private readonly ILoggingAdapter _log = Context.GetLogger();
// vtagId -> spawned child VirtualTagActor.
@@ -50,20 +55,27 @@ public sealed class VirtualTagHostActor : ReceiveActor
/// Optional dependency multiplexer; passed to each spawned child so it can
/// register interest in its dependency refs. Null on the dev/Mac path (no live values).
/// The evaluator each child uses to compute its expression.
- public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator) =>
- Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator));
+ /// Sink for results whose plan has Historize=true. Null ⇒
+ /// (no durable historian wired), so existing call sites
+ /// compile unchanged and never historize.
+ public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator,
+ IHistoryWriter? historyWriter = null) =>
+ Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator, historyWriter));
/// Initializes a new instance of the class.
/// The OPC UA publish actor results are bridged to.
/// Optional dependency multiplexer passed to each spawned child.
/// The evaluator each child uses to compute its expression.
- public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator)
+ /// Sink for historized results; null ⇒ .
+ public VirtualTagHostActor(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator,
+ IHistoryWriter? historyWriter = null)
{
ArgumentNullException.ThrowIfNull(publishActor);
ArgumentNullException.ThrowIfNull(evaluator);
_publishActor = publishActor;
_mux = mux;
_evaluator = evaluator;
+ _history = historyWriter ?? NullHistoryWriter.Instance;
Receive(OnApply);
Receive(OnResult);
@@ -154,6 +166,15 @@ public sealed class VirtualTagHostActor : ReceiveActor
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
nodeId, result.Value, OpcUaQuality.Good, result.TimestampUtc));
+
+ // 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
+ // to. For a computed value source == server, so both timestamps are the evaluation time.
+ if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
+ {
+ _history.Record(nodeId, new DataValueSnapshot(
+ result.Value, 0u /* StatusCodes.Good */, result.TimestampUtc, result.TimestampUtc));
+ }
}
private void OnChildTerminated(Terminated msg)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj
index c870ba65..98eb9cce 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj
@@ -32,6 +32,9 @@
+
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
index 43461e72..76bc7396 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
@@ -4,6 +4,8 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
@@ -153,6 +155,71 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
secondChild.ShouldNotBe(firstChild);
}
+ /// A plan with an explicit Historize flag, so H5c can assert the host historizes a
+ /// result iff the plan opted in. Mirrors but threads .
+ private static EquipmentVirtualTagPlan PlanH(
+ string vtagId, string equipmentId, string name, bool historize, string folderPath = "") =>
+ new(
+ VirtualTagId: vtagId,
+ EquipmentId: equipmentId,
+ FolderPath: folderPath,
+ Name: name,
+ DataType: "Double",
+ Expression: "ctx.GetTag(\"a\")",
+ DependencyRefs: new[] { "a" },
+ Historize: historize);
+
+ /// H5c: a result for a vtag whose plan has Historize=true is recorded with the
+ /// IHistoryWriter under the SAME folder-scoped NodeId the value was published to, carrying the
+ /// result value and OPC UA Good (0u) status — in addition to the normal publish.
+ [Fact]
+ public void Historized_vtag_result_is_recorded_with_the_history_writer()
+ {
+ 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, 6, 7, 12, 0, 0, DateTimeKind.Utc);
+ host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, CorrelationId.NewId()));
+
+ // The publish still happens — historization is additive, not a replacement.
+ var update = publish.ExpectMsg();
+ update.NodeId.ShouldBe("eq-1/speed-rpm");
+
+ // Wait for the history record to land (delivered on the same actor turn as the publish).
+ AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
+ var (path, value) = writer.Calls[0];
+ path.ShouldBe("eq-1/speed-rpm");
+ value.Value.ShouldBe(42.0);
+ value.StatusCode.ShouldBe(0u); // OPC UA Good
+ value.SourceTimestampUtc.ShouldBe(ts);
+ value.ServerTimestampUtc.ShouldBe(ts);
+ }
+
+ /// H5c: a result for a vtag whose plan has Historize=false is NOT recorded — the writer
+ /// is never called — though the value is still published.
+ [Fact]
+ public void Non_historized_vtag_result_is_not_recorded()
+ {
+ 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: false) }));
+
+ host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, DateTime.UtcNow, CorrelationId.NewId()));
+
+ // The value is still published…
+ publish.ExpectMsg();
+ // …but the historian was never touched.
+ publish.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
+ writer.Calls.ShouldBeEmpty();
+ }
+
/// A plan with an explicit Expression + DependencyRefs (the H1b in-place-change case).
private static EquipmentVirtualTagPlan PlanWithRefs(
string vtagId, string equipmentId, string name, string expression, params string[] refs) =>
@@ -277,6 +344,18 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
mux.LastSender.ShouldNotBe(firstChild);
}
+ /// Capturing : records every Record call so H5c tests can
+ /// assert the host historizes (or does not) and with what path + snapshot.
+ private sealed class CapturingHistoryWriter : IHistoryWriter
+ {
+ public readonly List<(string Path, DataValueSnapshot Value)> Calls = new();
+
+ /// Captures the path + snapshot of a Record call.
+ /// The virtual tag path.
+ /// The data value snapshot.
+ public void Record(string path, DataValueSnapshot value) => Calls.Add((path, value));
+ }
+
/// Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's
/// OnResult path directly via synthetic EvaluationResults.
private sealed class StubEvaluator : IVirtualTagEvaluator