Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c3e66a2
refactor(reporting): make model DataSet directory projection source-n…
masarray Sep 19, 2026
e2ad4ba
fix(reporting): keep canonical DataSet order through live static moni…
masarray Sep 19, 2026
c53c280
chore(engine): pin source-neutral static activation candidate
masarray Sep 19, 2026
167e4ba
fix(discovery): route completed model through shared signal-source ch…
masarray Sep 19, 2026
eeff774
ux(discovery): label shared post-model signal sources
masarray Sep 19, 2026
ef0cce7
ux(discovery): label shared post-model signal sources
masarray Sep 19, 2026
a69aaa9
ux(discovery): label shared post-model signal sources
masarray Sep 19, 2026
37f3c7b
fix(reporting): keep SCL-first projection authority precedence
masarray Sep 19, 2026
ccfdd02
test(reporting): lock canonical live static DataSet authority
masarray Sep 19, 2026
3da726f
test(discovery): lock canonical static parity routing
masarray Sep 19, 2026
895f5c7
chore(engine): advance static activation candidate pin
masarray Sep 19, 2026
eb3a431
fix(ci): keep immutable engine lock schema while pinning candidate co…
masarray Sep 19, 2026
e050cd7
diag(dataset): list exact unresolved primary memberships
masarray Sep 19, 2026
a6b129d
fix(discovery): promote physically proven smart route into main source
masarray Sep 19, 2026
63fb3a6
test(discovery): lock physically proven smart route in source
masarray Sep 19, 2026
92603e9
evidence(discovery): record effective R10 build transform provenance
masarray Sep 19, 2026
d9b9a90
chore(public): neutralize release-note interoperability wording
masarray Sep 19, 2026
c35bc85
evidence(discovery): require fresh physical acceptance for source pro…
masarray Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions IedCardActionsGearPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ namespace ArIED61850Tester;
/// <summary>
/// Adds a dedicated Lucide-style gear button to every IED card. The existing pencil
/// remains the direct Edit Signals shortcut; the gear is the explicit entry point for
/// the reusable IED Actions chooser (Static DataSet, Select Signals, RCB Engineering,
/// the reusable IED Actions chooser (DataSet Signals, Signal Catalog, RCB Engineering,
/// COMTRADE and Browse Offline).
/// </summary>
internal static class IedCardActionsGearPolicy
{
private const string GearUid = "ARSAS.IedActionsGear";
private const string GearToolTip = "IED Actions — Static DataSet, Select Signals, RCB Engineering, COMTRADE, Browse Offline";
private const string GearToolTip = "IED Actions — DataSet Signals, Signal Catalog, RCB Engineering, COMTRADE, Browse Offline";
private const double GearOpticalSize = 16d;

// Exact Lucide Settings outline supplied by the product owner, translated from the
Expand Down
2 changes: 1 addition & 1 deletion IedWorkspaceActionsCardBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace ArIED61850Tester;
internal static class IedWorkspaceActionsCardBehavior
{
private const string LegacyToolTip = "Configure Signals";
private const string ActionsToolTip = "IED Actions — Static DataSet, Select Signals, RCB Engineering, COMTRADE, Browse Offline";
private const string ActionsToolTip = "IED Actions — DataSet Signals, Signal Catalog, RCB Engineering, COMTRADE, Browse Offline";

[ModuleInitializer]
internal static void Initialize()
Expand Down
2 changes: 1 addition & 1 deletion MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@ private async Task<bool> ConnectAndConfigureDeviceAsync(
if (openWizard && device.SignalCount > 0)
{
if ((selectDevice || ReferenceEquals(SelectedDevice, device)) && !_signalSelectionWizardOpen)
await OpenSignalSelectionWizardAsync(device, restoredCount);
await OpenIedWorkspaceActionsAsync(device);
else
SetStatus($"{device.Name}: discovery complete. Use the edit icon on its IED card to review {restoredCount} restored selection(s).");
}
Expand Down
6 changes: 3 additions & 3 deletions SclSignalSelectionModeWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,14 @@
<Button Grid.Column="0" Style="{StaticResource PrimaryButton}" Padding="14,11"
Click="MonitorStaticDataSet_Click" ToolTip="Select authoritative static DataSet members and start online monitoring">
<StackPanel>
<TextBlock Text="Static DataSet" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Select + monitor" FontSize="10.5" Opacity="0.82" HorizontalAlignment="Center"/>
<TextBlock Text="DataSet Signals" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Static report-only" FontSize="10.5" Opacity="0.82" HorizontalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="2" Style="{StaticResource SoftButton}" Padding="14,11"
Click="MonitorManual_Click" ToolTip="Choose exact ST, MX and control points, then start monitoring">
<StackPanel>
<TextBlock Text="Select Signals" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Signal Catalog" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Manual + monitor" FontSize="10.5" Foreground="{StaticResource Muted}" HorizontalAlignment="Center"/>
</StackPanel>
</Button>
Expand Down
27 changes: 25 additions & 2 deletions Services/Iec61850DataSetCompletenessDiagnostic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public sealed record Iec61850DataSetCompletenessSnapshot(
public bool IsComplete => StaticMemberCount == RepresentedCount && MissingCount == 0;
public IReadOnlyList<Iec61850DataSetCompletenessDataSetSnapshot> DataSets { get; init; }
= Array.Empty<Iec61850DataSetCompletenessDataSetSnapshot>();
public IReadOnlyList<string> PrimaryUnresolvedReferences { get; init; }
= Array.Empty<string>();

public string Summary =>
$"DataSets={DataSetCount:N0}; static members={StaticMemberCount:N0}; semantic descriptors={MandatoryInventoryCount:N0}; " +
Expand Down Expand Up @@ -89,15 +91,28 @@ public static Iec61850DataSetCompletenessSnapshot Evaluate(
dataSetMissing));
}

var unresolvedPrimaryReferences = mandatory
.Where(descriptor => descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved)
.SelectMany(descriptor => descriptor.DataSetMemberships.Count > 0
? descriptor.DataSetMemberships.Select(membership =>
$"{membership.DataSetReference}[{membership.MemberIndex}] -> " +
$"{FirstNonEmpty(membership.CanonicalMemberReference, membership.OriginalMemberReference, descriptor.DesignReference)}")
: new[] { FirstNonEmpty(descriptor.DesignReference, descriptor.ObservedReference, "<unknown>") })
.Where(reference => !string.IsNullOrWhiteSpace(reference))
.Distinct(StringComparer.Ordinal)
.OrderBy(reference => reference, StringComparer.Ordinal)
.ToArray();

return new Iec61850DataSetCompletenessSnapshot(
model.DataSets.Count,
staticMemberCount,
mandatory.Count,
represented,
mandatory.Count(descriptor => descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved),
unresolvedPrimaryReferences.Length,
missing)
{
DataSets = dataSetSnapshots
DataSets = dataSetSnapshots,
PrimaryUnresolvedReferences = unresolvedPrimaryReferences
};
}

Expand All @@ -117,6 +132,11 @@ public static IEnumerable<string> FormatReportLines(Iec61850DataSetCompletenessS
yield return $" {dataSet.Reference}: {dataSet.RepresentedCount:N0}/{dataSet.StaticMemberCount:N0} represented • {dataSet.MissingCount:N0} missing";
}

foreach (var reference in snapshot.PrimaryUnresolvedReferences.Take(16))
yield return $" UNRESOLVED PRIMARY : {reference}";
if (snapshot.PrimaryUnresolvedReferences.Count > 16)
yield return $" ... unresolved : {snapshot.PrimaryUnresolvedReferences.Count - 16:N0} more member(s)";

if (snapshot.MissingCount == 0 || maxMissing <= 0)
yield break;

Expand Down Expand Up @@ -146,6 +166,9 @@ public static IEnumerable<string> FormatReportLines(Iec61850DataSetCompletenessS
yield return $" ... : {snapshot.MissingCount - emitted:N0} more missing member(s)";
}

private static string FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;

private static string Literal(string? reference)
=> (reference ?? string.Empty).Trim();
}
2 changes: 1 addition & 1 deletion Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1136,7 +1136,7 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT
{
var effectiveUpdates = session.StaticDataSetReportOnly
? session.StaticReportProjection.Project(
session.Device.LiveDiscoveryModel ?? session.Device.SclWorkspace?.DesignModel,
session.Device.SclWorkspace?.DesignModel ?? session.Device.LiveDiscoveryModel,
session.Points.Values.ToArray(),
sourceUpdate)
: new[] { sourceUpdate };
Expand Down
18 changes: 10 additions & 8 deletions Services/NativeIec61850Client.SclAssisted.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ or ArMms.InitialFcReadExecutionStatus.TimedOut

_liveModel = preparation.InitialReadDesign.Model;
var reportInventory = BuildTrustedSclReportInventory(_liveModel);
var dataSetDirectories = BuildTrustedSclDataSetDirectories(_liveModel);
var dataSetDirectories = BuildModelDataSetDirectories(_liveModel, "TrustedScl");
foreach (var directory in dataSetDirectories)
{
_trustedSclDataSetDirectories[
Expand Down Expand Up @@ -455,8 +455,9 @@ private static ArMms.MmsReportInventory BuildTrustedSclReportInventory(
return inventory;
}

private static IReadOnlyList<ArMms.MmsDataSetDirectoryResult> BuildTrustedSclDataSetDirectories(
LiveIedModelDiscoveryDocument model)
private static IReadOnlyList<ArMms.MmsDataSetDirectoryResult> BuildModelDataSetDirectories(
LiveIedModelDiscoveryDocument model,
string source)
=> model.DataSets
.Select(dataSet =>
{
Expand All @@ -467,7 +468,7 @@ private static ArMms.MmsReportInventory BuildTrustedSclReportInventory(
dataSet.Name);
var members = dataSet.Members
.OrderBy(member => member.Index)
.Select(member => BuildTrustedSclDataSetMember(member, domain))
.Select(member => BuildModelDataSetMember(member, domain, source))
.ToArray();
return new ArMms.MmsDataSetDirectoryResult
{
Expand All @@ -478,14 +479,15 @@ private static ArMms.MmsReportInventory BuildTrustedSclReportInventory(
IsDeletable = dataSet.IsDeletable,
Members = members,
Message =
$"Trusted SCL DataSet authority: {dataSet.Reference} has {members.Length} ordered member(s); no network directory request was sent."
$"{source} DataSet authority: {dataSet.Reference} has {members.Length} ordered member(s); model order is preserved."
};
})
.ToArray();

private static ArMms.MmsDataSetDirectoryMember BuildTrustedSclDataSetMember(
private static ArMms.MmsDataSetDirectoryMember BuildModelDataSetMember(
LiveIedDataSetMemberModel member,
string fallbackDomain)
string fallbackDomain,
string source)
{
var mmsReference = member.MmsReference?.Trim() ?? string.Empty;
var slash = mmsReference.IndexOf('/');
Expand All @@ -507,7 +509,7 @@ private static ArMms.MmsDataSetDirectoryMember BuildTrustedSclDataSetMember(
FunctionalConstraint = member.FunctionalConstraint,
LogicalNode = logicalNode,
DataObjectPath = dataObjectPath,
Source = "TrustedScl",
Source = source,
Confidence = 100
};
}
Expand Down
96 changes: 74 additions & 22 deletions Services/NativeIec61850Client.StaticDataSetReporting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ public async Task<NativeHybridReportPlanningResult> BuildStaticDataSetReportPlan
? "opened SCL design model"
: "live discovery model (online-only)";

// The model that created the operator-visible DataSet inventory is also the semantic
// member-order authority for InformationReport mapping. Live directory evidence is
// verification only; replacing these members with a second raw network projection can
// re-introduce positional/type ambiguity after discovery has already canonicalized it.
var modelDataSetDirectories = BuildModelDataSetDirectories(
projectionModel,
device.SclWorkspace?.DesignModel is not null ? "SclDesignModel" : "LiveDiscoveryModel");

// Fresh report discovery is verification, not permission policy. In particular we
// deliberately do NOT require the adaptive Hybrid availability gate to classify a
// configured BRCB as Available. Some perfectly usable servers omit enough reservation
Expand Down Expand Up @@ -235,20 +243,31 @@ public async Task<NativeHybridReportPlanningResult> BuildStaticDataSetReportPlan
if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference))
liveRcb.DataSetReference = dataSetReference;

var directories = await RunMmsOperationAsync(
() => _session.GetDataSetDirectoriesAsync(
new[] { dataSetReference },
discovery.IedDirectory,
cancellationToken),
cancellationToken).ConfigureAwait(false);
var directory = directories.SingleOrDefault(result =>
var modelDirectory = modelDataSetDirectories.SingleOrDefault(result =>
result.IsSuccess && SameStaticReference(result.DataSetReference, dataSetReference));
if (modelDirectory is null || modelDirectory.Members.Count == 0)
{
warnings.Add(
$"{dataSetReference}: canonical model has no ordered DataSet member list. RCB was not armed because report-index mapping would be unsafe; MMS process polling remains disabled.");
continue;
}

// Smart Discovery already captured GetNamedVariableListAttributes in this same
// association. Reuse that wire evidence instead of issuing a second directory read,
// then require exact count/order identity against the canonical model.
var liveDirectory = discovery.DataSetDirectories.SingleOrDefault(result =>
result.IsSuccess && SameStaticReference(result.DataSetReference, dataSetReference));
if (liveDirectory is null || liveDirectory.Members.Count == 0)
{
warnings.Add(
$"{dataSetReference}: discovery snapshot has no ordered live DataSet directory. RCB was not armed; re-scan is required and MMS process polling remains disabled.");
continue;
}

if (directory is null || directory.Members.Count == 0)
if (!TryVerifyStaticDataSetMemberOrder(modelDirectory, liveDirectory, out var directoryMismatch))
{
var detail = directories.FirstOrDefault()?.Message ?? "no directory response";
warnings.Add(
$"{dataSetReference}: live DataSet directory could not prove an ordered non-empty member list ({detail}). RCB was not armed because report-index mapping would be unsafe; MMS process polling remains disabled.");
$"{dataSetReference}: canonical/live DataSet member order mismatch ({directoryMismatch}). RCB was not armed; unsafe positional report projection and MMS process fallback were both refused.");
continue;
}

Expand Down Expand Up @@ -307,12 +326,12 @@ public async Task<NativeHybridReportPlanningResult> BuildStaticDataSetReportPlan
Status = ArMms.MmsReportSubscriptionPlanStatus.ReadyRequiresWrite,
ReportControl = liveRcb,
DataSetReference = dataSetReference,
Members = directory.Members,
Members = modelDirectory.Members,
DynamicPoints = Array.Empty<ArMms.MmsFcResolvedPoint>(),
Steps = new[]
{
$"Verify authoritative configured RCB {configured.Reference} as live object {concreteReportReference}.",
$"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).",
$"Use canonical ordered DataSet members {dataSetReference} ({modelDirectory.Members.Count} members), verified against the discovery snapshot.",
"Install InformationReport receiver before enabling the RCB.",
"Use client-compatible BRCB reservation when ResvTms is exposed, enable RptEna, then request GI after receiver registration.",
"Map report values by ordered DataSet member index; never substitute cyclic MMS process reads."
Expand Down Expand Up @@ -423,22 +442,18 @@ public async Task<NativeReportMonitorStartResult> StartStaticDataSetReportMonito
}

var coveredReferences = ExtractSubscriptionMemberReferences(subscription.Members);
var attempt = await RunMmsOperationAsync(
() => _session.StartPersistentReportMonitorClientCompatibleAsync(
var start = await RunMmsOperationAsync(
() => _session.StartConfiguredStaticReportMonitorAsync(
subscription,
triggerGeneralInterrogation: true,
deleteDynamicDataSetOnStop: false,
discovery.IedDirectory,
cancellationToken),
cancellationToken).ConfigureAwait(false);
var start = attempt.StartResult;
var warnings = start.Warnings
.Concat(subscription.Warnings)
.Concat(attempt.CleanupWarnings)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

if (!attempt.IsSuccess || start.Session is null)
if (!start.IsSuccess || start.Session is null)
{
return new NativeReportMonitorStartResult
{
Expand All @@ -451,9 +466,9 @@ public async Task<NativeReportMonitorStartResult> StartStaticDataSetReportMonito
UsedDynamicDataSet = false,
DynamicAttempted = false,
DynamicAttemptState = "NotApplicable",
FailureReason = attempt.FailureReason.ToString(),
CleanupAttempted = attempt.CleanupAttempted,
CleanupSucceeded = attempt.CleanupSucceeded,
FailureReason = "ConfiguredStaticActivationFailed",
CleanupAttempted = false,
CleanupSucceeded = true,
ReportControlReference = plan.ReportControlReference,
DataSetReference = plan.DataSetReference,
CoveredReferences = coveredReferences,
Expand Down Expand Up @@ -508,6 +523,43 @@ private static NativeHybridReportPlanningResult StaticPlanningUnavailable(
UncoveredSignalCount = points.Count
};

private static bool TryVerifyStaticDataSetMemberOrder(
ArMms.MmsDataSetDirectoryResult modelDirectory,
ArMms.MmsDataSetDirectoryResult liveDirectory,
out string mismatch)
{
mismatch = string.Empty;
if (modelDirectory.Members.Count != liveDirectory.Members.Count)
{
mismatch = $"count model={modelDirectory.Members.Count}, live={liveDirectory.Members.Count}";
return false;
}

for (var index = 0; index < modelDirectory.Members.Count; index++)
{
var modelMember = modelDirectory.Members[index];
var liveMember = liveDirectory.Members[index];
var mmsMatches =
!string.IsNullOrWhiteSpace(modelMember.MmsReference) &&
!string.IsNullOrWhiteSpace(liveMember.MmsReference) &&
SameStaticReference(modelMember.MmsReference, liveMember.MmsReference);
var userMatches =
!string.IsNullOrWhiteSpace(modelMember.UserReference) &&
!string.IsNullOrWhiteSpace(liveMember.UserReference) &&
SameStaticReference(modelMember.UserReference, liveMember.UserReference);

if (mmsMatches || userMatches)
continue;

mismatch =
$"member[{index}] model={modelMember.UserReference} ({modelMember.MmsReference}), " +
$"live={liveMember.UserReference} ({liveMember.MmsReference})";
return false;
}

return true;
}

private static int StaticRcbAvailabilityRank(ArMms.MmsRcbOperationalAvailability availability)
=> availability switch
{
Expand Down
Loading
Loading