feat(driver-calc): B2-WP7 Calculation driver — IDependencyConsumer + triggers + deploy cycle gate
Adds the Calculation pseudo-driver (tags computed by C# scripts over other tags' live values) plus the host seam that feeds it: - IDependencyConsumer capability interface (Core.Abstractions) + DriverTypeNames.Calculation. - CalculationDriver : IDriver, ISubscribable, IReadable, IDependencyConsumer — change-gate + dedupe (VirtualTagActor parity), timer-group scheduler, Bad-transition + script-log emission, Good recovery; reuses the T0-4 CalculationEvaluator. - DriverHostActor spawns a DependencyConsumerMuxAdapter per dependency-consuming driver, registering its refs on the per-node DependencyMuxActor and forwarding DependencyValueChanged → OnDependencyValue; re-registers on every apply, torn down with the driver. - Factory + probe registered in DriverFactoryBootstrap (keeps the T0-3 guard green); guard test csproj references the driver so the bin-scan discovers the factory. - DeploymentArtifact injects the resolved scriptSource (scriptId → SourceCode) into a calc tag's delivered TagConfig so the host-blind driver can compile it. - DraftValidator deploy gates: CalculationScriptMissing / CalculationScriptNotFound (scriptId existence) + CalculationDependencyCycle (DependencyGraph.DetectCycles over calc→calc edges). - Tests: driver units (dep-ref extraction, change-gate, dedupe, Bad+script-log+recovery, timer, read), tag-config parsing, DraftValidator gates (self/2-cycle/cross-driver-terminal), and a Runtime integration test proving values FLOW mux → adapter → driver → calc-of-calc end-to-end. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -55,6 +55,10 @@ public sealed class DraftSnapshot
|
||||
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
|
||||
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
|
||||
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>
|
||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ public static class DraftSnapshotFactory
|
||||
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
|
||||
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
||||
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
|
||||
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
|
||||
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
||||
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
||||
ActiveReservations = await db.ExternalIdReservations
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
@@ -38,9 +39,107 @@ public static class DraftValidator
|
||||
ValidateRawNameCharset(draft, errors);
|
||||
ValidateHistorizedTagnameLength(draft, errors);
|
||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||
ValidateCalculationTags(draft, 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
|
||||
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
|
||||
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<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>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -30,6 +35,9 @@
|
||||
<!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons.
|
||||
Cycle-safe — Commons has zero in-repo ProjectReferences. -->
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -50,6 +50,9 @@ public static class DriverTypeNames
|
||||
/// <summary>AVEVA System Platform (Wonderware) Galaxy driver, via the mxaccessgw gateway.</summary>
|
||||
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>
|
||||
/// Every driver-type string declared above, for callers that need to enumerate
|
||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||
@@ -64,5 +67,6 @@ public static class DriverTypeNames
|
||||
FOCAS,
|
||||
OpcUaClient,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user