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 once at construction so // DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter). 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. private readonly IReadOnlyCollection _dependencyRefs; // 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); BuildTagTable(); _dependencyRefs = _tagsByRawPath.Values .SelectMany(t => t.DependencyRefs) .Distinct(StringComparer.Ordinal) .ToArray(); } private void BuildTagTable() { foreach (var entry in _options.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; _stateByRawPath[entry.RawPath] = 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); } } } } // ---- 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) { // The authored tag table is fixed at construction (a RawTags change respawns the driver, mirroring // the fleet's factory-built-options pattern). Reinit drops compiled scripts (IScriptCacheOwner // contract — stops collectible ALCs accreting) + re-arms timers so the calc set stays live. StopTimers(); _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; }