Compare commits

...

2 Commits

Author SHA1 Message Date
Joseph Doherty
f1f53e1789 Phase 7 Stream G — Address-space integration (NodeSourceKind + walker emits VirtualTag/ScriptedAlarm)
Per ADR-002, adds the Driver/Virtual/ScriptedAlarm discriminator to DriverAttributeInfo
so the DriverNodeManager's dispatch layer can route Read/Write/Subscribe to the right
runtime subsystem — drivers (unchanged), VirtualTagEngine (Phase 7 Stream B), or
ScriptedAlarmEngine (Phase 7 Stream C).

## Changes
- NodeSourceKind enum added to Core.Abstractions (Driver=0/Virtual=1/ScriptedAlarm=2).
- DriverAttributeInfo gains Source / VirtualTagId / ScriptedAlarmId parameters — all
  default so existing call sites (every driver) compile unchanged.
- EquipmentNamespaceContent gains VirtualTags + ScriptedAlarms optional collections.
- EquipmentNodeWalker emits:
  - Virtual-tag variables — Source=Virtual, VirtualTagId set, Historize flag honored
  - Scripted-alarm variables — Source=ScriptedAlarm, ScriptedAlarmId set, IsAlarm=true
    (triggers node-manager AlarmConditionState materialization)
  - Skips disabled virtual tags + scripted alarms

## Tests — 13/13 in EquipmentNodeWalkerTests (5 new)
- Virtual-tag variables carry Source=Virtual + VirtualTagId + Historize flag
- Scripted-alarm variables carry Source=ScriptedAlarm + IsAlarm=true + Boolean type
- Disabled rows skipped
- Null VirtualTags/ScriptedAlarms collections safe (back-compat for non-Phase-7 callers)
- Driver tags default Source=Driver (ensures no discriminator regression)

## Next
Stream G follow-up: DriverNodeManager dispatch (Read/Write/Subscribe routing by
NodeSourceKind), SealedBootstrap wiring of VirtualTagEngine + ScriptedAlarmEngine,
end-to-end integration test.
2026-04-20 19:41:01 -04:00
e97db2d108 Merge pull request 'Phase 7 Stream E — Config DB schema for scripts, virtual tags, scripted alarms, and alarm state' (#183) from phase-7-stream-e-config-db into v2 2026-04-20 19:24:53 -04:00
3 changed files with 214 additions and 5 deletions

View File

@@ -33,6 +33,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// (holding registers with level-set values, set-point writes to analog tags) — the
/// capability invoker respects this flag when deciding whether to apply Polly retry.
/// </param>
/// <param name="Source">
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
/// </param>
/// <param name="VirtualTagId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
/// logical id the VirtualTagEngine addresses by. Null otherwise.
/// </param>
/// <param name="ScriptedAlarmId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
/// </param>
public sealed record DriverAttributeInfo(
string FullName,
DriverDataType DriverDataType,
@@ -41,4 +53,21 @@ public sealed record DriverAttributeInfo(
SecurityClassification SecurityClass,
bool IsHistorized,
bool IsAlarm = false,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
NodeSourceKind Source = NodeSourceKind.Driver,
string? VirtualTagId = null,
string? ScriptedAlarmId = null);
/// <summary>
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
/// materialized by the ScriptedAlarmEngine.
/// </summary>
public enum NodeSourceKind
{
Driver = 0,
Virtual = 1,
ScriptedAlarm = 2,
}

View File

@@ -87,6 +87,16 @@ public static class EquipmentNodeWalker
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var virtualTagsByEquipment = (content.VirtualTags ?? [])
.Where(v => v.Enabled)
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
.Where(a => a.Enabled)
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
{
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
@@ -103,9 +113,17 @@ public static class EquipmentNodeWalker
AddIdentifierProperties(equipmentBuilder, equipment);
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
foreach (var vtag in vTags)
AddVirtualTagVariable(equipmentBuilder, vtag);
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
foreach (var alarm in alarms)
AddScriptedAlarmVariable(equipmentBuilder, alarm);
}
}
}
@@ -157,6 +175,55 @@ public static class EquipmentNodeWalker
/// </summary>
private static DriverDataType ParseDriverDataType(string raw) =>
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
/// <summary>
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
/// driver.
/// </summary>
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
{
var attr = new DriverAttributeInfo(
FullName: vtag.VirtualTagId,
DriverDataType: ParseDriverDataType(vtag.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: vtag.Historize,
IsAlarm: false,
WriteIdempotent: false,
Source: NodeSourceKind.Virtual,
VirtualTagId: vtag.VirtualTagId,
ScriptedAlarmId: null);
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
}
/// <summary>
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
/// materialization path.
/// </summary>
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
{
var attr = new DriverAttributeInfo(
FullName: alarm.ScriptedAlarmId,
DriverDataType: DriverDataType.Boolean,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: false,
IsAlarm: true,
WriteIdempotent: false,
Source: NodeSourceKind.ScriptedAlarm,
VirtualTagId: null,
ScriptedAlarmId: alarm.ScriptedAlarmId);
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
}
}
/// <summary>
@@ -170,4 +237,6 @@ public sealed record EquipmentNamespaceContent(
IReadOnlyList<UnsArea> Areas,
IReadOnlyList<UnsLine> Lines,
IReadOnlyList<Equipment> Equipment,
IReadOnlyList<Tag> Tags);
IReadOnlyList<Tag> Tags,
IReadOnlyList<VirtualTag>? VirtualTags = null,
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);

View File

@@ -147,6 +147,117 @@ public sealed class EquipmentNodeWalkerTests
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
}
[Fact]
public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var vtag = new VirtualTag
{
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate",
DataType = "Float32", ScriptId = "scr-1", Historize = true,
};
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
[eq], [], VirtualTags: [vtag]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var equipmentNode = rec.Children[0].Children[0].Children[0];
var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate");
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual);
v.AttributeInfo.VirtualTagId.ShouldBe("vt-1");
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
v.AttributeInfo.IsHistorized.ShouldBeTrue();
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
}
[Fact]
public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var alarm = new ScriptedAlarm
{
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp",
AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded",
PredicateScriptId = "scr-9", Severity = 800,
};
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
[eq], [], ScriptedAlarms: [alarm]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp");
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm);
v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1");
v.AttributeInfo.VirtualTagId.ShouldBeNull();
v.AttributeInfo.IsAlarm.ShouldBeTrue();
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
}
[Fact]
public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var vtag = new VirtualTag
{
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled",
DataType = "Float32", ScriptId = "scr-1", Enabled = false,
};
var alarm = new ScriptedAlarm
{
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm",
AlarmType = "LimitAlarm", MessageTemplate = "x",
PredicateScriptId = "scr-9", Enabled = false,
};
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
[eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
}
[Fact]
public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
{
// Backwards-compat — callers that don't populate the new collections still work.
var eq = Eq("eq-1", "line-1", "oven-3");
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content); // must not throw
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
}
[Fact]
public void Driver_tag_default_NodeSourceKind_is_Driver()
{
var eq = Eq("eq-1", "line-1", "oven-3");
var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1");
var content = new EquipmentNamespaceContent(
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
[eq], [tag]);
var rec = new RecordingBuilder("root");
EquipmentNodeWalker.Walk(rec, content);
var v = rec.Children[0].Children[0].Children[0].Variables.Single();
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver);
v.AttributeInfo.VirtualTagId.ShouldBeNull();
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
}
// ----- builders for test seed rows -----
private static UnsArea Area(string id, string name) => new()