diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs
index a4476b3c..705079fa 100644
--- a/Services/Iec61850DataSetSignalInventoryService.cs
+++ b/Services/Iec61850DataSetSignalInventoryService.cs
@@ -26,11 +26,11 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals(
{
ArgumentNullException.ThrowIfNull(device);
- // Signal Selection is also opened directly from an offline CID/SCD workspace.
- // In that workflow LiveDiscoveryModel is intentionally null; the SCL design model
- // is the authoritative inventory and must not be ignored. Prefer the live model
- // only after a real association/discovery has produced one.
- var authoritativeModel = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel;
+ // When an SCL workspace is open, its configured DataSet membership remains the
+ // engineering authority across cached/fast reconnects. A partial live discovery
+ // model must not erase FCDA/FCD rows that were proven by the opened CID/SCD. For an
+ // online-only IED with no SCL workspace, live discovery remains the authority.
+ var authoritativeModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel;
if (authoritativeModel is null)
return EmptyResult();
diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs
index 63844047..5296d816 100644
--- a/Services/Iec61850StaticDataSetAuthoritySelection.cs
+++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs
@@ -6,11 +6,17 @@ namespace ArIED61850Tester.Services;
///
/// Builds the exact Static DataSet selection used by the report-only workflow.
///
-/// A DataSetReference on a browsed/runtime alias is not sufficient authority: several
+/// Static report-only mode is RCB-backed by definition. A static DataSet that is not
+/// referenced by an authoritative configured BRCB/URCB is valid engineering inventory,
+/// but it is not a live acquisition source and therefore must not inflate the monitor with
+/// permanently unavailable rows. When an SCL workspace is open, its ReportControl bindings
+/// are the configuration authority; live discovery is verification only. For online-only
+/// operation with no SCL workspace, live discovery becomes the configuration authority.
+///
+/// A DataSetReference on a browsed/runtime alias is also not sufficient authority: several
/// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or
-/// structured measurement descendants). Static mode must select one presentation row
-/// for each engine-authoritative DataSet membership, preserving the literal member
-/// identity that appears in SCL/ARIEC.
+/// structured measurement descendants). Static mode selects one presentation row for each
+/// engine-authoritative membership, preserving the literal member identity from SCL/ARIEC.
///
public static class Iec61850StaticDataSetAuthoritySelection
{
@@ -18,64 +24,108 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device)
{
ArgumentNullException.ThrowIfNull(device);
- var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel;
- if (model is null)
+ // Opened SCL is the engineering authority. A partial or richer live model may verify
+ // the configuration later, but it must not introduce extra static memberships that
+ // were never configured in the opened CID/SCD. Online-only mode falls back to live.
+ var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel;
+ if (authorityModel is null)
return new HashSet(ReferenceEqualityComparer.Instance);
- var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model);
+ var reportBackedDataSets = BuildReportBackedDataSetReferences(device);
+ if (reportBackedDataSets.Count == 0)
+ return new HashSet(ReferenceEqualityComparer.Instance);
+
+ var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel);
var selected = new HashSet(ReferenceEqualityComparer.Instance);
var signals = device.Signals.ToArray();
foreach (var descriptor in mandatory)
{
- var membership = descriptor.DataSetMemberships
+ // A descriptor may carry more than one membership. Do not arbitrarily take the
+ // first DataSet: choose only literal memberships that are backed by authoritative
+ // report-control configuration.
+ var memberships = descriptor.DataSetMemberships
+ .Where(item => reportBackedDataSets.Contains(NormalizeLiteral(item.DataSetReference)))
.OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.MemberIndex)
- .FirstOrDefault();
- if (membership is null)
- continue;
-
- var memberReference = FirstNonEmpty(
- membership.CanonicalMemberReference,
- membership.OriginalMemberReference,
- descriptor.DesignReference,
- descriptor.ObservedReference,
- descriptor.PrimaryValueReference);
- if (string.IsNullOrWhiteSpace(memberReference) ||
- string.IsNullOrWhiteSpace(membership.DataSetReference))
- continue;
-
- var candidates = signals
- .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime)
- .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference))
- .Where(signal => LiteralEquals(signal.DisplayReference, memberReference))
.ToArray();
- if (candidates.Length == 0)
- continue;
-
- // Prefer the runtime row already bound to ARIEC's resolved primary value.
- // For an unresolved structured member, the inventory-created exact static row
- // wins over a generic browsed alias. Never choose by fuzzy/prefix matching.
- var chosen = candidates
- .OrderByDescending(signal =>
- !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) &&
- LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference))
- .ThenByDescending(signal =>
- (signal.Source ?? string.Empty).Contains(
- "mandatory static DataSet member",
- StringComparison.OrdinalIgnoreCase))
- .ThenByDescending(signal =>
- string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase))
- .ThenByDescending(signal =>
- string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase))
- .First();
-
- selected.Add(chosen);
+
+ foreach (var membership in memberships)
+ {
+ var memberReference = FirstNonEmpty(
+ membership.CanonicalMemberReference,
+ membership.OriginalMemberReference,
+ descriptor.DesignReference,
+ descriptor.ObservedReference,
+ descriptor.PrimaryValueReference);
+ if (string.IsNullOrWhiteSpace(memberReference) ||
+ string.IsNullOrWhiteSpace(membership.DataSetReference))
+ {
+ continue;
+ }
+
+ var candidates = signals
+ .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime)
+ .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference))
+ .Where(signal => LiteralEquals(signal.DisplayReference, memberReference))
+ .ToArray();
+ if (candidates.Length == 0)
+ continue;
+
+ // Prefer the runtime row already bound to ARIEC's resolved primary value.
+ // For an unresolved structured member, the inventory-created exact static row
+ // wins over a generic browsed alias. Never choose by fuzzy/prefix matching.
+ var chosen = candidates
+ .OrderByDescending(signal =>
+ !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) &&
+ LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference))
+ .ThenByDescending(signal =>
+ (signal.Source ?? string.Empty).Contains(
+ "mandatory static DataSet member",
+ StringComparison.OrdinalIgnoreCase))
+ .ThenByDescending(signal =>
+ string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase))
+ .ThenByDescending(signal =>
+ string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase))
+ .First();
+
+ selected.Add(chosen);
+ }
}
return selected;
}
+ ///
+ /// Returns the literal DataSet references that have authoritative ReportControl backing.
+ /// Opened SCL configuration wins absolutely over extra live ReportControls. Live discovery
+ /// is used as configuration authority only when no SCL design model is open.
+ ///
+ public static IReadOnlySet BuildReportBackedDataSetReferences(Iec61850MonitorDevice device)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+
+ var result = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var configurationModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel;
+ AddReportBackedDataSets(configurationModel, result);
+ return result;
+ }
+
+ private static void AddReportBackedDataSets(
+ LiveIedModelDiscoveryDocument? model,
+ HashSet target)
+ {
+ if (model is null)
+ return;
+
+ foreach (var report in model.ReportControls)
+ {
+ var dataSetReference = NormalizeLiteral(report.DataSetReference);
+ if (!string.IsNullOrWhiteSpace(dataSetReference))
+ target.Add(dataSetReference);
+ }
+ }
+
private static bool LiteralEquals(string? left, string? right)
=> string.Equals(NormalizeLiteral(left), NormalizeLiteral(right), StringComparison.OrdinalIgnoreCase);
diff --git a/Services/Iec61850StaticRcbReferenceMatcher.cs b/Services/Iec61850StaticRcbReferenceMatcher.cs
new file mode 100644
index 00000000..8adc6a7c
--- /dev/null
+++ b/Services/Iec61850StaticRcbReferenceMatcher.cs
@@ -0,0 +1,74 @@
+namespace ArIED61850Tester.Services;
+
+///
+/// Matches one configured SCL ReportControl declaration to the concrete RCB object exposed
+/// by the live MMS server.
+///
+/// IEC 61850 SCL may describe one indexed ReportControl family while the server exposes
+/// concrete instances such as Buffer01 / Buffer02. Exact identity always wins. The only
+/// accepted fallback is a decimal indexed instance of the same literal RCB family; arbitrary
+/// same-DataSet RCBs are never treated as substitutes.
+///
+public static class Iec61850StaticRcbReferenceMatcher
+{
+ public static bool IsExact(string? configuredReference, string? liveReference)
+ => string.Equals(
+ Normalize(configuredReference),
+ Normalize(liveReference),
+ StringComparison.OrdinalIgnoreCase);
+
+ public static bool IsConfiguredOrIndexedInstance(string? configuredReference, string? liveReference)
+ {
+ var configured = Normalize(configuredReference);
+ var live = Normalize(liveReference);
+ if (string.IsNullOrWhiteSpace(configured) || string.IsNullOrWhiteSpace(live))
+ return false;
+ if (string.Equals(configured, live, StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ SplitLeaf(configured, out var configuredParent, out var configuredLeaf);
+ SplitLeaf(live, out var liveParent, out var liveLeaf);
+ if (!string.Equals(configuredParent, liveParent, StringComparison.OrdinalIgnoreCase) ||
+ string.IsNullOrWhiteSpace(configuredLeaf) ||
+ string.IsNullOrWhiteSpace(liveLeaf))
+ {
+ return false;
+ }
+
+ // If the configured declaration already ends in a digit, treat it as a concrete
+ // object and require exact identity. This avoids accidental prefix matches such as
+ // Buffer0 -> Buffer01.
+ if (char.IsDigit(configuredLeaf[^1]) ||
+ !liveLeaf.StartsWith(configuredLeaf, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var suffix = liveLeaf[configuredLeaf.Length..];
+ return suffix.Length > 0 && suffix.All(char.IsDigit);
+ }
+
+ public static int MatchRank(string? configuredReference, string? liveReference)
+ {
+ if (IsExact(configuredReference, liveReference))
+ return 0;
+ return IsConfiguredOrIndexedInstance(configuredReference, liveReference) ? 1 : int.MaxValue;
+ }
+
+ public static string Normalize(string? reference)
+ => (reference ?? string.Empty).Trim().Replace('$', '.');
+
+ private static void SplitLeaf(string reference, out string parent, out string leaf)
+ {
+ var separator = reference.LastIndexOf('.');
+ if (separator < 0)
+ {
+ parent = string.Empty;
+ leaf = reference;
+ return;
+ }
+
+ parent = reference[..separator];
+ leaf = reference[(separator + 1)..];
+ }
+}
diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs
index 68cdcd9b..32d5173a 100644
--- a/Services/NativeIec61850Client.StaticDataSetReporting.cs
+++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs
@@ -1,3 +1,4 @@
+using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;
using ArMms = AR.Iec61850.Mms;
@@ -9,9 +10,10 @@ namespace ArIED61850Tester.Services;
/// Static mode already has complete configuration authority in the opened/live SCL model:
/// exact DataSet membership and configured RCB -> DataSet bindings. Do not run those facts
/// through the adaptive Hybrid acquisition planner. The live association is used only to
-/// verify that the exact configured RCB and exact DataSet directory exist, then ARIEC's
-/// persistent monitor installs the InformationReport receiver, enables RptEna and requests
-/// GI. No dynamic DataSet write and no cyclic process-value MMS read is permitted here.
+/// verify that the configured RCB (or a concrete indexed instance of that configured family)
+/// and exact DataSet directory exist, then ARIEC's persistent monitor installs the
+/// InformationReport receiver, enables RptEna and requests GI. No dynamic DataSet write and
+/// no cyclic process-value MMS read is permitted here.
///
public sealed partial class NativeIec61850Client
{
@@ -30,8 +32,10 @@ public async Task BuildStaticDataSetReportPlan
_deterministicStaticSubscriptions.Clear();
ResetSemanticReportProjectionContext();
- var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel;
- if (model is null)
+ // Opened SCL is the semantic and configuration authority. A live model is authoritative
+ // only for online-only operation where no CID/SCD workspace is open.
+ var projectionModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel;
+ if (projectionModel is null)
{
return StaticPlanningUnavailable(
points,
@@ -45,12 +49,22 @@ public async Task BuildStaticDataSetReportPlan
$"Static DataSet report-only requires an initiated MMS association. Current state: {_session.State}.");
}
- SetSemanticReportProjectionAuthority(model);
+ SetSemanticReportProjectionAuthority(projectionModel);
+
+ // P0 authority rule: when an SCL workspace exists, only ReportControls from that
+ // design model may authorize static acquisition. Fresh live discovery is verification
+ // and concrete-instance evidence; it must never introduce a peer RCB that can displace
+ // an explicit SCL binding such as Digital -> Buffer02.
+ var configurationModel = projectionModel;
+ var configurationAuthorityLabel = device.SclWorkspace?.DesignModel is not null
+ ? "opened SCL design model"
+ : "live discovery model (online-only)";
// Fresh report discovery is verification, not permission policy. In particular we
- // deliberately do NOT classify a configured BRCB through the Hybrid availability
- // confidence gate. Some perfectly usable servers expose RptEna and DatSet but omit
- // enough reservation metadata for that adaptive gate to call the RCB 'Available'.
+ // deliberately do NOT require the adaptive Hybrid availability gate to classify a
+ // configured BRCB as Available. Some perfectly usable servers omit enough reservation
+ // metadata to remain Unknown. Explicit enabled/reserved/owner evidence is still used
+ // to avoid stealing an occupied RCB.
var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false);
if (discovery is null)
{
@@ -61,6 +75,11 @@ public async Task BuildStaticDataSetReportPlan
: LastErrorMessage);
}
+ var callerOwnedRcbReferences = _reportMonitorSessions.Values
+ .Select(session => NormalizeStaticReference(session.ReportControl.Reference))
+ .Where(reference => !string.IsNullOrWhiteSpace(reference))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
var reportPlans = new List();
var warnings = new List();
var coveredPointKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
@@ -76,8 +95,13 @@ public async Task BuildStaticDataSetReportPlan
{
cancellationToken.ThrowIfCancellationRequested();
- var configuredReports = model.ReportControls
+ var dataSetReference = dataSetGroup.First().DataSetReference.Trim();
+ var configuredReports = configurationModel.ReportControls
.Where(report => SameStaticReference(report.DataSetReference, dataSetGroup.Key))
+ .GroupBy(
+ report => $"{NormalizeStaticReference(report.Reference)}|{NormalizeStaticReference(report.DataSetReference)}",
+ StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
.OrderByDescending(report => report.Buffered)
.ThenBy(report => report.Reference, StringComparer.OrdinalIgnoreCase)
.ToArray();
@@ -85,42 +109,129 @@ public async Task BuildStaticDataSetReportPlan
if (configuredReports.Length == 0)
{
warnings.Add(
- $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL model; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted.");
+ $"{dataSetReference}: no configured BRCB/URCB in the authoritative {configurationAuthorityLabel}; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted.");
continue;
}
- if (configuredReports.Length > 1)
- {
- warnings.Add(
- $"{dataSetGroup.First().DataSetReference}: {configuredReports.Length} configured RCBs reference this DataSet; deterministic Static mode selected {configuredReports[0].Reference} (BRCB preferred, then literal reference order)." );
- }
+ // Evaluate every authoritative configured RCB instead of selecting only the first.
+ // If several SCL ReportControls legitimately reference the same DataSet, one
+ // missing/occupied RCB must not hide another configured option. A configured family
+ // may resolve only to decimal indexed instances of that same literal family;
+ // arbitrary same-DataSet substitution remains forbidden.
+ var matchedLiveCandidates = configuredReports
+ .SelectMany(configured => discovery.ReportInventory.ReportControls
+ .Select(candidate => new
+ {
+ Configured = configured,
+ Candidate = candidate,
+ Rank = Iec61850StaticRcbReferenceMatcher.MatchRank(
+ configured.Reference,
+ candidate.Reference)
+ })
+ .Where(item => item.Rank != int.MaxValue)
+ .Where(item =>
+ string.IsNullOrWhiteSpace(item.Candidate.DataSetReference) ||
+ SameStaticReference(item.Candidate.DataSetReference, dataSetReference)))
+ .OrderBy(item => item.Rank)
+ .ThenByDescending(item => item.Configured.Buffered)
+ .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ // Use ARIEC's operational evaluator so Owner is part of the same occupancy
+ // decision as RptEna/Resv/ResvTms. Directory evidence is intentionally omitted at
+ // this stage: Unknown is still eligible and the exact DataSet directory is verified
+ // before the plan is armed. If a currently active session already owns the RCB,
+ // preserve that fact as UsedByCaller instead of misclassifying it as foreign use.
+ var evaluatedLiveCandidates = matchedLiveCandidates
+ .Select(item => new
+ {
+ item.Configured,
+ item.Candidate,
+ item.Rank,
+ Availability = ArMms.MmsRcbAvailabilityEvaluator.Evaluate(
+ item.Candidate,
+ dataSetDirectory: null,
+ callerOwned: callerOwnedRcbReferences.Contains(
+ NormalizeStaticReference(item.Candidate.Reference)))
+ })
+ .ToArray();
- var configured = configuredReports[0];
- var liveCandidates = discovery.ReportInventory.ReportControls
- .Where(candidate => SameStaticReference(candidate.Reference, configured.Reference))
+ // An indexed family can expose several concrete RCBs. Literal instance order is
+ // not an activation policy: Buffer01 may already be enabled/reserved/owned while
+ // Buffer02 is the usable instance. Reject only explicit operational blockers;
+ // Unknown remains eligible because some relays omit reservation metadata. Among
+ // equally matched candidates prefer exact identity, caller-owned/known-safe state,
+ // BRCB, explicitly disabled state, and finally stable literal order.
+ var liveCandidates = evaluatedLiveCandidates
+ .Where(item => StaticRcbAvailabilityRank(item.Availability.Availability) != int.MaxValue)
+ .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate))
+ .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate))
+ .OrderBy(item => item.Rank)
+ .ThenBy(item => StaticRcbAvailabilityRank(item.Availability.Availability))
+ .ThenByDescending(item => item.Configured.Buffered)
+ .ThenByDescending(item => ArMms.MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate))
+ .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase)
.ToArray();
- if (liveCandidates.Length != 1)
+ if (liveCandidates.Length == 0)
{
+ if (evaluatedLiveCandidates.Length > 0)
+ {
+ var occupied = string.Join(
+ ", ",
+ evaluatedLiveCandidates.Take(8).Select(item =>
+ $"{item.Configured.Reference}->{item.Candidate.Reference}[availability={item.Availability.Availability}, RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]"));
+ warnings.Add(
+ $"{dataSetReference}: exact/indexed-family RCB objects were found for authoritative configuration, but every concrete instance was explicitly unavailable/in-use ({occupied}). Static mode will not steal an occupied RCB and did not poll process values.");
+ continue;
+ }
+
+ var configuredNames = string.Join(", ", configuredReports
+ .Select(report => report.Reference)
+ .Where(reference => !string.IsNullOrWhiteSpace(reference))
+ .Take(8));
+ var sameDataSet = discovery.ReportInventory.ReportControls
+ .Where(candidate =>
+ !string.IsNullOrWhiteSpace(candidate.DataSetReference) &&
+ SameStaticReference(candidate.DataSetReference, dataSetReference))
+ .Select(candidate => candidate.Reference)
+ .Where(reference => !string.IsNullOrWhiteSpace(reference))
+ .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase)
+ .Take(8)
+ .ToArray();
+ var observed = sameDataSet.Length == 0
+ ? "none"
+ : string.Join(", ", sameDataSet);
warnings.Add(
- $"{configured.Reference}: configured RCB was proven by SCL but exact live MMS discovery returned {liveCandidates.Length} matching RCB object(s). Static mode refused to guess and did not poll process values.");
+ $"{dataSetReference}: authoritative configured RCB(s) [{configuredNames}] had no exact/indexed-family live instance. Same-DataSet live RCBs: {observed}. Static mode refused arbitrary substitution and did not poll process values.");
continue;
}
- var liveRcb = CloneReportControlForPlanning(liveCandidates[0]);
- var dataSetReference = dataSetGroup.First().DataSetReference.Trim();
+ var selected = liveCandidates[0];
+ var configured = selected.Configured;
+ var liveSource = selected.Candidate;
+ var liveAvailability = selected.Availability;
+ var liveRcb = CloneReportControlForPlanning(liveSource);
+
+ if (configuredReports.Length > 1 || liveCandidates.Length > 1)
+ {
+ warnings.Add(
+ $"{dataSetReference}: evaluated {configuredReports.Length} authoritative configured RCB(s) and {liveCandidates.Length} non-occupied exact/indexed live match(es); selected {configured.Reference} -> {liveSource.Reference} ({liveAvailability.Availability}). {configurationAuthorityLabel} remained authoritative.");
+ }
if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) &&
!SameStaticReference(liveRcb.DataSetReference, dataSetReference))
{
warnings.Add(
- $"{configured.Reference}: SCL binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling.");
+ $"{configured.Reference}: authoritative configuration binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling.");
continue;
}
- // Missing live DatSet text is not treated as a reason to discard correct SCL
- // configuration. The exact DataSet directory below is still required and is the
- // ordered mapping authority for InformationReport values.
+ // Missing live DatSet text is not treated as a reason to discard correct
+ // authoritative configuration. The exact DataSet directory below is still required
+ // and is the ordered mapping authority for InformationReport values.
if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference))
liveRcb.DataSetReference = dataSetReference;
@@ -149,13 +260,16 @@ public async Task BuildStaticDataSetReportPlan
if (bindings.Count == 0)
continue;
+ var concreteReportReference = string.IsNullOrWhiteSpace(liveRcb.Reference)
+ ? configured.Reference
+ : liveRcb.Reference;
var plan = new ReportControlPlan
{
RelayId = device.DeviceId,
RelayName = device.Name,
RelayIpAddress = device.IpAddress,
IedName = device.Name,
- ReportControlReference = configured.Reference,
+ ReportControlReference = concreteReportReference,
DataSetReference = dataSetReference,
Mode = "Static DataSet • deterministic configured RCB",
AllowDynamicDataSetWrites = false,
@@ -171,10 +285,20 @@ public async Task BuildStaticDataSetReportPlan
};
var subscriptionWarnings = new List();
- if (string.IsNullOrWhiteSpace(liveCandidates[0].DataSetReference))
+ if (!Iec61850StaticRcbReferenceMatcher.IsExact(configured.Reference, concreteReportReference))
+ {
+ subscriptionWarnings.Add(
+ $"Configured ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}.");
+ }
+ if (liveAvailability.Availability == ArMms.MmsRcbOperationalAvailability.Unknown)
{
subscriptionWarnings.Add(
- "Live RCB DatSet text was not returned; exact SCL RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority.");
+ $"Live RCB availability for {concreteReportReference} remains Unknown ({liveAvailability.Reason}). Static mode permits this because identity/configuration are authoritative and no explicit in-use evidence was observed.");
+ }
+ if (string.IsNullOrWhiteSpace(liveSource.DataSetReference))
+ {
+ subscriptionWarnings.Add(
+ "Live RCB DatSet text was not returned; exact authoritative RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority.");
}
var subscription = new ArMms.MmsReportSubscriptionPlan
@@ -187,7 +311,7 @@ public async Task BuildStaticDataSetReportPlan
DynamicPoints = Array.Empty(),
Steps = new[]
{
- $"Verify exact configured RCB {configured.Reference} exists on the live association.",
+ $"Verify authoritative configured RCB {configured.Reference} as live object {concreteReportReference}.",
$"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).",
"Install InformationReport receiver before enabling the RCB.",
"Write RptEna=true, then request GI=true.",
@@ -384,6 +508,15 @@ private static NativeHybridReportPlanningResult StaticPlanningUnavailable(
UncoveredSignalCount = points.Count
};
+ private static int StaticRcbAvailabilityRank(ArMms.MmsRcbOperationalAvailability availability)
+ => availability switch
+ {
+ ArMms.MmsRcbOperationalAvailability.UsedByCaller => 0,
+ ArMms.MmsRcbOperationalAvailability.Available => 1,
+ ArMms.MmsRcbOperationalAvailability.Unknown => 2,
+ _ => int.MaxValue
+ };
+
private static bool SameStaticReference(string? left, string? right)
=> string.Equals(
NormalizeStaticReference(left),
@@ -391,7 +524,7 @@ private static bool SameStaticReference(string? left, string? right)
StringComparison.OrdinalIgnoreCase);
private static string NormalizeStaticReference(string? reference)
- => (reference ?? string.Empty).Trim().Replace('$', '.');
+ => Iec61850StaticRcbReferenceMatcher.Normalize(reference);
private static int ParseStaticInteger(string? value)
=> int.TryParse(value, out var parsed) && parsed > 0 ? parsed : 0;
diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json
index 5d8014d0..94c589a8 100644
--- a/engines/ARIEC61850.lock.json
+++ b/engines/ARIEC61850.lock.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
- "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc",
+ "commit": "0d7525bd330900917fb9f6d15a46059dc3d7a70a",
"sourcePullRequest": 111,
- "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling."
+ "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values."
}
diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs
index af8ab324..ac7637b0 100644
--- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs
+++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs
@@ -8,7 +8,8 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling()
var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs");
Assert.Contains("Deterministic Static DataSet configured-RCB path", source, StringComparison.Ordinal);
- Assert.Contains("model.ReportControls", source, StringComparison.Ordinal);
+ Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal);
+ Assert.Contains("configurationModel.ReportControls", source, StringComparison.Ordinal);
Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal);
Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal);
Assert.Contains("MmsReportSubscriptionPlanStatus.ReadyRequiresWrite", source, StringComparison.Ordinal);
@@ -17,6 +18,7 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling()
Assert.Contains("PollingPointKeys = Array.Empty()", source, StringComparison.Ordinal);
Assert.Contains("PollingFallbackSignalCount = 0", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal);
Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner", source, StringComparison.Ordinal);
Assert.DoesNotContain("BuildDynamicPlan", source, StringComparison.Ordinal);
Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal);
@@ -24,17 +26,53 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling()
}
[Fact]
- public void StaticPath_RequiresExactConfiguredRcbAndOrderedLiveDataSetDirectory()
+ public void StaticPath_RequiresConfiguredRcbFamilyAndOrderedLiveDataSetDirectory()
{
var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs");
Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal);
- Assert.Contains("SameStaticReference(candidate.Reference, configured.Reference)", source, StringComparison.Ordinal);
+ Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal);
+ Assert.Contains("arbitrary same-DataSet", source, StringComparison.OrdinalIgnoreCase);
Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal);
Assert.Contains("directory.Members", source, StringComparison.Ordinal);
Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal);
- Assert.Contains("SCL binds", source, StringComparison.Ordinal);
+ Assert.Contains("authoritative configuration binds", source, StringComparison.Ordinal);
Assert.Contains("live DatSet reports", source, StringComparison.Ordinal);
+ Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void StaticPath_SclAuthorityCannotBeDisplacedByLivePeerRcb()
+ {
+ var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs");
+
+ Assert.Contains("device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal);
+ Assert.Contains("only ReportControls from that", source, StringComparison.Ordinal);
+ Assert.Contains("design model may authorize static acquisition", source, StringComparison.Ordinal);
+ Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal);
+ Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("SelectMany(configurationModel => configurationModel.ReportControls)", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("var configured = configuredReports[0]", source, StringComparison.Ordinal);
+ Assert.Contains("remained authoritative", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance()
+ {
+ var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs");
+
+ Assert.Contains("matchedLiveCandidates", source, StringComparison.Ordinal);
+ Assert.Contains("evaluatedLiveCandidates", source, StringComparison.Ordinal);
+ Assert.Contains("MmsRcbAvailabilityEvaluator.Evaluate", source, StringComparison.Ordinal);
+ Assert.Contains("callerOwnedRcbReferences", source, StringComparison.Ordinal);
+ Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)", source, StringComparison.Ordinal);
+ Assert.Contains("MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)", source, StringComparison.Ordinal);
+ Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)", source, StringComparison.Ordinal);
+ Assert.Contains("StaticRcbAvailabilityRank", source, StringComparison.Ordinal);
+ Assert.Contains("Owner={item.Candidate.Owner}", source, StringComparison.Ordinal);
+ Assert.Contains("every concrete instance was explicitly unavailable/in-use", source, StringComparison.Ordinal);
+ Assert.Contains("Static mode will not steal an occupied RCB", source, StringComparison.Ordinal);
+ Assert.Contains("prefer exact identity, caller-owned/known-safe state", source, StringComparison.Ordinal);
}
[Fact]
diff --git a/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs
new file mode 100644
index 00000000..56d23d3e
--- /dev/null
+++ b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs
@@ -0,0 +1,100 @@
+namespace ARSAS.Tests;
+
+public sealed class FatScrollStabilityRegressionTests
+{
+ [Fact]
+ public void Recovery_KeepsBuild1868Value1Value2GridContract()
+ {
+ var ux = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs"));
+
+ Assert.Contains("Header = \"TEST\"", ux, StringComparison.Ordinal);
+ Assert.Contains("TextColumn(\"SIGNAL\"", ux, StringComparison.Ordinal);
+ Assert.Contains("TextColumn(\"IEC REFERENCE\"", ux, StringComparison.Ordinal);
+ Assert.Contains("TextColumn(\"TYPE\"", ux, StringComparison.Ordinal);
+ Assert.Contains("Header = \"LIVE VALUE\"", ux, StringComparison.Ordinal);
+ Assert.Contains("Header = slot == FatValueSlot.Value1 ? \"VALUE 1\" : \"VALUE 2\"", ux, StringComparison.Ordinal);
+ Assert.Contains("TextColumn(\"STATUS\"", ux, StringComparison.Ordinal);
+ Assert.Contains("TextColumn(\"RESULT\"", ux, StringComparison.Ordinal);
+ Assert.DoesNotContain("ON · RELAY TIME", ux, StringComparison.Ordinal);
+ Assert.DoesNotContain("OFF · RELAY TIME", ux, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Recovery_RestoresGoldenWpfVirtualizationAndForbidsRuntimeModeMutation()
+ {
+ var root = FindRepoRoot();
+ var xaml = File.ReadAllText(Path.Combine(root, "IoListTestingWindow.xaml"));
+
+ // The physical-bench failure proved that VirtualizationMode must never be mutated
+ // from Loaded/OnInitialized/runtime code. Restore the Build #1868 XAML-owned policy.
+ Assert.Contains("EnableRowVirtualization=\"True\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("EnableColumnVirtualization=\"True\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("VirtualizingPanel.IsVirtualizing=\"True\"", xaml, StringComparison.Ordinal);
+ Assert.Contains("VirtualizingPanel.VirtualizationMode=\"Recycling\"", xaml, StringComparison.Ordinal);
+
+ Assert.False(
+ File.Exists(Path.Combine(root, "IoListTestingWindow.FatScrollStability.cs")),
+ "Do not reintroduce a runtime FAT virtualization patch. WPF throws if VirtualizationMode is changed after the ItemsHost has entered Measure.");
+
+ foreach (var file in Directory.EnumerateFiles(root, "IoListTestingWindow*.cs", SearchOption.TopDirectoryOnly))
+ {
+ var source = File.ReadAllText(file);
+ Assert.DoesNotContain("VirtualizingPanel.SetVirtualizationMode", source, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void Recovery_KeepsGoldenFatMembershipAndEngineeringReturnPath()
+ {
+ var fatUx = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs"));
+ var fatModeSwitch = File.ReadAllText(FindRepoFile("IoListTestingWindow.WorkspaceModeSwitch.cs"));
+ var mainModeSwitch = File.ReadAllText(FindRepoFile("MainWindow.WorkspaceModeSwitch.cs"));
+ var sharedScl = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs"));
+ var fatHost = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs"));
+
+ Assert.Contains("point.WorkspaceSelected", fatUx, StringComparison.Ordinal);
+ Assert.Contains("point.IsIncludedInFat", fatUx, StringComparison.Ordinal);
+
+ Assert.Contains("Engineering Workspace", fatModeSwitch, StringComparison.Ordinal);
+ Assert.Contains("Return to Engineering without unloading this FAT project", fatModeSwitch, StringComparison.Ordinal);
+ Assert.Contains("owner.ShowEngineeringWorkspaceFromFat(this)", fatModeSwitch, StringComparison.Ordinal);
+ Assert.Contains("Hide();", fatModeSwitch, StringComparison.Ordinal);
+
+ Assert.Contains("_loadedIoFatWindow", mainModeSwitch, StringComparison.Ordinal);
+ Assert.Contains("ShowLoadedIoFatWorkspace", mainModeSwitch, StringComparison.Ordinal);
+ Assert.Contains("CurrentEngineeringSclSourcePaths", mainModeSwitch, StringComparison.Ordinal);
+ Assert.Contains("OpenSclFatSourcesAsync(sharedSources, selectionMode: null)", mainModeSwitch, StringComparison.Ordinal);
+
+ Assert.Contains("_sharedSclSelectionAuthorityDeviceIds", sharedScl, StringComparison.Ordinal);
+ Assert.Contains("RegisterLoadedIoFatWindow(window)", fatHost, StringComparison.Ordinal);
+ Assert.DoesNotContain("window.ShowDialog();", fatHost, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Recovery_DoesNotReintroducePerCellLiveValueAuthorityPatch()
+ {
+ var root = FindRepoRoot();
+ var forbiddenPath = Path.Combine(root, "IoListTestingWindow.LiveValueAuthority.cs");
+
+ Assert.False(
+ File.Exists(forbiddenPath),
+ "The per-cell LIVE VALUE authority patch is forbidden on the Build #1868 recovery branch because it can fan out UI-thread subscriptions and regress FAT lifecycle behavior.");
+ }
+
+ private static string FindRepoFile(string relativePath)
+ => Path.Combine(FindRepoRoot(), relativePath);
+
+ private static string FindRepoRoot()
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "IoListTestingWindow.FatV2Ux.cs")))
+ return directory.FullName;
+ directory = directory.Parent;
+ }
+
+ throw new DirectoryNotFoundException(
+ $"Could not locate repository root from '{AppContext.BaseDirectory}'.");
+ }
+}
diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs
index b9d397a6..03be0935 100644
--- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs
+++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs
@@ -6,14 +6,18 @@ namespace ARSAS.Tests;
public sealed class OfflineDataSetSignalSelectionRegressionTests
{
[Fact]
- public void DeviceInventoryMerge_FallsBackToOfflineSclDesignModel()
+ public void DeviceInventoryMerge_PreservesOpenedSclAuthorityAcrossFastReconnect()
{
var source = File.ReadAllText(FindRepoFile("Services/Iec61850DataSetSignalInventoryService.cs"));
Assert.Contains(
- "device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel",
+ "device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel",
source,
StringComparison.Ordinal);
+ Assert.Contains(
+ "partial live discovery",
+ source,
+ StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(
"if (device.LiveDiscoveryModel is null)\n return EmptyResult();",
source,
diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs
index 3594d1ac..a82711b9 100644
--- a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs
+++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs
@@ -50,7 +50,10 @@ public void StaticReport_TotPf_UsesExactSemanticEngineAuthorityWithoutMmsFallbac
var projection = Read("Services/StaticDataSetReportProjectionAccumulator.cs");
Assert.Contains("\"sourcePullRequest\": 111", engineLock, StringComparison.Ordinal);
- Assert.Contains("69bfe70e2c779c7e8268af087bd1a3a38986c0fc", engineLock, StringComparison.Ordinal);
+ Assert.Contains("0d7525bd330900917fb9f6d15a46059dc3d7a70a", engineLock, StringComparison.Ordinal);
+ Assert.Contains("P1 hardening", engineLock, StringComparison.Ordinal);
+ Assert.Contains("report-value position", engineLock, StringComparison.Ordinal);
+ Assert.Contains("omits MemberReference", engineLock, StringComparison.Ordinal);
Assert.Contains("MmsSemanticReportValueProjector.Project", semanticBridge, StringComparison.Ordinal);
Assert.Contains("session.StaticReportProjection.Project", runtime, StringComparison.Ordinal);
Assert.Contains("MMS process fallback is disabled", runtime, StringComparison.Ordinal);
diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs
index 50a41a62..0e9e804b 100644
--- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs
+++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs
@@ -40,7 +40,7 @@ public void RuntimeContract_StaticDataSetMode_DoesNotScheduleCyclicMmsProcessPol
}
[Fact]
- public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatasetTaggedAlias()
+ public void SharedSclStaticSelection_UsesOnlyExactRcbBackedAriecMembershipRows()
{
var source = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs"));
var authority = File.ReadAllText(FindRepoFile("Services/Iec61850StaticDataSetAuthoritySelection.cs"));
@@ -53,13 +53,62 @@ public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatase
Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", source, StringComparison.Ordinal);
Assert.DoesNotContain("fallback remains available", source, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)", authority, StringComparison.Ordinal);
+ Assert.Contains("var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", authority, StringComparison.Ordinal);
+ Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel)", authority, StringComparison.Ordinal);
+ Assert.Contains("BuildReportBackedDataSetReferences(device)", authority, StringComparison.Ordinal);
+ Assert.Contains("reportBackedDataSets.Contains", authority, StringComparison.Ordinal);
+ Assert.Contains("var configurationModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", authority, StringComparison.Ordinal);
+ Assert.Contains("AddReportBackedDataSets(configurationModel, result)", authority, StringComparison.Ordinal);
+ Assert.DoesNotContain("AddReportBackedDataSets(device.SclWorkspace?.DesignModel, result)", authority, StringComparison.Ordinal);
+ Assert.DoesNotContain("AddReportBackedDataSets(device.LiveDiscoveryModel, result)", authority, StringComparison.Ordinal);
Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal);
Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal);
Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal);
Assert.DoesNotContain("Contains(memberReference", authority, StringComparison.Ordinal);
}
+ [Theory]
+ [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Buffer01", true)]
+ [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Buffer02", true)]
+ [InlineData("IEDApplication/LLN0$BR$Buffer02", "IEDApplication/LLN0$BR$Buffer02", true)]
+ [InlineData("IEDApplication/LLN0$BR$Buffer02", "IEDApplication/LLN0$BR$Buffer01", false)]
+ [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Other01", false)]
+ [InlineData("IEDApplication/LLN0$BR$Buffer0", "IEDApplication/LLN0$BR$Buffer01", false)]
+ public void StaticRcbMatcher_AcceptsOnlyExactOrDecimalIndexedFamilyInstances(
+ string configured,
+ string live,
+ bool expected)
+ {
+ Assert.Equal(expected, Iec61850StaticRcbReferenceMatcher.IsConfiguredOrIndexedInstance(configured, live));
+ }
+
+ [Fact]
+ public void StaticRcbMatcher_ExactIdentityAlwaysRanksBeforeIndexedFamily()
+ {
+ const string configured = "IEDApplication/LLN0$BR$Buffer";
+ Assert.Equal(0, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, configured));
+ Assert.Equal(1, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, "IEDApplication/LLN0$BR$Buffer01"));
+ Assert.Equal(int.MaxValue, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, "IEDApplication/LLN0$BR$Other01"));
+ }
+
+ [Fact]
+ public void DeterministicStaticPlanner_PreservesSclConfigurationAndConcreteLiveRcbAuthority()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.StaticDataSetReporting.cs"));
+
+ Assert.Contains("device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal);
+ Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal);
+ Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal);
+ Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal);
+ Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal);
+ Assert.Contains("Install InformationReport receiver before enabling the RCB", source, StringComparison.Ordinal);
+ Assert.Contains("Write RptEna=true, then request GI=true", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("var configured = configuredReports[0]", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("PollingPointKeys = points.Select", source, StringComparison.Ordinal);
+ }
+
[Fact]
public void StaticDataSetRegressionGuard_PreservesFa16ReportOnlyDirection()
{
@@ -103,8 +152,6 @@ public void SharedSclFat_IsConsumerOfExistingAcquisitionSession_NotASecondMonito
Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal);
Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal);
- // The legacy helper may remain for workbook-only FAT, but the shared SCL branch
- // must be structurally separate and must start through the normal Engineering owner.
var sharedBranchStart = source.IndexOf("if (reuseSharedSclAcquisition)", StringComparison.Ordinal);
var legacyBranchStart = source.IndexOf("Compatibility path for non-shared/legacy FAT only", StringComparison.Ordinal);
Assert.True(sharedBranchStart >= 0 && legacyBranchStart > sharedBranchStart);