diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 14ad26d1..a1090eab 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -21,6 +21,7 @@
+
@@ -81,6 +82,7 @@
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs
new file mode 100644
index 00000000..481ce36e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs
@@ -0,0 +1,193 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Commons.Engines;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
+using SerilogLogger = Serilog.ILogger;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
+
+///
+/// T0-4 — the Calculation driver's script evaluator. A calc tag is a raw tag whose value is
+/// produced by a C# script over other tags' live values. This evaluator is a deliberate mirror of
+/// the VirtualTag RoslynVirtualTagEvaluator: it compiles each unique source once via
+/// (the Roslyn-backed sandbox), caches the
+/// resulting evaluator keyed by source in a ,
+/// and runs steady-state evaluations as in-process method invocations against the dependency
+/// dictionary — fast enough to run inline on the driver's dispatch. It reuses
+/// verbatim so the Monaco editor, sandbox, and diagnostics pipeline
+/// apply to calc scripts with no divergence.
+///
+///
+/// Single-tag mode. A calc tag produces exactly one output value — its own — so cross-tag
+/// ctx.SetVirtualTag writes are dropped (and logged at Debug). Fan-out between tags is owned
+/// by the host's DependencyMuxActor (calc-of-calc chains re-enter the mux), never by the
+/// eval engine, exactly as in the VirtualTag adapter.
+///
+///
+/// Error surfacing (for WP7). This evaluator never throws for script faults: compile error,
+/// sandbox violation, runtime throw, and timeout all return
+/// with a descriptive Reason. A successful run returns
+/// carrying the computed value (which may be null). WP7's CalculationDriver maps a
+/// Failure to Bad quality + a ScriptLogEntry and an Ok to the published value.
+///
+///
+/// Cache management. Implements so WP7 can drop compiled
+/// scripts at each deploy generation whose source set changed — mirroring the VirtualTag adapter's
+/// apply-boundary clear, which is what stops collectible AssemblyLoadContexts accreting across
+/// script edits.
+///
+///
+public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
+{
+ // Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose()
+ // that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the
+ // WP7 driver per deploy generation — is what stops ALCs accreting across script edits.
+ private readonly CompiledScriptCache _cache = new();
+ private readonly ILogger _logger;
+ private readonly SerilogLogger _scriptRoot;
+ private readonly TimeSpan _runTimeout;
+ private bool _disposed;
+
+ /// Initializes a new .
+ /// Logger for host-side compilation/execution diagnostics.
+ /// Root script logger; user ctx.Logger.* output flows through this to the Script-log page.
+ /// Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator).
+ public CalculationEvaluator(
+ ILogger logger,
+ ScriptRootLogger scriptRoot,
+ TimeSpan? runTimeout = null)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
+ _runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
+ }
+
+ ///
+ /// Evaluate for the calc tag against
+ /// the snapshot in (RawPath → last value). Never throws — script
+ /// faults are reported via ; a successful run returns
+ /// carrying the single computed value.
+ ///
+ /// Identity of the calc tag (bound to the script log; in the live path equals the script id).
+ /// The C# script source producing the tag's value.
+ /// Read-only snapshot of dependency values keyed by RawPath.
+ public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary dependencies)
+ {
+ if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
+ if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression");
+
+ // Passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` needs no Roslyn.
+ // Semantics are byte-identical to the compiled mirror: a present dependency returns its value
+ // (Ok), an absent one makes GetTag yield a Bad snapshot whose .Value is null, so the script
+ // returns null — Ok(null) here too. Near-misses fall through to the compiled path.
+ if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef))
+ {
+ return dependencies.TryGetValue(passthroughRef, out var ptValue)
+ ? VirtualTagEvalResult.Ok(ptValue)
+ : VirtualTagEvalResult.Ok(null);
+ }
+
+ var readCache = BuildReadCache(dependencies);
+ // Per-evaluation script logger: bind both ScriptId and CalcTagId (via the VirtualTagId slot, which
+ // the Script-log page attributes on) from the calc-tag id so each line is attributable.
+ var scriptLog = _scriptRoot
+ .ForContext(ScriptLoggerFactory.ScriptIdProperty, calcTagId)
+ .ForContext(ScriptLoggerFactory.VirtualTagIdProperty, calcTagId);
+ var context = new VirtualTagContext(
+ readCache,
+ // Single-tag mode: a calc tag has exactly one output (its own), so cross-tag writes are dropped.
+ setVirtualTag: (path, _) =>
+ _logger.LogDebug("Calculation {Id}: cross-tag write to {Path} dropped (single-tag evaluator)",
+ calcTagId, path),
+ logger: scriptLog);
+
+ // Fetch + run inside a bounded loop so an ObjectDisposedException caused by a concurrent
+ // ClearCompiledScripts (which disposed the evaluator between GetOrCompile and the run's
+ // disposed guard) re-fetches and retries exactly once. The loop runs at most twice.
+ for (var attempt = 0; ; attempt++)
+ {
+ ScriptEvaluator evaluator;
+ try
+ {
+ evaluator = _cache.GetOrCompile(scriptSource);
+ }
+ catch (CompilationErrorException ex)
+ {
+ _logger.LogWarning(ex, "Calculation {Id}: Roslyn compile failed", calcTagId);
+ return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
+ }
+ catch (ScriptSandboxViolationException ex)
+ {
+ _logger.LogWarning(ex, "Calculation {Id}: sandbox violation", calcTagId);
+ return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
+ }
+ catch (ObjectDisposedException)
+ {
+ // Fetch-time disposal is a shutdown signal (the cache is tearing down), not the
+ // apply-boundary race — never retry.
+ return VirtualTagEvalResult.Failure("evaluator disposed");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Calculation {Id}: compile threw", calcTagId);
+ return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
+ }
+
+ try
+ {
+ // Route through TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't
+ // interrupt a CPU-bound/infinite-loop Roslyn script, so the timed wrapper is what bounds a
+ // runaway script to the wall-clock budget instead of wedging the driver's dispatch.
+ var timed = new TimedScriptEvaluator(evaluator, _runTimeout);
+ var raw = timed.RunAsync(context).GetAwaiter().GetResult();
+ return VirtualTagEvalResult.Ok(raw);
+ }
+ catch (ScriptTimeoutException)
+ {
+ return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
+ }
+ catch (ObjectDisposedException) when (!_disposed && attempt == 0)
+ {
+ // A concurrent ClearCompiledScripts disposed the evaluator between our GetOrCompile and the
+ // run's disposed-guard. Re-fetch (recompiling the same source into the CURRENT generation)
+ // and retry once. A second disposal mid-retry falls through to the failure path below.
+ continue;
+ }
+ catch (ObjectDisposedException)
+ {
+ return VirtualTagEvalResult.Failure("evaluator disposed during evaluation");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Calculation {Id}: script execution threw", calcTagId);
+ return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ public void ClearCompiledScripts() => _cache.Clear();
+
+ private static IReadOnlyDictionary BuildReadCache(
+ IReadOnlyDictionary deps)
+ {
+ // VirtualTagContext.GetTag returns a DataValueSnapshot — wrap each raw dep value as Good-quality
+ // so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null.
+ var nowUtc = DateTime.UtcNow;
+ var cache = new Dictionary(StringComparer.Ordinal);
+ foreach (var kv in deps)
+ {
+ cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
+ }
+ return cache;
+ }
+
+ /// Disposes the evaluator, draining + disposing every cached compile's collectible ALC.
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _cache.Dispose();
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj
new file mode 100644
index 00000000..c3aa6ec4
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.Calculation
+ ZB.MOM.WW.OtOpcUa.Driver.Calculation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs
new file mode 100644
index 00000000..3893e2bc
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationEvaluatorTests.cs
@@ -0,0 +1,247 @@
+using System.Reflection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Serilog;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
+
+///
+/// T0-4 — parity tests for against the VirtualTag
+/// RoslynVirtualTagEvaluator it mirrors: the passthrough fast-path, the wall-clock timeout,
+/// sandbox-violation rejection, single-tag SetVirtualTag drop, compile-error surfacing, and
+/// the compile-cache reuse / clear contract. All script faults surface as
+/// VirtualTagEvalResult.Failure (never a thrown exception) so WP7 can map them to Bad quality.
+///
+public sealed class CalculationEvaluatorTests
+{
+ /// Builds a no-op for tests that don't assert on script logging.
+ private static ScriptRootLogger NoOpScriptRoot() =>
+ new(new LoggerConfiguration().CreateLogger());
+
+ // ── passthrough fast-path ───────────────────────────────────────────────────────────────
+
+ /// The mirror shape return ctx.GetTag("X").Value; returns the dependency value
+ /// verbatim without invoking Roslyn (parity with the VT evaluator's fast-path).
+ [Fact]
+ public void Passthrough_returns_dependency_value_without_compiling()
+ {
+ using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot());
+
+ var result = sut.Evaluate(
+ calcTagId: "calc-mirror",
+ scriptSource: "return ctx.GetTag(\"a\").Value;",
+ dependencies: new Dictionary { ["a"] = 42 });
+
+ result.Success.ShouldBeTrue(result.Reason);
+ result.Value.ShouldBe(42);
+ }
+
+ /// Decisive proof the fast-path skips Roslyn: the compiled-script cache stays EMPTY after a
+ /// mirror evaluation, then grows to one entry once a genuine (non-mirror) expression forces compilation.
+ [Fact]
+ public void Passthrough_does_not_populate_the_compiled_script_cache()
+ {
+ using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot());
+
+ sut.Evaluate("calc-mirror", "return ctx.GetTag(\"a\").Value;",
+ new Dictionary { ["a"] = 1 });
+ CompiledCacheCount(sut).ShouldBe(0);
+
+ sut.Evaluate("calc-real", "return (int)ctx.GetTag(\"a\").Value + 1;",
+ new Dictionary { ["a"] = 1 });
+ CompiledCacheCount(sut).ShouldBe(1);
+ }
+
+ // ── timeout ─────────────────────────────────────────────────────────────────────────────
+
+ /// An infinite-loop script returns Failure ("timed out") within the wall-clock budget instead
+ /// of wedging the caller. The whole call is WaitAsync-bounded so a regression FAILS rather than hangs.
+ [Fact]
+ public async Task Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang()
+ {
+ using var sut = new CalculationEvaluator(
+ NullLogger.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200));
+
+ var evalTask = Task.Run(
+ () => sut.Evaluate("calc-loop", "while(true){}return 0;", new Dictionary()),
+ TestContext.Current.CancellationToken);
+
+ var result = await evalTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+
+ result.Success.ShouldBeFalse();
+ result.Reason.ShouldNotBeNull();
+ result.Reason.ShouldContain("timed out");
+ }
+
+ // ── sandbox violation ───────────────────────────────────────────────────────────────────
+
+ /// A script touching a forbidden API (file I/O) is rejected the way the VT evaluator rejects
+ /// it: the ScriptSandboxViolationException from compile is caught and returned as a Failure whose
+ /// reason names the sandbox violation — never propagated as an exception.
+ [Fact]
+ public void Evaluate_sandbox_violation_returns_Failure_and_does_not_throw()
+ {
+ using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot());
+
+ var result = sut.Evaluate(
+ calcTagId: "calc-fileio",
+ scriptSource: "return System.IO.File.ReadAllText(\"c:/secrets.txt\");",
+ dependencies: new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason.ShouldNotBeNull();
+ result.Reason.ShouldContain("sandbox violation");
+ }
+
+ // ── single-tag mode: ctx.SetVirtualTag drop ─────────────────────────────────────────────
+
+ /// Single-tag mode: a script calling ctx.SetVirtualTag has that cross-tag write dropped
+ /// (logged at Debug) while the script's own return value — the calc tag's single output — is preserved.
+ [Fact]
+ public void SetVirtualTag_call_is_dropped_and_logged_single_output_preserved()
+ {
+ var logger = new CapturingLogger();
+ using var sut = new CalculationEvaluator(logger, NoOpScriptRoot());
+
+ var result = sut.Evaluate(
+ calcTagId: "calc-fanout",
+ scriptSource: "ctx.SetVirtualTag(\"other\", 99); return (int)ctx.GetTag(\"a\").Value + 1;",
+ dependencies: new Dictionary { ["a"] = 41 });
+
+ // The write to "other" is dropped, but the calc tag's own output flows through unaffected.
+ result.Success.ShouldBeTrue(result.Reason);
+ result.Value.ShouldBe(42);
+
+ // The drop was logged (single-tag evaluator), never applied.
+ logger.Entries.ShouldContain(e =>
+ e.Level == LogLevel.Debug && e.Message.Contains("dropped") && e.Message.Contains("other"));
+ }
+
+ // ── compile error ───────────────────────────────────────────────────────────────────────
+
+ /// A compile error returns Failure with a descriptive reason (parity with the VT evaluator).
+ [Fact]
+ public void Evaluate_compile_error_returns_Failure_with_reason()
+ {
+ using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot());
+
+ var result = sut.Evaluate("calc-bad", "this is not valid C#;", new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason.ShouldNotBeNull();
+ result.Reason.ShouldContain("compile");
+ }
+
+ /// A runtime throw returns Failure ("threw") rather than propagating (parity with the VT evaluator).
+ [Fact]
+ public void Evaluate_runtime_exception_returns_Failure_with_reason()
+ {
+ using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot());
+
+ var result = sut.Evaluate(
+ calcTagId: "calc-div0",
+ scriptSource: "int a = 0; return 1 / a;",
+ dependencies: new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason.ShouldNotBeNull();
+ result.Reason.ShouldContain("threw");
+ }
+
+ // ── cache reuse + clear ─────────────────────────────────────────────────────────────────
+
+ /// A compiled (non-mirror) expression is cached and re-used across calls: two evaluations of the
+ /// same source produce fresh values but leave the cache at exactly one entry.
+ [Fact]
+ public void Evaluate_caches_compiled_expression_across_calls()
+ {
+ using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot());
+ const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
+
+ var first = sut.Evaluate("calc-cache", expr, new Dictionary { ["x"] = 5 });
+ var second = sut.Evaluate("calc-cache", expr, new Dictionary { ["x"] = 7 });
+
+ first.Success.ShouldBeTrue(first.Reason);
+ first.Value.ShouldBe(10);
+ second.Success.ShouldBeTrue(second.Reason);
+ second.Value.ShouldBe(14);
+ CompiledCacheCount(sut).ShouldBe(1);
+ }
+
+ /// drops every cached compile — the hook
+ /// WP7 calls per deploy generation to release stale AssemblyLoadContexts — and the same source recompiles
+ /// cleanly afterward.
+ [Fact]
+ public void ClearCompiledScripts_empties_cache_then_recompiles()
+ {
+ using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot());
+ const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;";
+
+ sut.Evaluate("calc-clear", expr, new Dictionary { ["a"] = 1 });
+ CompiledCacheCount(sut).ShouldBe(1);
+
+ sut.ClearCompiledScripts();
+ CompiledCacheCount(sut).ShouldBe(0);
+
+ var again = sut.Evaluate("calc-clear", expr, new Dictionary { ["a"] = 41 });
+ again.Success.ShouldBeTrue(again.Reason);
+ again.Value.ShouldBe(42);
+ CompiledCacheCount(sut).ShouldBe(1);
+ }
+
+ // ── guard rails ─────────────────────────────────────────────────────────────────────────
+
+ /// Empty / whitespace scripts return Failure (parity with the VT evaluator).
+ [Fact]
+ public void Evaluate_empty_expression_returns_Failure()
+ {
+ using var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot());
+
+ sut.Evaluate("calc-empty", "", new Dictionary()).Success.ShouldBeFalse();
+ sut.Evaluate("calc-empty", " ", new Dictionary()).Success.ShouldBeFalse();
+ }
+
+ /// Evaluation after disposal returns Failure ("disposed") rather than throwing.
+ [Fact]
+ public void Evaluate_after_dispose_returns_Failure()
+ {
+ var sut = new CalculationEvaluator(NullLogger.Instance, NoOpScriptRoot());
+ sut.Dispose();
+
+ var result = sut.Evaluate("calc", "return 1;", new Dictionary());
+
+ result.Success.ShouldBeFalse();
+ result.Reason!.ShouldContain("disposed");
+ }
+
+ /// Reads the count of the private CompiledScriptCache (which exposes a Count
+ /// property) via reflection — mirrors the VT evaluator test's cache-count probe.
+ private static int CompiledCacheCount(CalculationEvaluator sut)
+ {
+ var field = typeof(CalculationEvaluator)
+ .GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic);
+ field.ShouldNotBeNull();
+ var cache = field.GetValue(sut)!;
+ var countProp = cache.GetType().GetProperty("Count");
+ countProp.ShouldNotBeNull();
+ return (int)countProp.GetValue(cache)!;
+ }
+
+ /// Minimal that records emitted entries so the SetVirtualTag-drop
+ /// test can assert the Debug "dropped" line was logged.
+ private sealed class CapturingLogger : ILogger
+ {
+ public List<(LogLevel Level, string Message)> Entries { get; } = [];
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter) =>
+ Entries.Add((logLevel, formatter(state, exception)));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj
new file mode 100644
index 00000000..b3cbfe8b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+