merge wt/wp7 into v3/batch2-raw-ui (Wave C — Calculation driver)
This commit is contained in:
@@ -55,6 +55,10 @@ public sealed class DraftSnapshot
|
|||||||
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
|
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
|
||||||
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
|
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
|
||||||
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
|
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives
|
||||||
|
/// the WP7 Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
|
||||||
|
public IReadOnlyList<Script> Scripts { get; init; } = [];
|
||||||
/// <summary>Gets the list of poll groups.</summary>
|
/// <summary>Gets the list of poll groups.</summary>
|
||||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public static class DraftSnapshotFactory
|
|||||||
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
|
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
|
||||||
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
||||||
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
||||||
|
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
|
||||||
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
||||||
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
||||||
ActiveReservations = await db.ExternalIdReservations
|
ActiveReservations = await db.ExternalIdReservations
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
@@ -38,9 +39,107 @@ public static class DraftValidator
|
|||||||
ValidateRawNameCharset(draft, errors);
|
ValidateRawNameCharset(draft, errors);
|
||||||
ValidateHistorizedTagnameLength(draft, errors);
|
ValidateHistorizedTagnameLength(draft, errors);
|
||||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||||
|
ValidateCalculationTags(draft, errors);
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||||
|
/// a <see cref="Script"/> row in the draft.</item>
|
||||||
|
/// <item><b>Cycle gate (hard error)</b> — build the calc→calc edge set (a calc tag → its
|
||||||
|
/// <c>ctx.GetTag</c> deps that are <b>themselves</b> Calculation tags; cross-driver refs are terminal,
|
||||||
|
/// not edges) and run <see cref="DependencyGraph.DetectCycles"/> (Tarjan SCC). Any SCC / self-loop is a
|
||||||
|
/// deploy error naming the cycle members — an undetected calc cycle is a live oscillation loop.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Compile is deliberately NOT hard-gated (VirtualTag parity — the editor's live diagnostics mirror it and
|
||||||
|
/// a runtime compile failure lands as Bad quality + a script-log).</summary>
|
||||||
|
private static void ValidateCalculationTags(DraftSnapshot draft, List<ValidationError> errors)
|
||||||
|
{
|
||||||
|
var driverTypeByInstance = draft.DriverInstances
|
||||||
|
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
|
||||||
|
var driverTypeByDevice = draft.Devices
|
||||||
|
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => driverTypeByInstance.TryGetValue(g.First().DriverInstanceId, out var dt) ? dt : null,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var resolver = BuildRawPathResolver(draft);
|
||||||
|
var scriptExists = new HashSet<string>(draft.Scripts.Select(s => s.ScriptId), StringComparer.Ordinal);
|
||||||
|
var scriptSourceById = draft.Scripts
|
||||||
|
.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Identify calc tags (tags on a Calculation-typed device) + their RawPath + scriptId in one pass.
|
||||||
|
var calcTags = new List<(Tag Tag, string? RawPath, string? ScriptId)>();
|
||||||
|
var calcRawPaths = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var t in draft.Tags)
|
||||||
|
{
|
||||||
|
if (!driverTypeByDevice.TryGetValue(t.DeviceId, out var dt)
|
||||||
|
|| !string.Equals(dt, Core.Abstractions.DriverTypeNames.Calculation, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
var rawPath = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||||
|
calcTags.Add((t, rawPath, ParseScriptId(t.TagConfig)));
|
||||||
|
if (rawPath is not null) calcRawPaths.Add(rawPath);
|
||||||
|
}
|
||||||
|
if (calcTags.Count == 0) return;
|
||||||
|
|
||||||
|
// Rule 1 — scriptId existence.
|
||||||
|
foreach (var (t, _, scriptId) in calcTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptId))
|
||||||
|
errors.Add(new("CalculationScriptMissing",
|
||||||
|
$"Calculation tag '{t.TagId}' has no 'scriptId' in its TagConfig", t.TagId));
|
||||||
|
else if (!scriptExists.Contains(scriptId!))
|
||||||
|
errors.Add(new("CalculationScriptNotFound",
|
||||||
|
$"Calculation tag '{t.TagId}' references script '{scriptId}', which does not exist in the draft",
|
||||||
|
t.TagId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2 — cycle gate. Edges run from a calc tag to each dep that is ITSELF a calc tag.
|
||||||
|
var graph = new DependencyGraph();
|
||||||
|
foreach (var (_, rawPath, scriptId) in calcTags)
|
||||||
|
{
|
||||||
|
if (rawPath is null) continue; // broken chain — flagged by the charset rule; not a graph node
|
||||||
|
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) ? src : null;
|
||||||
|
var calcDeps = EquipmentScriptPaths.ExtractDependencyRefs(source)
|
||||||
|
.Where(calcRawPaths.Contains)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
graph.Add(rawPath, calcDeps);
|
||||||
|
}
|
||||||
|
foreach (var cycle in graph.DetectCycles())
|
||||||
|
errors.Add(new("CalculationDependencyCycle",
|
||||||
|
"Calculation tags form a dependency cycle (members: " + string.Join(", ", cycle) +
|
||||||
|
"); a calc→calc cycle is a live oscillation loop and must be broken before deploy",
|
||||||
|
cycle.Count > 0 ? cycle[0] : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Extract the <c>scriptId</c> string from a calc tag's schemaless <c>TagConfig</c> JSON. Never
|
||||||
|
/// throws — malformed/blank/non-object JSON or an absent/non-string <c>scriptId</c> yields null.</summary>
|
||||||
|
private static string? ParseScriptId(string? tagConfig)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
|
||||||
|
if (root.TryGetProperty("scriptId", out var el)
|
||||||
|
&& el.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var v = el.GetString();
|
||||||
|
return string.IsNullOrWhiteSpace(v) ? null : v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
|
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
|
||||||
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
|
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
|
||||||
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
|
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<!-- WP7: the calc deploy-gate reuses the pure DependencyGraph (Tarjan SCC) from Core.VirtualTags,
|
||||||
|
whose closure includes Microsoft.CodeAnalysis.CSharp.Scripting 4.12.0 (== CodeAnalysis.Common
|
||||||
|
4.12.0), while EF's transitive CodeAnalysis.Common 5.0.0 wins resolution. Suppress NU1608 — the
|
||||||
|
only surface Configuration touches (DependencyGraph.DetectCycles) has ZERO Roslyn dependency, so
|
||||||
|
the version skew never runs. Mirrors the identical suppression + rationale in the Host csproj. -->
|
||||||
|
<NoWarn>$(NoWarn);CS1591;NU1608</NoWarn>
|
||||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace>
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -30,6 +35,9 @@
|
|||||||
<!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons.
|
<!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons.
|
||||||
Cycle-safe — Commons has zero in-repo ProjectReferences. -->
|
Cycle-safe — Commons has zero in-repo ProjectReferences. -->
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||||
|
<!-- WP7: the deploy-gate cycle check reuses DependencyGraph (Tarjan SCC) for the calc→calc
|
||||||
|
dependency-cycle rule. Cycle-safe — Core.VirtualTags does not reference Configuration. -->
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ public static class DriverTypeNames
|
|||||||
/// <summary>AVEVA System Platform (Wonderware) Galaxy driver, via the mxaccessgw gateway.</summary>
|
/// <summary>AVEVA System Platform (Wonderware) Galaxy driver, via the mxaccessgw gateway.</summary>
|
||||||
public const string Galaxy = "GalaxyMxGateway";
|
public const string Galaxy = "GalaxyMxGateway";
|
||||||
|
|
||||||
|
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
|
||||||
|
public const string Calculation = "Calculation";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Every driver-type string declared above, for callers that need to enumerate
|
/// Every driver-type string declared above, for callers that need to enumerate
|
||||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||||
@@ -64,5 +67,6 @@ public static class DriverTypeNames
|
|||||||
FOCAS,
|
FOCAS,
|
||||||
OpcUaClient,
|
OpcUaClient,
|
||||||
Galaxy,
|
Galaxy,
|
||||||
|
Calculation,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional composable driver capability: the driver consumes <b>other</b> tags' live values.
|
||||||
|
/// The host registers the declared <see cref="DependencyRefs"/> with its per-node dependency mux
|
||||||
|
/// and forwards every matching value change into <see cref="OnDependencyValue"/> — the seam that
|
||||||
|
/// lets a host-blind driver (e.g. the <c>Calculation</c> pseudo-driver) read the address space's
|
||||||
|
/// live values without any cross-driver plumbing of its own.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The mechanism mirrors how a <c>VirtualTagActor</c> registers interest on the
|
||||||
|
/// <c>DependencyMuxActor</c>: the mux already receives every driver's
|
||||||
|
/// <c>AttributeValuePublished</c> keyed by wire-ref (under v3, RawPath), so a driver that
|
||||||
|
/// implements this interface simply piggy-backs on that fan-out. A calc tag's computed output
|
||||||
|
/// re-enters the mux via the ordinary driver publish path, so calc-of-calc chains work with no
|
||||||
|
/// extra machinery — which is exactly why the deploy-time cycle gate is mandatory.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Implementations of <see cref="OnDependencyValue"/> are invoked from an actor context and
|
||||||
|
/// <b>must be non-blocking</b> — do the work inline and cheaply (the compile cache makes
|
||||||
|
/// steady-state calc evaluation a bounded method invocation), never block on I/O.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IDependencyConsumer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-refs (RawPaths) this driver needs fed to it, derived from its authored tags. The host
|
||||||
|
/// re-reads this after <see cref="IDriver.InitializeAsync"/> / <see cref="IDriver.ReinitializeAsync"/>
|
||||||
|
/// and (re)registers the set with its dependency mux on every apply, so a tag/script edit that
|
||||||
|
/// changes the set converges without a bespoke notification.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyCollection<string> DependencyRefs { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host push of a single dependency value change. Called from an actor context, so the
|
||||||
|
/// implementation must be non-blocking.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rawPath">The changed dependency's wire-ref (RawPath).</param>
|
||||||
|
/// <param name="value">The new value (may be null).</param>
|
||||||
|
/// <param name="statusCode">The OPC UA status code carried with the value (0 = Good).</param>
|
||||||
|
/// <param name="timestampUtc">The source timestamp of the change.</param>
|
||||||
|
void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc);
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>Calculation</c> pseudo-driver. Its tags are ordinary raw tags whose value is <b>computed</b>
|
||||||
|
/// by a C# script over other tags' live values — there is no backend, no endpoint, no discovery, no
|
||||||
|
/// writes. It implements:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="IDriver"/> — lifecycle + always-Connected health.</item>
|
||||||
|
/// <item><see cref="IDependencyConsumer"/> — the host feeds it the other tags' live values.</item>
|
||||||
|
/// <item><see cref="ISubscribable"/> — computed values flow out via <see cref="OnDataChange"/>
|
||||||
|
/// exactly like any driver, so the ordinary node fan-out + the mux re-entry (calc-of-calc) apply.</item>
|
||||||
|
/// <item><see cref="IReadable"/> — on-demand read returns the last computed snapshot
|
||||||
|
/// (<c>BadWaitingForInitialData</c> before the first evaluation).</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Triggers.</b> A tag re-evaluates on any of its declared dependencies changing (change-trigger,
|
||||||
|
/// gated exactly like <c>VirtualTagActor</c>: 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).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Error semantics (mini-design §6).</b> An evaluator <c>Failure</c> publishes <b>Bad</b> quality
|
||||||
|
/// carrying the last-known value, once per Good→Bad transition and only after all deps have arrived,
|
||||||
|
/// and emits a <c>ScriptLogEntry</c> on the <c>script-logs</c> topic (via the injected
|
||||||
|
/// <see cref="ScriptRootLogger"/>'s <c>ScriptLogTopicSink</c>). Recovery to Good is force-published.
|
||||||
|
/// A historized Bad records <c>BadInternalError</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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<CalculationDriver> _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<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
|
||||||
|
private readonly Dictionary<string, List<string>> _changeDependents = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Union of every tag's declared dependency refs — the interest set the host registers on the mux.
|
||||||
|
private readonly IReadOnlyCollection<string> _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<string, object?> _depValues = new(StringComparer.Ordinal);
|
||||||
|
private readonly HashSet<string> _arrivedDeps = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, TagState> _stateByRawPath = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private CalculationTimerScheduler? _timers;
|
||||||
|
private volatile bool _running;
|
||||||
|
private DateTime? _lastComputeUtc;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Occurs when a calc tag's computed value changes (or transitions Good↔Bad).</summary>
|
||||||
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new <see cref="CalculationDriver"/>.</summary>
|
||||||
|
/// <param name="options">Bound options carrying the authored raw calc tags.</param>
|
||||||
|
/// <param name="driverInstanceId">Stable logical id of this driver instance.</param>
|
||||||
|
/// <param name="scriptRoot">Root script logger — user <c>ctx.Logger</c> output + failure entries fan out
|
||||||
|
/// to the <c>script-logs</c> topic through its <c>ScriptLogTopicSink</c>. When null a no-op logger is used.</param>
|
||||||
|
/// <param name="logger">Host-side diagnostics logger; defaults to the null logger.</param>
|
||||||
|
public CalculationDriver(
|
||||||
|
CalculationDriverOptions options,
|
||||||
|
string driverInstanceId,
|
||||||
|
ScriptRootLogger? scriptRoot = null,
|
||||||
|
ILogger<CalculationDriver>? logger = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
_options = options;
|
||||||
|
_driverInstanceId = driverInstanceId;
|
||||||
|
_logger = logger ?? NullLogger<CalculationDriver>.Instance;
|
||||||
|
_scriptRoot = scriptRoot ?? new ScriptRootLogger(new LoggerConfiguration().CreateLogger());
|
||||||
|
_evaluator = new CalculationEvaluator(
|
||||||
|
_logger is ILogger<CalculationEvaluator> el ? el : NullLogger<CalculationEvaluator>.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<string>();
|
||||||
|
if (!list.Contains(entry.RawPath, StringComparer.Ordinal)) list.Add(entry.RawPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IDriver ----
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverInstanceId => _driverInstanceId;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverType => DriverTypeNames.Calculation;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
StopTimers();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, _lastComputeUtc, null);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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 ----
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyCollection<string> DependencyRefs => _dependencyRefs;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
if (!_running) return;
|
||||||
|
List<string>? 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 ----
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<string> 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<ISubscriptionHandle>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
// ---- IReadable ----
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> 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<IReadOnlyList<DataValueSnapshot>>(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 ----
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
private void EvaluateTagLocked(string rawPath)
|
||||||
|
{
|
||||||
|
if (!_running) return;
|
||||||
|
lock (_lock) EvaluateTag(rawPath, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Evaluate one calc tag and publish the outcome. MUST be called under <see cref="_lock"/>.</summary>
|
||||||
|
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<string, object?>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Failure tail (mirrors <c>VirtualTagActor</c> 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.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test/diagnostic accessor: number of timer ticks skipped for in-flight groups.</summary>
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_running = false;
|
||||||
|
StopTimers();
|
||||||
|
_evaluator.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-tag published state (guarded by <see cref="_lock"/>).</summary>
|
||||||
|
private sealed class TagState
|
||||||
|
{
|
||||||
|
public bool HasValue;
|
||||||
|
public object? LastValue;
|
||||||
|
public bool LastPublishedBad;
|
||||||
|
public DateTime LastTimestampUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CalculationSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static factory registration helper for <see cref="CalculationDriver"/>. The Server's
|
||||||
|
/// <c>DriverFactoryBootstrap</c> calls <see cref="Register"/> once at startup; the bootstrapper then
|
||||||
|
/// materialises <c>Calculation</c> DriverInstance rows into live driver instances. Mirrors
|
||||||
|
/// <c>ModbusDriverFactoryExtensions</c>, with the addition of a <see cref="ScriptRootLogger"/> so a
|
||||||
|
/// script failure fans out onto the <c>script-logs</c> topic (via the root logger's
|
||||||
|
/// <c>ScriptLogTopicSink</c>) — the calc driver has no Akka access of its own.
|
||||||
|
/// </summary>
|
||||||
|
public static class CalculationDriverFactoryExtensions
|
||||||
|
{
|
||||||
|
/// <summary>The <c>DriverInstance.DriverType</c> string this factory registers under.</summary>
|
||||||
|
public const string DriverTypeName = "Calculation";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register the Calculation factory with the driver registry. The optional
|
||||||
|
/// <paramref name="loggerFactory"/> + <paramref name="scriptRoot"/> are captured at registration
|
||||||
|
/// time; both may be null (the guard test invokes <c>Register(registry)</c> reflectively with the
|
||||||
|
/// trailing parameters defaulted), in which case the driver runs with the null logger + a no-op
|
||||||
|
/// script-logger.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="registry">The driver factory registry to register with.</param>
|
||||||
|
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
||||||
|
/// <param name="scriptRoot">Optional root script logger (fans failure entries onto the <c>script-logs</c> topic).</param>
|
||||||
|
public static void Register(
|
||||||
|
DriverFactoryRegistry registry,
|
||||||
|
ILoggerFactory? loggerFactory = null,
|
||||||
|
ScriptRootLogger? scriptRoot = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public overload for the Server-side bootstrapper + test consumers.</summary>
|
||||||
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
|
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
|
||||||
|
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
||||||
|
public static CalculationDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
|
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, scriptRoot: null);
|
||||||
|
|
||||||
|
/// <summary>Logger/script-logger-aware overload — used by <see cref="Register"/>'s closure via DI.</summary>
|
||||||
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
|
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
|
||||||
|
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
|
||||||
|
/// <param name="scriptRoot">Optional root script logger for failure fan-out.</param>
|
||||||
|
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
|
||||||
|
public static CalculationDriver CreateInstance(
|
||||||
|
string driverInstanceId, string driverConfigJson,
|
||||||
|
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
|
var dto = JsonSerializer.Deserialize<CalculationDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
|
var options = new CalculationDriverOptions
|
||||||
|
{
|
||||||
|
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
||||||
|
RunTimeout = dto.RunTimeoutMs is { } ms && ms > 0
|
||||||
|
? TimeSpan.FromMilliseconds(ms) : TimeSpan.FromSeconds(2),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new CalculationDriver(
|
||||||
|
options, driverInstanceId, scriptRoot,
|
||||||
|
logger: loggerFactory?.CreateLogger<CalculationDriver>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The merged-config DTO the calc factory binds from. The Calculation driver has no
|
||||||
|
/// backend/endpoint config — only the injected raw tags (+ an optional evaluation-timeout override).</summary>
|
||||||
|
internal sealed class CalculationDriverConfigDto
|
||||||
|
{
|
||||||
|
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName).</summary>
|
||||||
|
public List<RawTagEntry>? RawTags { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional per-script evaluation wall-clock budget override, in milliseconds (default 2000).</summary>
|
||||||
|
public int? RunTimeoutMs { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bound options for a <see cref="CalculationDriver"/> instance. The Calculation pseudo-driver has
|
||||||
|
/// no backend/endpoint config — its only input is the authored raw calc tags the deploy artifact
|
||||||
|
/// injects as the <see cref="RawTags"/> list (each carrying the tag's <c>scriptId</c> + resolved
|
||||||
|
/// <c>scriptSource</c> + trigger config inside its <see cref="RawTagEntry.TagConfig"/> blob).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalculationDriverOptions
|
||||||
|
{
|
||||||
|
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent), from
|
||||||
|
/// the deploy artifact. Each is mapped to a <see cref="CalculationTagDefinition"/> at construction.</summary>
|
||||||
|
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = Array.Empty<RawTagEntry>();
|
||||||
|
|
||||||
|
/// <summary>Per-script wall-clock evaluation budget handed to the <see cref="CalculationEvaluator"/>.
|
||||||
|
/// Defaults to 2 seconds (parity with the VirtualTag evaluator).</summary>
|
||||||
|
public TimeSpan RunTimeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-connect probe for the <c>Calculation</c> pseudo-driver. There is no backend to connect to —
|
||||||
|
/// the driver computes tags from other tags' values — so the probe just confirms the driver config
|
||||||
|
/// parses (mini-design §7). Per-script compile verification belongs to the editor diagnostics + the
|
||||||
|
/// tag editor's inline panel, not the probe (which receives only the driver config). Never throws;
|
||||||
|
/// returns Ok with negligible latency.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalculationDriverProbe : IDriverProbe
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Opts = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverType => CalculationDriverFactoryExtensions.DriverTypeName;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configJson))
|
||||||
|
return Task.FromResult(new DriverProbeResult(false, "Config JSON is empty.", null));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parse-only: a Calculation driver's config is well-formed as long as it deserialises to an
|
||||||
|
// object (the RawTags array is optional — a driver with no calc tags is still valid).
|
||||||
|
_ = JsonSerializer.Deserialize<CalculationDriverFactoryExtensions.CalculationDriverConfigDto>(configJson, Opts);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single authored calc tag as the driver runs it: its <see cref="RawPath"/> identity, the script
|
||||||
|
/// that computes it (<see cref="ScriptId"/> for attribution, <see cref="ScriptSource"/> for
|
||||||
|
/// evaluation), its trigger config (<see cref="ChangeTriggered"/> / <see cref="TimerInterval"/>), and
|
||||||
|
/// the static <see cref="DependencyRefs"/> extracted from the script's literal
|
||||||
|
/// <c>ctx.GetTag("…")</c> reads.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="RawPath">The tag's cluster-scoped RawPath — its identity + the calc-tag id passed to the evaluator.</param>
|
||||||
|
/// <param name="ScriptId">Logical FK to the shared <c>Script</c> entity — bound to the failure script-log for attribution.</param>
|
||||||
|
/// <param name="ScriptSource">The resolved C# script source that produces the tag's value; empty when unresolved.</param>
|
||||||
|
/// <param name="ChangeTriggered">Re-evaluate whenever a declared dependency changes.</param>
|
||||||
|
/// <param name="TimerInterval">Optional periodic re-evaluation cadence; null ⇒ change-trigger only.</param>
|
||||||
|
/// <param name="DependencyRefs">Distinct literal <c>ctx.GetTag</c> RawPaths this tag reads (first-seen order).</param>
|
||||||
|
public sealed record CalculationTagDefinition(
|
||||||
|
string RawPath,
|
||||||
|
string ScriptId,
|
||||||
|
string ScriptSource,
|
||||||
|
bool ChangeTriggered,
|
||||||
|
TimeSpan? TimerInterval,
|
||||||
|
IReadOnlyList<string> DependencyRefs)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Map a calc tag's <c>TagConfig</c> blob (<c>{ "scriptId", "scriptSource", "changeTriggered",
|
||||||
|
/// "timerIntervalMs" }</c>) + its RawPath into a <see cref="CalculationTagDefinition"/>. Returns
|
||||||
|
/// <see langword="false"/> (and leaves <paramref name="def"/> null) only when the blob has no
|
||||||
|
/// <c>scriptId</c> — a tag with no identifiable script cannot be a calc tag. A present
|
||||||
|
/// <c>scriptId</c> with an empty/absent <c>scriptSource</c> still maps (the driver logs it and the
|
||||||
|
/// tag never computes). Never throws — malformed JSON yields a miss.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagConfig">The calc tag's schemaless <c>TagConfig</c> JSON.</param>
|
||||||
|
/// <param name="rawPath">The tag's RawPath identity.</param>
|
||||||
|
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||||
|
/// <returns><see langword="true"/> when a definition (with a scriptId) was mapped.</returns>
|
||||||
|
public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def)
|
||||||
|
{
|
||||||
|
def = null!;
|
||||||
|
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
|
||||||
|
|
||||||
|
string? scriptId;
|
||||||
|
string scriptSource;
|
||||||
|
bool changeTriggered;
|
||||||
|
int? timerMs;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(tagConfig);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||||
|
|
||||||
|
scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
|
||||||
|
? sidEl.GetString() : null;
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptId)) return false;
|
||||||
|
|
||||||
|
scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
||||||
|
? srcEl.GetString() ?? string.Empty : string.Empty;
|
||||||
|
|
||||||
|
// changeTriggered defaults to true (mini-design §2) — only an explicit false disables it.
|
||||||
|
changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
|
||||||
|
&& ctEl.ValueKind == JsonValueKind.False);
|
||||||
|
|
||||||
|
timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
|
||||||
|
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
|
||||||
|
? ms : null;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deps = EquipmentScriptPaths.ExtractDependencyRefs(scriptSource);
|
||||||
|
def = new CalculationTagDefinition(
|
||||||
|
RawPath: rawPath,
|
||||||
|
ScriptId: scriptId!,
|
||||||
|
ScriptSource: scriptSource,
|
||||||
|
ChangeTriggered: changeTriggered,
|
||||||
|
TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
|
||||||
|
DependencyRefs: deps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic re-evaluation scheduler for calc tags carrying a <c>timerIntervalMs</c>. A deliberate
|
||||||
|
/// mirror of the VirtualTag <c>TimerTriggerScheduler</c> (<c>Core.VirtualTags</c>): one
|
||||||
|
/// <see cref="System.Threading.Timer"/> per distinct interval group (so the wire count stays low
|
||||||
|
/// regardless of tag count) with a per-group in-flight flag that skips a tick when the prior tick for
|
||||||
|
/// the same group is still running. Revived <b>inside the driver</b> because the VT scheduler is welded
|
||||||
|
/// to <c>VirtualTagEngine.EvaluateOneAsync</c>; here each tick invokes the driver's own evaluate
|
||||||
|
/// callback per tag in the group.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CalculationTimerScheduler : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Action<string> _evaluate;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly List<Timer> _timers = [];
|
||||||
|
private readonly List<TickGroup> _groups = [];
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private long _skippedTickCount;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="CalculationTimerScheduler"/> class.</summary>
|
||||||
|
/// <param name="evaluate">Callback invoked with a calc tag's RawPath when its timer group ticks.</param>
|
||||||
|
/// <param name="logger">Logger for diagnostics.</param>
|
||||||
|
public CalculationTimerScheduler(Action<string> evaluate, ILogger logger)
|
||||||
|
{
|
||||||
|
_evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of timer callbacks that skipped their work because the prior tick for the same
|
||||||
|
/// group was still running. Monotonic; exposed for tests + operational metrics.</summary>
|
||||||
|
public long SkippedTickCount => Interlocked.Read(ref _skippedTickCount);
|
||||||
|
|
||||||
|
/// <summary>Stand up one <see cref="Timer"/> per distinct interval. All tags sharing an interval share
|
||||||
|
/// a timer; each tick re-evaluates every tag in the group.</summary>
|
||||||
|
/// <param name="tags">The calc tags to schedule (only those with a positive <see cref="CalculationTagDefinition.TimerInterval"/> are scheduled).</param>
|
||||||
|
public void Start(IEnumerable<CalculationTagDefinition> tags)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tags);
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(CalculationTimerScheduler));
|
||||||
|
|
||||||
|
var byInterval = tags
|
||||||
|
.Where(t => t.TimerInterval is { } iv && iv > TimeSpan.Zero)
|
||||||
|
.GroupBy(t => t.TimerInterval!.Value);
|
||||||
|
|
||||||
|
foreach (var group in byInterval)
|
||||||
|
{
|
||||||
|
var paths = group.Select(t => t.RawPath).ToArray();
|
||||||
|
var interval = group.Key;
|
||||||
|
var ctx = new TickGroup(paths);
|
||||||
|
_groups.Add(ctx);
|
||||||
|
var timer = new Timer(_ => OnTimer(ctx), null, interval, interval);
|
||||||
|
_timers.Add(timer);
|
||||||
|
_logger.LogInformation("CalculationTimerScheduler: {TagCount} tag(s) on {Interval} cadence",
|
||||||
|
paths.Length, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTimer(TickGroup ctx)
|
||||||
|
{
|
||||||
|
if (_cts.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
// Skip the tick when the prior one for this group is still running — bounds the outstanding
|
||||||
|
// work to one tick per group regardless of how long an evaluation takes.
|
||||||
|
if (Interlocked.CompareExchange(ref ctx.InFlight, 1, 0) != 0)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _skippedTickCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var path in ctx.Paths)
|
||||||
|
{
|
||||||
|
if (_cts.IsCancellationRequested) return;
|
||||||
|
try { _evaluate(path); }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "CalculationTimerScheduler evaluate failed for {Path}", path); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref ctx.InFlight, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases all timers and disposes the scheduler's resources.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_cts.Cancel();
|
||||||
|
foreach (var t in _timers)
|
||||||
|
{
|
||||||
|
try { t.Dispose(); } catch { /* best-effort teardown */ }
|
||||||
|
}
|
||||||
|
_timers.Clear();
|
||||||
|
_groups.Clear();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TickGroup(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
// 0 = idle, 1 = a tick is currently running for this group.
|
||||||
|
public int InFlight;
|
||||||
|
public IReadOnlyList<string> Paths { get; } = paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -21,6 +21,7 @@
|
|||||||
owns DataValueSnapshot. -->
|
owns DataValueSnapshot. -->
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ using TwinCATProbe = Driver.TwinCAT.TwinCATDriverProbe;
|
|||||||
using FocasProbe = Driver.FOCAS.FocasDriverProbe;
|
using FocasProbe = Driver.FOCAS.FocasDriverProbe;
|
||||||
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
|
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
|
||||||
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
|
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
|
||||||
|
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
||||||
@@ -44,7 +45,10 @@ public static class DriverFactoryBootstrap
|
|||||||
{
|
{
|
||||||
var registry = new DriverFactoryRegistry();
|
var registry = new DriverFactoryRegistry();
|
||||||
var loggerFactory = sp.GetService<ILoggerFactory>();
|
var loggerFactory = sp.GetService<ILoggerFactory>();
|
||||||
Register(registry, loggerFactory);
|
// The calc driver needs the root script logger so a script failure fans out onto the
|
||||||
|
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
||||||
|
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
||||||
|
Register(registry, loggerFactory, scriptRoot);
|
||||||
return registry;
|
return registry;
|
||||||
});
|
});
|
||||||
services.AddSingleton<IDriverFactory>(sp =>
|
services.AddSingleton<IDriverFactory>(sp =>
|
||||||
@@ -113,6 +117,7 @@ public static class DriverFactoryBootstrap
|
|||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
|
||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
||||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
||||||
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
@@ -123,10 +128,14 @@ public static class DriverFactoryBootstrap
|
|||||||
/// handles platform/role-dependent stubbing (e.g. Galaxy on macOS), so registering a
|
/// handles platform/role-dependent stubbing (e.g. Galaxy on macOS), so registering a
|
||||||
/// factory here doesn't mean it always runs in production.
|
/// factory here doesn't mean it always runs in production.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory)
|
private static void Register(
|
||||||
|
DriverFactoryRegistry registry,
|
||||||
|
ILoggerFactory? loggerFactory,
|
||||||
|
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||||
{
|
{
|
||||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
|
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
@@ -141,6 +142,13 @@ public static class DeploymentArtifact
|
|||||||
|
|
||||||
var resolver = new RawPathResolver(folders, drivers, devices, groups);
|
var resolver = new RawPathResolver(folders, drivers, devices, groups);
|
||||||
|
|
||||||
|
// scriptId → SourceCode, from the artifact's Scripts array — the same join the VirtualTag plan
|
||||||
|
// builder uses. A calc tag's TagConfig carries a scriptId (not the source); the calc driver is
|
||||||
|
// host-blind and can't resolve it, so we inject the resolved `scriptSource` into the delivered
|
||||||
|
// TagConfig here (the deploy-side counterpart of the Calculation driver). Tags with no scriptId
|
||||||
|
// are untouched.
|
||||||
|
var scriptSourceById = BuildScriptSourceMap(root);
|
||||||
|
|
||||||
foreach (var el in EnumerateArray(root, "Tags"))
|
foreach (var el in EnumerateArray(root, "Tags"))
|
||||||
{
|
{
|
||||||
var deviceId = ReadString(el, "DeviceId");
|
var deviceId = ReadString(el, "DeviceId");
|
||||||
@@ -152,6 +160,7 @@ public static class DeploymentArtifact
|
|||||||
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
|
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
|
||||||
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
|
||||||
? tcEl.GetString() ?? "{}" : "{}";
|
? tcEl.GetString() ?? "{}" : "{}";
|
||||||
|
tagConfig = InjectScriptSource(tagConfig, scriptSourceById);
|
||||||
// WriteIdempotent (bool; non-bool/absent ⇒ false — the safe R2 default: writes are non-idempotent).
|
// WriteIdempotent (bool; non-bool/absent ⇒ false — the safe R2 default: writes are non-idempotent).
|
||||||
var writeIdempotent = el.TryGetProperty("WriteIdempotent", out var wiEl)
|
var writeIdempotent = el.TryGetProperty("WriteIdempotent", out var wiEl)
|
||||||
&& wiEl.ValueKind is JsonValueKind.True or JsonValueKind.False && wiEl.GetBoolean();
|
&& wiEl.ValueKind is JsonValueKind.True or JsonValueKind.False && wiEl.GetBoolean();
|
||||||
@@ -168,6 +177,47 @@ public static class DeploymentArtifact
|
|||||||
return byDriver;
|
return byDriver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
|
||||||
|
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
|
||||||
|
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
|
||||||
|
{
|
||||||
|
var byId = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in EnumerateArray(root, "Scripts"))
|
||||||
|
{
|
||||||
|
var sid = ReadString(el, "ScriptId");
|
||||||
|
if (string.IsNullOrWhiteSpace(sid)) continue;
|
||||||
|
byId[sid!] = el.TryGetProperty("SourceCode", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
|
||||||
|
? srcEl.GetString() ?? string.Empty : string.Empty;
|
||||||
|
}
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <paramref name="tagConfig"/> carries a <c>scriptId</c> (a calc tag), inject the resolved
|
||||||
|
/// <c>scriptSource</c> so the host-blind Calculation driver can compile + evaluate it. Identity for
|
||||||
|
/// tags with no <c>scriptId</c> (every other driver's tags), an unresolvable id, or malformed JSON —
|
||||||
|
/// so no existing tag's blob changes. The pre-existing <c>scriptId</c>/trigger fields are preserved.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON.</param>
|
||||||
|
/// <param name="scriptSourceById">The <c>scriptId → SourceCode</c> map from the artifact.</param>
|
||||||
|
/// <returns>The TagConfig with <c>scriptSource</c> injected, or the original when nothing to inject.</returns>
|
||||||
|
private static string InjectScriptSource(string tagConfig, IReadOnlyDictionary<string, string> scriptSourceById)
|
||||||
|
{
|
||||||
|
if (scriptSourceById.Count == 0 || string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
||||||
|
JsonNode? node;
|
||||||
|
try { node = JsonNode.Parse(tagConfig); }
|
||||||
|
catch (JsonException) { return tagConfig; }
|
||||||
|
if (node is not JsonObject obj) return tagConfig;
|
||||||
|
|
||||||
|
var scriptId = obj.TryGetPropertyValue("scriptId", out var sidNode) && sidNode is JsonValue sv
|
||||||
|
&& sv.TryGetValue<string>(out var sid) ? sid : null;
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptId) || !scriptSourceById.TryGetValue(scriptId!, out var source))
|
||||||
|
return tagConfig;
|
||||||
|
|
||||||
|
obj["scriptSource"] = source;
|
||||||
|
return obj.ToJsonString();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Group the artifact's Devices into per-driver <see cref="DriverDeviceConfigMerger.DeviceRow"/>
|
/// <summary>Group the artifact's Devices into per-driver <see cref="DriverDeviceConfigMerger.DeviceRow"/>
|
||||||
/// lists (entity Name + schemaless DeviceConfig), ordered by DeviceId for a stable merge.</summary>
|
/// lists (entity Name + schemaless DeviceConfig), ordered by DeviceId for a stable merge.</summary>
|
||||||
/// <param name="root">The artifact root element.</param>
|
/// <param name="root">The artifact root element.</param>
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
private readonly Dictionary<string, ChildEntry> _children = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, ChildEntry> _children = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Per-driver <see cref="DependencyConsumerMuxAdapter"/> children — one for each spawned driver
|
||||||
|
/// that implements <see cref="IDependencyConsumer"/> (e.g. the Calculation pseudo-driver). Each registers
|
||||||
|
/// the driver's declared dependency refs on the per-node <see cref="_dependencyMux"/> and forwards value
|
||||||
|
/// changes into the driver. Spawned in <see cref="SpawnChild"/>, torn down with the driver in
|
||||||
|
/// <see cref="StopDependencyAdapter"/>, and re-registered on every apply (refs change when tags/scripts
|
||||||
|
/// change). Empty when no mux is wired (the dev/None path) or no dependency-consuming driver is hosted.</summary>
|
||||||
|
private readonly Dictionary<string, IActorRef> _depConsumerAdapters = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
// Monotonic counter feeding the child actor-name suffix (see ActorNameFor / SpawnChild). Single-
|
// Monotonic counter feeding the child actor-name suffix (see ActorNameFor / SpawnChild). Single-
|
||||||
// threaded actor, so a plain increment is safe; it only ever grows, guaranteeing a unique name per
|
// threaded actor, so a plain increment is safe; it only ever grows, guaranteeing a unique name per
|
||||||
// spawn so a restart's respawn never collides with the still-terminating old child.
|
// spawn so a restart's respawn never collides with the still-terminating old child.
|
||||||
@@ -1372,6 +1380,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
foreach (var id in plan.ToStop) StopChild(id);
|
foreach (var id in plan.ToStop) StopChild(id);
|
||||||
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
|
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
|
||||||
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
|
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
|
||||||
|
|
||||||
|
// v3 (WP7): re-register every dependency-consumer adapter's interest on each apply — a driver's
|
||||||
|
// declared dependency refs change when its tags/scripts change. Newly-spawned adapters already
|
||||||
|
// registered in PreStart; this refreshes the ones an ApplyDelta kept in place. A Tell (no blocking).
|
||||||
|
foreach (var adapter in _depConsumerAdapters.Values)
|
||||||
|
adapter.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags.DependencyConsumerMuxAdapter.ReRegister());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1736,10 +1750,33 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
|
|
||||||
_children[spec.DriverInstanceId] = new ChildEntry(child, spec, stub);
|
_children[spec.DriverInstanceId] = new ChildEntry(child, spec, stub);
|
||||||
|
|
||||||
|
// v3 (WP7): if the (real, non-stub) driver consumes other tags' live values, spawn a mux-adapter
|
||||||
|
// child that registers its dependency refs on the per-node dependency mux and forwards changes
|
||||||
|
// into the driver. Requires a wired mux (dev/None deployments have none — then calc inputs simply
|
||||||
|
// can't flow, matching the VirtualTag dev path).
|
||||||
|
if (!stub && driver is IDependencyConsumer consumer && _dependencyMux is not null)
|
||||||
|
{
|
||||||
|
var adapter = Context.ActorOf(
|
||||||
|
ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags.DependencyConsumerMuxAdapter.Props(consumer, _dependencyMux),
|
||||||
|
"depmux-" + actorName);
|
||||||
|
_depConsumerAdapters[spec.DriverInstanceId] = adapter;
|
||||||
|
_log.Info("DriverHost {Node}: wired dependency-consumer mux adapter for {Id} ({Count} ref(s))",
|
||||||
|
_localNode, spec.DriverInstanceId, consumer.DependencyRefs.Count);
|
||||||
|
}
|
||||||
|
|
||||||
_log.Info("DriverHost {Node}: spawned {Type} driver {Id} (stub={Stub})",
|
_log.Info("DriverHost {Node}: spawned {Type} driver {Id} (stub={Stub})",
|
||||||
_localNode, spec.DriverType, spec.DriverInstanceId, stub);
|
_localNode, spec.DriverType, spec.DriverInstanceId, stub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops + forgets the dependency-consumer mux adapter for a driver (if any). Called wherever a
|
||||||
|
/// driver child is stopped/respawned so a stale adapter never keeps a dead driver's refs registered.</summary>
|
||||||
|
private void StopDependencyAdapter(string driverInstanceId)
|
||||||
|
{
|
||||||
|
if (_depConsumerAdapters.Remove(driverInstanceId, out var adapter))
|
||||||
|
Context.Stop(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyChildDelta(DriverInstanceSpec spec)
|
private void ApplyChildDelta(DriverInstanceSpec spec)
|
||||||
{
|
{
|
||||||
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
||||||
@@ -1752,6 +1789,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
private void StopChild(string driverInstanceId)
|
private void StopChild(string driverInstanceId)
|
||||||
{
|
{
|
||||||
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
|
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
|
||||||
|
StopDependencyAdapter(driverInstanceId);
|
||||||
Context.Stop(entry.Actor);
|
Context.Stop(entry.Actor);
|
||||||
_children.Remove(driverInstanceId);
|
_children.Remove(driverInstanceId);
|
||||||
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
||||||
@@ -1805,6 +1843,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
_localNode, msg.DriverInstanceId, msg.ActorByUserName);
|
_localNode, msg.DriverInstanceId, msg.ActorByUserName);
|
||||||
|
|
||||||
// Stop the existing child actor — DriverInstanceActor.PostStop calls ShutdownAsync.
|
// Stop the existing child actor — DriverInstanceActor.PostStop calls ShutdownAsync.
|
||||||
|
StopDependencyAdapter(msg.DriverInstanceId);
|
||||||
Context.Stop(entry.Actor);
|
Context.Stop(entry.Actor);
|
||||||
_children.Remove(msg.DriverInstanceId);
|
_children.Remove(msg.DriverInstanceId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Event;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bridges a driver that implements <see cref="IDependencyConsumer"/> onto the per-node
|
||||||
|
/// <see cref="DependencyMuxActor"/>. Spawned as a child of <c>DriverHostActor</c> for each such
|
||||||
|
/// driver: on start (and on every apply via <see cref="ReRegister"/>) it registers the driver's
|
||||||
|
/// declared <see cref="IDependencyConsumer.DependencyRefs"/> with the mux, and forwards each
|
||||||
|
/// <see cref="VirtualTagActor.DependencyValueChanged"/> the mux fans out into
|
||||||
|
/// <see cref="IDependencyConsumer.OnDependencyValue"/>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This is the exact seam a <c>VirtualTagActor</c> uses to receive its inputs, generalised so a
|
||||||
|
/// host-blind driver (the <c>Calculation</c> pseudo-driver) can read other tags' live values with
|
||||||
|
/// no cross-driver plumbing. The mux fans out value + timestamp only (quality is stripped at the
|
||||||
|
/// mux boundary, same as for virtual tags), so the forwarded status code is <c>Good</c> (0).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DependencyConsumerMuxAdapter : ReceiveActor
|
||||||
|
{
|
||||||
|
/// <summary>Sent by the host after an apply so the adapter re-reads the driver's (possibly changed)
|
||||||
|
/// dependency refs and re-registers them with the mux.</summary>
|
||||||
|
public sealed record ReRegister;
|
||||||
|
|
||||||
|
private readonly IDependencyConsumer _consumer;
|
||||||
|
private readonly IActorRef _mux;
|
||||||
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||||
|
|
||||||
|
/// <summary>Creates props for a <see cref="DependencyConsumerMuxAdapter"/>.</summary>
|
||||||
|
/// <param name="consumer">The dependency-consuming driver to feed.</param>
|
||||||
|
/// <param name="mux">The per-node dependency mux to register interest with.</param>
|
||||||
|
/// <returns>Props for the adapter.</returns>
|
||||||
|
public static Props Props(IDependencyConsumer consumer, IActorRef mux) =>
|
||||||
|
Akka.Actor.Props.Create(() => new DependencyConsumerMuxAdapter(consumer, mux));
|
||||||
|
|
||||||
|
/// <summary>Initializes a new <see cref="DependencyConsumerMuxAdapter"/>.</summary>
|
||||||
|
/// <param name="consumer">The dependency-consuming driver to feed.</param>
|
||||||
|
/// <param name="mux">The per-node dependency mux to register interest with.</param>
|
||||||
|
public DependencyConsumerMuxAdapter(IDependencyConsumer consumer, IActorRef mux)
|
||||||
|
{
|
||||||
|
_consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||||
|
_mux = mux ?? throw new ArgumentNullException(nameof(mux));
|
||||||
|
|
||||||
|
Receive<VirtualTagActor.DependencyValueChanged>(OnDependencyValueChanged);
|
||||||
|
Receive<ReRegister>(_ => RegisterInterest());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void PreStart() => RegisterInterest();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void PostStop() => _mux.Tell(new DependencyMuxActor.UnregisterInterest(Self));
|
||||||
|
|
||||||
|
private void RegisterInterest()
|
||||||
|
{
|
||||||
|
var refs = _consumer.DependencyRefs.ToList();
|
||||||
|
// A RegisterInterest replaces the prior interest set for this subscriber (mux is idempotent on
|
||||||
|
// re-register), so re-registering after an apply cleanly adopts an added/removed ref.
|
||||||
|
_mux.Tell(new DependencyMuxActor.RegisterInterest(refs, Self));
|
||||||
|
_log.Debug("DependencyConsumerMuxAdapter: registered {Count} dependency ref(s)", refs.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDependencyValueChanged(VirtualTagActor.DependencyValueChanged msg)
|
||||||
|
{
|
||||||
|
// Quality is stripped at the mux boundary (value + timestamp only), so forward Good (0).
|
||||||
|
_consumer.OnDependencyValue(msg.TagId, msg.Value, statusCode: 0u, msg.TimestampUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WP7 Calculation deploy gates in <see cref="DraftValidator"/>: <c>scriptId</c> existence and the
|
||||||
|
/// calc→calc dependency-cycle gate (self-cycle, 2-cycle, and the cross-driver-terminal negative case).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class DraftValidatorCalculationTests
|
||||||
|
{
|
||||||
|
// A calc DriverInstance "calc" + one Device "engine" → calc tags get RawPath "calc/engine/<name>".
|
||||||
|
private static DriverInstance CalcDriver() => new()
|
||||||
|
{
|
||||||
|
DriverInstanceId = "di-calc", ClusterId = "c", Name = "calc",
|
||||||
|
DriverType = "Calculation", DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DriverInstance ModbusDriver() => new()
|
||||||
|
{
|
||||||
|
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
|
||||||
|
DriverType = "Modbus", DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Device Device(string id, string driverInstanceId, string name) => new()
|
||||||
|
{
|
||||||
|
DeviceId = id, DriverInstanceId = driverInstanceId, Name = name, DeviceConfig = "{}",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Tag CalcTagRow(string tagId, string deviceId, string name, string tagConfig) => new()
|
||||||
|
{
|
||||||
|
TagId = tagId, DeviceId = deviceId, Name = name, DataType = "Double",
|
||||||
|
AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Script ScriptRow(string id, string source) => new()
|
||||||
|
{
|
||||||
|
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h-" + id,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Cfg(string scriptId) => $"{{\"scriptId\":\"{scriptId}\",\"changeTriggered\":true}}";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_scriptId_is_a_deploy_error()
|
||||||
|
{
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1, ClusterId = "c",
|
||||||
|
DriverInstances = [CalcDriver()],
|
||||||
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||||
|
Tags = [CalcTagRow("t-1", "dev-1", "a", "{\"changeTriggered\":true}")],
|
||||||
|
};
|
||||||
|
|
||||||
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationScriptMissing" && e.Context == "t-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_scriptId_is_a_deploy_error()
|
||||||
|
{
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1, ClusterId = "c",
|
||||||
|
DriverInstances = [CalcDriver()],
|
||||||
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||||
|
Tags = [CalcTagRow("t-1", "dev-1", "a", Cfg("s-nope"))],
|
||||||
|
Scripts = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationScriptNotFound" && e.Context == "t-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Existing_scriptId_with_no_cycle_is_accepted()
|
||||||
|
{
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1, ClusterId = "c",
|
||||||
|
DriverInstances = [CalcDriver()],
|
||||||
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||||
|
Tags = [CalcTagRow("t-1", "dev-1", "a", Cfg("s1"))],
|
||||||
|
Scripts = [ScriptRow("s1", "return 42.0;")],
|
||||||
|
};
|
||||||
|
|
||||||
|
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code.StartsWith("Calculation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Self_cycle_is_rejected()
|
||||||
|
{
|
||||||
|
// calc/engine/a reads itself.
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1, ClusterId = "c",
|
||||||
|
DriverInstances = [CalcDriver()],
|
||||||
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||||
|
Tags = [CalcTagRow("t-a", "dev-1", "a", Cfg("s-a"))],
|
||||||
|
Scripts = [ScriptRow("s-a", "return (double)ctx.GetTag(\"calc/engine/a\").Value;")],
|
||||||
|
};
|
||||||
|
|
||||||
|
DraftValidator.Validate(draft).ShouldContain(e =>
|
||||||
|
e.Code == "CalculationDependencyCycle" && e.Message.Contains("calc/engine/a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Two_node_cycle_names_both_members()
|
||||||
|
{
|
||||||
|
// a → b and b → a.
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1, ClusterId = "c",
|
||||||
|
DriverInstances = [CalcDriver()],
|
||||||
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
CalcTagRow("t-a", "dev-1", "a", Cfg("s-a")),
|
||||||
|
CalcTagRow("t-b", "dev-1", "b", Cfg("s-b")),
|
||||||
|
],
|
||||||
|
Scripts =
|
||||||
|
[
|
||||||
|
ScriptRow("s-a", "return (double)ctx.GetTag(\"calc/engine/b\").Value;"),
|
||||||
|
ScriptRow("s-b", "return (double)ctx.GetTag(\"calc/engine/a\").Value;"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationDependencyCycle");
|
||||||
|
var msg = DraftValidator.Validate(draft).First(e => e.Code == "CalculationDependencyCycle").Message;
|
||||||
|
msg.ShouldContain("calc/engine/a");
|
||||||
|
msg.ShouldContain("calc/engine/b");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Cross_driver_ref_is_terminal_not_an_edge()
|
||||||
|
{
|
||||||
|
// A calc tag reads a Modbus tag's RawPath ("mb/plc/reg"); that is a terminal node, never an edge,
|
||||||
|
// so it can never form a cycle — even if the Modbus tag existed.
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 1, ClusterId = "c",
|
||||||
|
DriverInstances = [CalcDriver(), ModbusDriver()],
|
||||||
|
Devices = [Device("dev-1", "di-calc", "engine"), Device("dev-2", "di-mb", "plc")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
CalcTagRow("t-a", "dev-1", "a", Cfg("s-a")),
|
||||||
|
new Tag { TagId = "t-reg", DeviceId = "dev-2", Name = "reg", DataType = "Int32",
|
||||||
|
AccessLevel = TagAccessLevel.Read, TagConfig = "{}" },
|
||||||
|
],
|
||||||
|
Scripts = [ScriptRow("s-a", "return (double)ctx.GetTag(\"mb/plc/reg\").Value;")],
|
||||||
|
};
|
||||||
|
|
||||||
|
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "CalculationDependencyCycle");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -33,6 +33,7 @@
|
|||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WP7 unit tests for <see cref="CalculationDriver"/>: dependency-ref extraction from the authored
|
||||||
|
/// tags, the change-gate (no publish until every declared dep has arrived), value dedup, the
|
||||||
|
/// Good→Bad transition + <c>ScriptLogEntry</c> emission + Good recovery, and timer-trigger recompute.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalculationDriverTests
|
||||||
|
{
|
||||||
|
private const uint StatusGood = 0u;
|
||||||
|
private const uint StatusBadInternalError = 0x80020000u;
|
||||||
|
private const uint StatusBadWaitingForInitialData = 0x80320000u;
|
||||||
|
|
||||||
|
private static RawTagEntry CalcTag(string rawPath, string source, bool changeTriggered = true, int? timerMs = null)
|
||||||
|
{
|
||||||
|
var timer = timerMs is { } ms ? $",\"timerIntervalMs\":{ms}" : "";
|
||||||
|
var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":{(changeTriggered ? "true" : "false")}{timer}," +
|
||||||
|
$"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
|
||||||
|
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (CalculationDriver Driver, ConcurrentQueue<DataChangeEventArgs> Published, List<ScriptLogEntry> Logs)
|
||||||
|
Build(params RawTagEntry[] tags)
|
||||||
|
{
|
||||||
|
var logs = new List<ScriptLogEntry>();
|
||||||
|
var sink = new ScriptLogTopicSink(new CapturingPublisher(logs), LogEventLevel.Information);
|
||||||
|
var scriptRoot = new ScriptRootLogger(new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger());
|
||||||
|
var driver = new CalculationDriver(new CalculationDriverOptions { RawTags = tags }, "calc-drv", scriptRoot);
|
||||||
|
var published = new ConcurrentQueue<DataChangeEventArgs>();
|
||||||
|
driver.OnDataChange += (_, e) => published.Enqueue(e);
|
||||||
|
return (driver, published, logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dependency-ref extraction ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DependencyRefs_are_the_union_of_authored_tag_scripts()
|
||||||
|
{
|
||||||
|
var (driver, _, _) = Build(
|
||||||
|
CalcTag("calc/e/x", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"),
|
||||||
|
CalcTag("calc/e/y", "return (double)ctx.GetTag(\"src/b\").Value + (double)ctx.GetTag(\"src/c\").Value;"));
|
||||||
|
|
||||||
|
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b", "src/c" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── change-gate ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task No_publish_until_every_declared_dep_has_arrived()
|
||||||
|
{
|
||||||
|
var (driver, published, _) = Build(
|
||||||
|
CalcTag("calc/e/sum", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"));
|
||||||
|
await driver.InitializeAsync("{}", default);
|
||||||
|
|
||||||
|
// Only one of two deps arrived → the change-gate holds.
|
||||||
|
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
|
||||||
|
published.ShouldBeEmpty();
|
||||||
|
|
||||||
|
// Second dep arrives → now it computes + publishes 30.
|
||||||
|
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
|
||||||
|
published.Count.ShouldBe(1);
|
||||||
|
published.TryDequeue(out var e).ShouldBeTrue();
|
||||||
|
e!.FullReference.ShouldBe("calc/e/sum");
|
||||||
|
e.Snapshot.Value.ShouldBe(30.0);
|
||||||
|
e.Snapshot.StatusCode.ShouldBe(StatusGood);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dedup + recompute ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Equal_result_is_deduped_and_a_changed_result_republishes()
|
||||||
|
{
|
||||||
|
var (driver, published, _) = Build(
|
||||||
|
CalcTag("calc/e/dbl", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
|
||||||
|
await driver.InitializeAsync("{}", default);
|
||||||
|
|
||||||
|
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (publish)
|
||||||
|
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (dedup, no publish)
|
||||||
|
driver.OnDependencyValue("src/a", 7.0, StatusGood, DateTime.UtcNow); // → 14 (publish)
|
||||||
|
|
||||||
|
published.Select(p => p.Snapshot.Value).ShouldBe(new object?[] { 10.0, 14.0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bad transition + script-log + recovery ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Failure_publishes_Bad_once_per_transition_emits_script_log_and_recovers()
|
||||||
|
{
|
||||||
|
var (driver, published, logs) = Build(
|
||||||
|
CalcTag("calc/e/div", "return 100 / (int)ctx.GetTag(\"src/a\").Value;"));
|
||||||
|
await driver.InitializeAsync("{}", default);
|
||||||
|
|
||||||
|
// a = 0 → DivideByZero → Failure → Bad (carrying last-known = null) + one script-log.
|
||||||
|
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
|
||||||
|
published.Count.ShouldBe(1);
|
||||||
|
published.TryDequeue(out var bad).ShouldBeTrue();
|
||||||
|
bad!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
|
||||||
|
logs.Count.ShouldBe(1);
|
||||||
|
logs[0].Level.ShouldBe("Warning");
|
||||||
|
logs[0].ScriptId.ShouldBe("s-calc/e/div");
|
||||||
|
logs[0].VirtualTagId.ShouldBe("calc/e/div");
|
||||||
|
|
||||||
|
// a = 0 again → still failing, but NOT a fresh transition → no second Bad publish (script-log still emits).
|
||||||
|
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
|
||||||
|
published.ShouldBeEmpty();
|
||||||
|
logs.Count.ShouldBe(2);
|
||||||
|
|
||||||
|
// a = 5 → 20 → Good recovery is force-published.
|
||||||
|
driver.OnDependencyValue("src/a", 5, StatusGood, DateTime.UtcNow);
|
||||||
|
published.Count.ShouldBe(1);
|
||||||
|
published.TryDequeue(out var good).ShouldBeTrue();
|
||||||
|
good!.Snapshot.Value.ShouldBe(20);
|
||||||
|
good.Snapshot.StatusCode.ShouldBe(StatusGood);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── timer trigger ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Timer_only_tag_computes_on_its_interval()
|
||||||
|
{
|
||||||
|
// No dependencies (constant script) so only the timer can drive it.
|
||||||
|
var (driver, published, _) = Build(
|
||||||
|
CalcTag("calc/e/const", "return 42.0;", changeTriggered: false, timerMs: 50));
|
||||||
|
await driver.InitializeAsync("{}", default);
|
||||||
|
|
||||||
|
var fired = await WaitUntil(() => !published.IsEmpty, TimeSpan.FromSeconds(3));
|
||||||
|
fired.ShouldBeTrue("the timer trigger should have computed the tag within 3s");
|
||||||
|
published.TryDequeue(out var e).ShouldBeTrue();
|
||||||
|
e!.FullReference.ShouldBe("calc/e/const");
|
||||||
|
e.Snapshot.Value.ShouldBe(42.0);
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IReadable ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_returns_BadWaitingForInitialData_before_first_eval_then_the_computed_value()
|
||||||
|
{
|
||||||
|
var (driver, _, _) = Build(CalcTag("calc/e/x", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
|
||||||
|
await driver.InitializeAsync("{}", default);
|
||||||
|
|
||||||
|
var before = await driver.ReadAsync(new[] { "calc/e/x" }, default);
|
||||||
|
before[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData);
|
||||||
|
|
||||||
|
driver.OnDependencyValue("src/a", 3.0, StatusGood, DateTime.UtcNow);
|
||||||
|
var after = await driver.ReadAsync(new[] { "calc/e/x" }, default);
|
||||||
|
after[0].StatusCode.ShouldBe(StatusGood);
|
||||||
|
after[0].Value.ShouldBe(6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Health_is_always_Connected()
|
||||||
|
{
|
||||||
|
var (driver, _, _) = Build(CalcTag("calc/e/x", "return 1.0;"));
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> WaitUntil(Func<bool> predicate, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (predicate()) return true;
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
return predicate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingPublisher(List<ScriptLogEntry> sink) : IScriptLogPublisher
|
||||||
|
{
|
||||||
|
private readonly object _lock = new();
|
||||||
|
public void Publish(ScriptLogEntry entry)
|
||||||
|
{
|
||||||
|
lock (_lock) sink.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||||
|
|
||||||
|
/// <summary>Parsing contract for <see cref="CalculationTagDefinition.TryFromTagConfig"/> — the calc tag's
|
||||||
|
/// <c>TagConfig</c> blob (<c>scriptId</c> + resolved <c>scriptSource</c> + trigger fields) → typed def with
|
||||||
|
/// its static dependency refs.</summary>
|
||||||
|
public sealed class CalculationTagDefinitionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Missing_scriptId_is_not_a_calc_tag()
|
||||||
|
{
|
||||||
|
CalculationTagDefinition.TryFromTagConfig("{\"changeTriggered\":true}", "calc/e/x", out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Malformed_json_is_a_miss()
|
||||||
|
{
|
||||||
|
CalculationTagDefinition.TryFromTagConfig("not json", "calc/e/x", out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parses_scriptId_source_triggers_and_extracts_dependency_refs()
|
||||||
|
{
|
||||||
|
var json = "{\"scriptId\":\"s1\",\"changeTriggered\":true,\"timerIntervalMs\":5000," +
|
||||||
|
"\"scriptSource\":\"return (double)ctx.GetTag(\\\"src/a\\\").Value + (double)ctx.GetTag(\\\"src/b\\\").Value;\"}";
|
||||||
|
CalculationTagDefinition.TryFromTagConfig(json, "calc/e/x", out var def).ShouldBeTrue();
|
||||||
|
|
||||||
|
def.RawPath.ShouldBe("calc/e/x");
|
||||||
|
def.ScriptId.ShouldBe("s1");
|
||||||
|
def.ChangeTriggered.ShouldBeTrue();
|
||||||
|
def.TimerInterval.ShouldBe(TimeSpan.FromMilliseconds(5000));
|
||||||
|
def.DependencyRefs.ShouldBe(new[] { "src/a", "src/b" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChangeTriggered_defaults_true_and_timer_is_optional()
|
||||||
|
{
|
||||||
|
CalculationTagDefinition.TryFromTagConfig(
|
||||||
|
"{\"scriptId\":\"s1\",\"scriptSource\":\"return 1.0;\"}", "calc/e/x", out var def).ShouldBeTrue();
|
||||||
|
def.ChangeTriggered.ShouldBeTrue();
|
||||||
|
def.TimerInterval.ShouldBeNull();
|
||||||
|
def.DependencyRefs.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Present_scriptId_with_empty_source_still_maps()
|
||||||
|
{
|
||||||
|
CalculationTagDefinition.TryFromTagConfig(
|
||||||
|
"{\"scriptId\":\"s1\",\"changeTriggered\":false}", "calc/e/x", out var def).ShouldBeTrue();
|
||||||
|
def.ScriptSource.ShouldBe("");
|
||||||
|
def.ChangeTriggered.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WP7 register-AND-consume proof: dependency values actually FLOW end-to-end through the REAL
|
||||||
|
/// <see cref="DependencyMuxActor"/> → <see cref="DependencyConsumerMuxAdapter"/> →
|
||||||
|
/// <see cref="CalculationDriver"/> path (not merely that <see cref="IDependencyConsumer"/> exists).
|
||||||
|
/// A source value published to the mux is fed into the calc driver, which computes and publishes a
|
||||||
|
/// value; and a calc tag reading ANOTHER calc tag's output (calc-of-calc) recomputes when the first
|
||||||
|
/// tag's output re-enters the mux — the exact re-entry the host's <c>ForwardToMux</c> performs, mirrored
|
||||||
|
/// here by looping the driver's <see cref="ISubscribable.OnDataChange"/> back into the mux.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
|
||||||
|
{
|
||||||
|
private static readonly DateTime Ts = new(2026, 7, 16, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static RawTagEntry CalcTag(string rawPath, string source)
|
||||||
|
{
|
||||||
|
var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":true," +
|
||||||
|
$"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
|
||||||
|
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_value_flows_through_mux_and_adapter_into_the_calc_driver_and_propagates_calc_of_calc()
|
||||||
|
{
|
||||||
|
// A: out = src/a * 2. B: b = A.out + 1 (calc-of-calc; B depends on A's RawPath).
|
||||||
|
var driver = new CalculationDriver(
|
||||||
|
new CalculationDriverOptions
|
||||||
|
{
|
||||||
|
RawTags =
|
||||||
|
[
|
||||||
|
CalcTag("calc/engine/out", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"),
|
||||||
|
CalcTag("calc/engine/b", "return (double)ctx.GetTag(\"calc/engine/out\").Value + 1.0;"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"calc-drv");
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// The driver declares interest in both the external source AND the first calc tag's output.
|
||||||
|
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "calc/engine/out", "src/a" });
|
||||||
|
|
||||||
|
var published = new ConcurrentQueue<DataChangeEventArgs>();
|
||||||
|
var mux = Sys.ActorOf(DependencyMuxActor.Props(), "dep-mux");
|
||||||
|
|
||||||
|
// Loop the driver's outputs back into the mux exactly as DriverHostActor.ForwardToMux does — this
|
||||||
|
// is what makes calc-of-calc chains work.
|
||||||
|
driver.OnDataChange += (_, e) =>
|
||||||
|
{
|
||||||
|
published.Enqueue(e);
|
||||||
|
var quality = e.Snapshot.StatusCode == 0u ? OpcUaQuality.Good : OpcUaQuality.Bad;
|
||||||
|
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||||
|
"calc-drv", e.FullReference, e.Snapshot.Value, quality, Ts));
|
||||||
|
};
|
||||||
|
|
||||||
|
// The REAL adapter registers the driver's dependency refs on the mux + forwards changes into it.
|
||||||
|
Sys.ActorOf(DependencyConsumerMuxAdapter.Props(driver, mux), "dep-adapter");
|
||||||
|
|
||||||
|
// Publish the external source into the mux. AwaitAssert re-Tells (idempotent after the first
|
||||||
|
// compute dedups) until the computed value has flowed all the way through.
|
||||||
|
AwaitAssert(() =>
|
||||||
|
{
|
||||||
|
mux.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||||
|
"src-drv", "src/a", 21.0, OpcUaQuality.Good, Ts));
|
||||||
|
|
||||||
|
// Leg 1 — dep flow: src/a=21 → A computes 42 and publishes on calc/engine/out.
|
||||||
|
published.ShouldContain(e => e.FullReference == "calc/engine/out" && Equals(e.Snapshot.Value, 42.0));
|
||||||
|
// Leg 2 — calc-of-calc: A's 42 re-entered the mux → B computes 43 on calc/engine/b.
|
||||||
|
published.ShouldContain(e => e.FullReference == "calc/engine/b" && Equals(e.Snapshot.Value, 43.0));
|
||||||
|
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@
|
|||||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||||
|
<!-- WP7: the real Calculation driver, so the dependency-flow integration test drives values through
|
||||||
|
the real DependencyMuxActor → DependencyConsumerMuxAdapter → CalculationDriver path. -->
|
||||||
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user