using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
///
/// The Calculation pseudo-driver. Its tags are ordinary raw tags whose value is computed
/// by a C# script over other tags' live values — there is no backend, no endpoint, no discovery, no
/// writes. It implements:
///
/// - — lifecycle + always-Connected health.
/// - — the host feeds it the other tags' live values.
/// - — computed values flow out via
/// exactly like any driver, so the ordinary node fan-out + the mux re-entry (calc-of-calc) apply.
/// - — on-demand read returns the last computed snapshot
/// (BadWaitingForInitialData before the first evaluation).
///
///
///
/// Triggers. A tag re-evaluates on any of its declared dependencies changing (change-trigger,
/// gated exactly like VirtualTagActor: nothing publishes until every declared dep has arrived
/// at least once, and equal results are deduped) and/or on a timer (one timer per interval group).
///
///
/// Error semantics (mini-design §6). An evaluator Failure publishes Bad quality
/// carrying the last-known value, once per Good→Bad transition and only after all deps have arrived,
/// and emits a ScriptLogEntry on the script-logs topic (via the injected
/// 's ScriptLogTopicSink). Recovery to Good is force-published.
/// A historized Bad records BadInternalError.
///
///
public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscribable, IReadable, IDisposable
{
// OPC UA status codes surfaced by the calc driver.
private const uint StatusGood = 0u;
private const uint StatusBadWaitingForInitialData = 0x80320000u;
private const uint StatusBadInternalError = 0x80020000u;
private readonly string _driverInstanceId;
private readonly CalculationDriverOptions _options;
private readonly ILogger _logger;
private readonly ScriptRootLogger _scriptRoot;
private readonly CalculationEvaluator _evaluator;
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
// and REBUILT on every ReinitializeAsync (an in-place ApplyDelta re-binds an edited calc-tag set).
private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
private readonly Dictionary> _changeDependents = new(StringComparer.Ordinal);
// Union of every tag's declared dependency refs — the interest set the host registers on the mux.
// Reassigned by RebuildTagTable so a redeploy that adds/removes dependencies re-registers on the mux.
private IReadOnlyCollection _dependencyRefs = Array.Empty();
// Live evaluation state. Guarded by _lock: OnDependencyValue (actor thread) and timer ticks
// (pool threads) can both drive an evaluation, so all state mutation is serialized.
private readonly object _lock = new();
private readonly Dictionary _depValues = new(StringComparer.Ordinal);
private readonly HashSet _arrivedDeps = new(StringComparer.Ordinal);
private readonly Dictionary _stateByRawPath = new(StringComparer.Ordinal);
private CalculationTimerScheduler? _timers;
private volatile bool _running;
private DateTime? _lastComputeUtc;
private bool _disposed;
/// Occurs when a calc tag's computed value changes (or transitions Good↔Bad).
public event EventHandler? OnDataChange;
/// Initializes a new .
/// Bound options carrying the authored raw calc tags.
/// Stable logical id of this driver instance.
/// Root script logger — user ctx.Logger output + failure entries fan out
/// to the script-logs topic through its ScriptLogTopicSink. When null a no-op logger is used.
/// Host-side diagnostics logger; defaults to the null logger.
public CalculationDriver(
CalculationDriverOptions options,
string driverInstanceId,
ScriptRootLogger? scriptRoot = null,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger.Instance;
_scriptRoot = scriptRoot ?? new ScriptRootLogger(new LoggerConfiguration().CreateLogger());
_evaluator = new CalculationEvaluator(
_logger is ILogger el ? el : NullLogger.Instance,
_scriptRoot,
_options.RunTimeout);
RebuildTagTable(_options);
}
///
/// (Re)build the authored tag table (), the change-trigger fan-out
/// (), the dependency-ref interest set (),
/// and the per-tag state map () from . Called
/// once at construction AND on every so an in-place ApplyDelta — a
/// calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor
/// and reinit paths share ONE construction routine and cannot drift.
/// Per-tag is preserved for a tag that survives the edit (cheap last-known
/// value carry-over); a brand-new tag starts fresh; a removed tag's state is dropped. MUST be called
/// under when invoked post-construction (the ctor runs single-threaded).
///
/// The (new) bound options carrying the authored raw calc tags.
private void RebuildTagTable(CalculationDriverOptions config)
{
_tagsByRawPath.Clear();
_changeDependents.Clear();
// Retain surviving tags' state; anything not re-authored is dropped when we swap this in below.
var retainedState = new Dictionary(StringComparer.Ordinal);
foreach (var entry in config.RawTags)
{
if (!CalculationTagDefinition.TryFromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
_logger.LogWarning(
"Calculation {Driver}: raw tag {RawPath} has no scriptId; skipping (not a calc tag)",
_driverInstanceId, entry.RawPath);
continue;
}
if (string.IsNullOrWhiteSpace(def.ScriptSource))
{
_logger.LogWarning(
"Calculation {Driver}: calc tag {RawPath} (script {Script}) has no resolved scriptSource; " +
"it will not compute until the deploy artifact injects the script source",
_driverInstanceId, entry.RawPath, def.ScriptId);
}
_tagsByRawPath[entry.RawPath] = def;
// Carry the last-known state forward for a surviving tag; a brand-new tag starts fresh.
retainedState[entry.RawPath] =
_stateByRawPath.TryGetValue(entry.RawPath, out var prior) ? prior : new TagState();
if (def.ChangeTriggered)
{
foreach (var dep in def.DependencyRefs)
{
if (!_changeDependents.TryGetValue(dep, out var list))
_changeDependents[dep] = list = new List();
if (!list.Contains(entry.RawPath, StringComparer.Ordinal)) list.Add(entry.RawPath);
}
}
}
// Swap in the retained/new state map — a removed tag's state is dropped so it stops publishing.
_stateByRawPath.Clear();
foreach (var (rawPath, state) in retainedState) _stateByRawPath[rawPath] = state;
_dependencyRefs = _tagsByRawPath.Values
.SelectMany(t => t.DependencyRefs)
.Distinct(StringComparer.Ordinal)
.ToArray();
}
// Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
// tolerant) so an ApplyDelta payload re-binds identically to the spawn payload.
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
/// Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
/// payload). The ctor-fixed timeout is kept — RunTimeout is bound
/// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the
/// current tags so a malformed delta can never blank the driver.
private CalculationDriverOptions ParseOptions(string driverConfigJson)
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
try
{
var dto = JsonSerializer.Deserialize(driverConfigJson, ReinitJsonOptions);
if (dto is not null)
{
return new CalculationDriverOptions
{
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
RunTimeout = _options.RunTimeout,
};
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex,
"Calculation {Driver}: reinit config parse failed; keeping the current tag table",
_driverInstanceId);
}
}
return new CalculationDriverOptions { RawTags = _options.RawTags, RunTimeout = _options.RunTimeout };
}
/// Minimal reinit-bind DTO — the calc driver has no backend config, only the injected raw tags
/// (+ an optional eval-timeout, ignored on reinit since the evaluator is ctor-fixed). Mirrors the factory's
/// CalculationDriverConfigDto.
private sealed class ReinitConfigDto
{
public List? RawTags { get; init; }
public int? RunTimeoutMs { get; init; }
}
// ---- IDriver ----
///
public string DriverInstanceId => _driverInstanceId;
///
public string DriverType => DriverTypeNames.Calculation;
///
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_running = true;
StartTimers();
_logger.LogInformation(
"Calculation {Driver}: initialized with {TagCount} calc tag(s), {DepCount} distinct dependency ref(s)",
_driverInstanceId, _tagsByRawPath.Count, _dependencyRefs.Count);
return Task.CompletedTask;
}
///
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
// merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
// respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
// fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
// here. Without this, added tags never compute, removed tags keep publishing, and edited scripts
// serve stale results (deploy reports success but nothing changed). After the rebuild _dependencyRefs
// reflects the new authored tags, and the host re-registers the mux interest post-reinit (the
// DriverInstanceActor.DeltaApplied → DriverHostActor path, sequenced AFTER this completes).
StopTimers();
var newOptions = ParseOptions(driverConfigJson);
lock (_lock)
{
RebuildTagTable(newOptions);
}
// Drop compiled scripts (IScriptCacheOwner contract — stops collectible ALCs accreting AND forces a
// recompile from each tag's NEW ScriptSource) + re-arm timers off the rebuilt tag set.
_evaluator.ClearCompiledScripts();
_running = true;
StartTimers();
return Task.CompletedTask;
}
///
public Task ShutdownAsync(CancellationToken cancellationToken)
{
_running = false;
StopTimers();
return Task.CompletedTask;
}
///
public DriverHealth GetHealth() => new(DriverState.Healthy, _lastComputeUtc, null);
///
public long GetMemoryFootprint() => 0;
///
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
{
// The compile cache is the only optional cache; dropping it is safe (recompiled on next eval).
_evaluator.ClearCompiledScripts();
return Task.CompletedTask;
}
// ---- IDependencyConsumer ----
///
public IReadOnlyCollection DependencyRefs => _dependencyRefs;
///
public void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
{
if (!_running) return;
List? dependents;
lock (_lock)
{
_depValues[rawPath] = value;
_arrivedDeps.Add(rawPath);
if (!_changeDependents.TryGetValue(rawPath, out dependents) || dependents.Count == 0) return;
// Evaluate each change-triggered dependent inline under the lock (the compile cache makes this
// a bounded method invocation — the same "fast enough to run inline" contract as the VT evaluator).
foreach (var calcRawPath in dependents)
EvaluateTag(calcRawPath, timestampUtc);
}
}
// ---- ISubscribable ----
///
public Task SubscribeAsync(
IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var handle = new CalculationSubscriptionHandle($"calc-{Guid.NewGuid():N}");
// Initial-data callback (OPC UA convention): surface the current snapshot for each subscribed calc
// tag so a freshly-materialised node shows BadWaitingForInitialData until its first evaluation lands.
foreach (var reference in fullReferences)
{
var snapshot = ReadOne(reference);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, snapshot));
}
return Task.FromResult(handle);
}
///
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
=> Task.CompletedTask;
// ---- IReadable ----
///
public Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
results[i] = ReadOne(fullReferences[i]);
return Task.FromResult>(results);
}
private DataValueSnapshot ReadOne(string reference)
{
var now = DateTime.UtcNow;
lock (_lock)
{
if (!_stateByRawPath.TryGetValue(reference, out var st) || !st.HasValue)
return new DataValueSnapshot(null, StatusBadWaitingForInitialData, null, now);
var status = st.LastPublishedBad ? StatusBadInternalError : StatusGood;
return new DataValueSnapshot(st.LastValue, status, st.LastTimestampUtc, st.LastTimestampUtc);
}
}
// ---- evaluation ----
/// Timer-trigger entry point — evaluates a single calc tag. Guards the lock itself (called
/// from a pool thread, unlike the change path which is already under the lock).
private void EvaluateTagLocked(string rawPath)
{
if (!_running) return;
lock (_lock) EvaluateTag(rawPath, DateTime.UtcNow);
}
/// Evaluate one calc tag and publish the outcome. MUST be called under .
private void EvaluateTag(string rawPath, DateTime timestampUtc)
{
if (!_tagsByRawPath.TryGetValue(rawPath, out var def)) return;
if (string.IsNullOrWhiteSpace(def.ScriptSource)) return; // unresolved script — never computes (logged at init)
var st = _stateByRawPath[rawPath];
// Change-gate (mini-design §4): publish nothing until every declared dep has arrived at least once.
var allArrived = def.DependencyRefs.All(_arrivedDeps.Contains);
// Build the per-tag dependency snapshot (only its declared reads, current values).
var deps = new Dictionary(StringComparer.Ordinal);
foreach (var dep in def.DependencyRefs)
if (_depValues.TryGetValue(dep, out var v)) deps[dep] = v;
var result = _evaluator.Evaluate(rawPath, def.ScriptSource, deps);
if (!result.Success)
{
OnEvaluationFailed(def, st, result.Reason ?? "evaluator failure", allArrived, timestampUtc);
return;
}
if (!allArrived) return; // change-trigger warm-up: hold until inputs are ready
// Value dedup — but bypass it while recovering from Bad so a recovery whose value equals the
// pre-failure value still republishes Good (otherwise the node would stay Bad forever).
if (!st.LastPublishedBad && st.HasValue && Equals(st.LastValue, result.Value))
return;
st.HasValue = true;
st.LastValue = result.Value;
st.LastPublishedBad = false;
st.LastTimestampUtc = timestampUtc;
_lastComputeUtc = DateTime.UtcNow;
Publish(rawPath, result.Value, StatusGood, timestampUtc);
}
/// Failure tail (mirrors VirtualTagActor 02/S13): always emits the per-failure
/// script-log; additionally publishes a Bad snapshot — carrying the last-known value — once per
/// Good→Bad transition and only after every declared dependency has arrived.
private void OnEvaluationFailed(
CalculationTagDefinition def, TagState st, string reason, bool allArrived, DateTime timestampUtc)
{
PublishScriptLog(def, reason);
if (st.LastPublishedBad || !allArrived) return;
st.LastPublishedBad = true;
st.LastTimestampUtc = timestampUtc;
// Carry the last-known value (null if none) with Bad quality.
Publish(def.RawPath, st.HasValue ? st.LastValue : null, StatusBadInternalError, timestampUtc);
}
private void Publish(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
{
var snapshot = new DataValueSnapshot(value, statusCode, timestampUtc, timestampUtc);
// Fired under _lock — the subscriber (DriverInstanceActor) marshals the event to its own thread,
// so there is no re-entrant call back into this driver.
OnDataChange?.Invoke(this, new DataChangeEventArgs(SharedHandle, rawPath, snapshot));
}
private void PublishScriptLog(CalculationTagDefinition def, string reason)
{
// Route through the script logger bound with ScriptId + the calc tag's RawPath (in the VirtualTagId
// slot the Script-log page attributes on). The ScriptRootLogger's ScriptLogTopicSink converts this
// Warning event into a ScriptLogEntry on the script-logs DPS topic — the driver needs no Akka access.
_scriptRoot.Logger
.ForContext(ScriptLoggerFactory.ScriptIdProperty, def.ScriptId)
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, def.RawPath)
.Warning("{Reason}", reason);
}
private void StartTimers()
{
var timed = _tagsByRawPath.Values.Where(t => t.TimerInterval is not null).ToArray();
if (timed.Length == 0) return;
_timers = new CalculationTimerScheduler(EvaluateTagLocked, _logger);
_timers.Start(timed);
}
private void StopTimers()
{
_timers?.Dispose();
_timers = null;
}
/// Test/diagnostic accessor: number of timer ticks skipped for in-flight groups.
internal long TimerSkippedTickCount => _timers?.SkippedTickCount ?? 0;
// A single shared handle for host-pushed publishes (change + timer). SubscribeAsync mints its own
// per-call handle for the initial-data callback; steady-state publishes reuse this so OnDataChange
// always carries a non-null handle.
private static readonly CalculationSubscriptionHandle SharedHandle = new("calc-shared");
///
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_running = false;
StopTimers();
_evaluator.Dispose();
}
/// Per-tag published state (guarded by ).
private sealed class TagState
{
public bool HasValue;
public object? LastValue;
public bool LastPublishedBad;
public DateTime LastTimestampUtc;
}
private sealed record CalculationSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
}