feat(alarms): EquipmentTagPlan.Alarm parsed byte-parity from TagConfig (Phase B WS-2)
This commit is contained in:
@@ -75,7 +75,9 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
|
|||||||
/// mirrors the authored <c>Tag.AccessLevel == ReadWrite</c> so the materialised node is created
|
/// mirrors the authored <c>Tag.AccessLevel == ReadWrite</c> so the materialised node is created
|
||||||
/// <c>CurrentReadWrite</c> (the prerequisite for the inbound-write pipeline); a <c>Read</c> tag
|
/// <c>CurrentReadWrite</c> (the prerequisite for the inbound-write pipeline); a <c>Read</c> tag
|
||||||
/// stays read-only. This flag is derived identically on the artifact-decode side
|
/// stays read-only. This flag is derived identically on the artifact-decode side
|
||||||
/// (<c>DeploymentArtifact.BuildEquipmentTagPlans</c>) for byte-parity.
|
/// (<c>DeploymentArtifact.BuildEquipmentTagPlans</c>) for byte-parity. <see cref="Alarm"/> carries
|
||||||
|
/// the optional native-alarm intent parsed from <c>Tag.TagConfig</c>'s <c>alarm</c> object (null ⇒
|
||||||
|
/// a plain value variable); it too is parsed identically on the artifact-decode side for byte-parity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record EquipmentTagPlan(
|
public sealed record EquipmentTagPlan(
|
||||||
string TagId,
|
string TagId,
|
||||||
@@ -85,7 +87,13 @@ public sealed record EquipmentTagPlan(
|
|||||||
string Name,
|
string Name,
|
||||||
string DataType,
|
string DataType,
|
||||||
string FullName,
|
string FullName,
|
||||||
bool Writable);
|
bool Writable,
|
||||||
|
EquipmentTagAlarmInfo? Alarm);
|
||||||
|
|
||||||
|
/// <summary>Native-alarm intent parsed from an equipment tag's <c>TagConfig.alarm</c> object. Null ⇒
|
||||||
|
/// the tag is a plain value variable. <see cref="AlarmType"/> is an OPC UA Part 9 subtype string
|
||||||
|
/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); <see cref="Severity"/> is the 1..1000 scale.</summary>
|
||||||
|
public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
|
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
|
||||||
@@ -328,7 +336,8 @@ public static class Phase7Composer
|
|||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
DataType: t.DataType,
|
DataType: t.DataType,
|
||||||
FullName: ExtractTagFullName(t.TagConfig),
|
FullName: ExtractTagFullName(t.TagConfig),
|
||||||
Writable: t.AccessLevel == TagAccessLevel.ReadWrite))
|
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
||||||
|
Alarm: ExtractTagAlarm(t.TagConfig)))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
|
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
|
||||||
@@ -445,4 +454,24 @@ public static class Phase7Composer
|
|||||||
catch (JsonException) { /* fall through to raw blob */ }
|
catch (JsonException) { /* fall through to raw blob */ }
|
||||||
return tagConfig;
|
return tagConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
||||||
|
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
||||||
|
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
||||||
|
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(tagConfig);
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||||
|
if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||||
|
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
|
||||||
|
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
||||||
|
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
||||||
|
? sEl.GetInt32() : 500;
|
||||||
|
return new EquipmentTagAlarmInfo(type, sev);
|
||||||
|
}
|
||||||
|
catch (JsonException) { return null; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,7 +445,8 @@ public static class DeploymentArtifact
|
|||||||
Name: name!,
|
Name: name!,
|
||||||
DataType: dataType ?? "BaseDataType",
|
DataType: dataType ?? "BaseDataType",
|
||||||
FullName: ExtractTagFullName(tagConfig),
|
FullName: ExtractTagFullName(tagConfig),
|
||||||
Writable: writable));
|
Writable: writable,
|
||||||
|
Alarm: ExtractTagAlarm(tagConfig)));
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Sort((a, b) =>
|
result.Sort((a, b) =>
|
||||||
@@ -651,6 +652,26 @@ public static class DeploymentArtifact
|
|||||||
return tagConfig;
|
return tagConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
|
||||||
|
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
|
||||||
|
/// live-edit side (<c>Phase7Composer.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
|
||||||
|
private static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(tagConfig);
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||||
|
if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
|
||||||
|
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
|
||||||
|
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
||||||
|
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
||||||
|
? sEl.GetInt32() : 500;
|
||||||
|
return new EquipmentTagAlarmInfo(type, sev);
|
||||||
|
}
|
||||||
|
catch (JsonException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
public class ExtractTagAlarmTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)]
|
||||||
|
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)]
|
||||||
|
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
|
||||||
|
[InlineData("not json", false, null, 0)]
|
||||||
|
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)]
|
||||||
|
public void ExtractTagAlarm_parses_or_returns_null(string cfg, bool present, string? type, int sev)
|
||||||
|
{
|
||||||
|
var info = Phase7Composer.ExtractTagAlarm(cfg);
|
||||||
|
if (!present) { info.ShouldBeNull(); return; }
|
||||||
|
info!.AlarmType.ShouldBe(type);
|
||||||
|
info.Severity.ShouldBe(sev);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,7 +143,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true),
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false),
|
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -185,8 +185,8 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||||
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,8 +243,8 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||||
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false),
|
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
||||||
},
|
},
|
||||||
EquipmentVirtualTags = new[]
|
EquipmentVirtualTags = new[]
|
||||||
{
|
{
|
||||||
@@ -338,7 +338,7 @@ public sealed class Phase7ApplierTests
|
|||||||
{
|
{
|
||||||
AddedEquipmentTags = new[]
|
AddedEquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed class Phase7PlannerTests
|
|||||||
{
|
{
|
||||||
EquipmentTags = new[]
|
EquipmentTags = new[]
|
||||||
{
|
{
|
||||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false),
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -241,6 +241,8 @@ public sealed class DeploymentArtifactAliasParityTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The Galaxy equipment tag — FullName is the Galaxy ref "tag_name.AttributeName".
|
// The Galaxy equipment tag — FullName is the Galaxy ref "tag_name.AttributeName".
|
||||||
|
// It also carries a native-alarm intent in TagConfig.alarm, so this draft proves the
|
||||||
|
// optional Alarm field is parsed byte-identically on both producers (Phase B WS-2).
|
||||||
var galaxyTag = new Tag
|
var galaxyTag = new Tag
|
||||||
{
|
{
|
||||||
TagId = "tag-galaxy",
|
TagId = "tag-galaxy",
|
||||||
@@ -250,7 +252,7 @@ public sealed class DeploymentArtifactAliasParityTests
|
|||||||
Name = "DownloadPath",
|
Name = "DownloadPath",
|
||||||
DataType = "String",
|
DataType = "String",
|
||||||
AccessLevel = TagAccessLevel.Read,
|
AccessLevel = TagAccessLevel.Read,
|
||||||
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
|
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
|
||||||
};
|
};
|
||||||
// A non-Galaxy (Modbus) equipment tag — proves the parity holds across drivers, not just Galaxy.
|
// A non-Galaxy (Modbus) equipment tag — proves the parity holds across drivers, not just Galaxy.
|
||||||
var modbusTag = new Tag
|
var modbusTag = new Tag
|
||||||
@@ -315,6 +317,7 @@ public sealed class DeploymentArtifactAliasParityTests
|
|||||||
d.DataType.ShouldBe(x.DataType);
|
d.DataType.ShouldBe(x.DataType);
|
||||||
d.FullName.ShouldBe(x.FullName);
|
d.FullName.ShouldBe(x.FullName);
|
||||||
d.Writable.ShouldBe(x.Writable);
|
d.Writable.ShouldBe(x.Writable);
|
||||||
|
d.Alarm.ShouldBe(x.Alarm); // EquipmentTagAlarmInfo is a positional record ⇒ value equality
|
||||||
}
|
}
|
||||||
|
|
||||||
var galaxyPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-galaxy");
|
var galaxyPlan = decoded.EquipmentTags.Single(t => t.TagId == "tag-galaxy");
|
||||||
@@ -323,6 +326,15 @@ public sealed class DeploymentArtifactAliasParityTests
|
|||||||
galaxyPlan.DriverInstanceId.ShouldBe("drv-galaxy");
|
galaxyPlan.DriverInstanceId.ShouldBe("drv-galaxy");
|
||||||
galaxyPlan.FolderPath.ShouldBe(string.Empty); // null FolderPath coalesced identically on both sides
|
galaxyPlan.FolderPath.ShouldBe(string.Empty); // null FolderPath coalesced identically on both sides
|
||||||
|
|
||||||
|
// The native-alarm intent in the Galaxy tag's TagConfig.alarm is parsed byte-identically on both
|
||||||
|
// producers (Phase B WS-2). The Modbus tag has no alarm object ⇒ null Alarm on both sides.
|
||||||
|
galaxyPlan.Alarm.ShouldNotBeNull();
|
||||||
|
galaxyPlan.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
|
||||||
|
galaxyPlan.Alarm.Severity.ShouldBe(700);
|
||||||
|
composed.EquipmentTags.Single(t => t.TagId == "tag-galaxy").Alarm.ShouldBe(galaxyPlan.Alarm);
|
||||||
|
decoded.EquipmentTags.Single(t => t.TagId == "tag-modbus").Alarm.ShouldBeNull();
|
||||||
|
composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Alarm.ShouldBeNull();
|
||||||
|
|
||||||
// Writability flows from Tag.AccessLevel: the Galaxy tag is Read (read-only node), the Modbus
|
// Writability flows from Tag.AccessLevel: the Galaxy tag is Read (read-only node), the Modbus
|
||||||
// tag is ReadWrite (writable node). Both producers must derive the same Writable flag, and the
|
// tag is ReadWrite (writable node). Both producers must derive the same Writable flag, and the
|
||||||
// SequenceEqual above already proves they agree element-wise.
|
// SequenceEqual above already proves they agree element-wise.
|
||||||
|
|||||||
Reference in New Issue
Block a user