Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 40 additions & 0 deletions MainWindow.DataSetSignalInventory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ArIED61850Tester;

public partial class MainWindow
{
internal void RegisterRecoveredDataSetSignals(
Iec61850MonitorDevice device,
Iec61850DataSetSignalInventoryMergeResult merge)
{
ArgumentNullException.ThrowIfNull(device);
ArgumentNullException.ThrowIfNull(merge);

foreach (var signal in merge.AddedSignals)
{
// The wizard can recover a row after the normal discovery collection has
// already been registered with MainWindow. Bring that row under the same
// application lifecycle without changing its user-selection state.
signal.PropertyChanged -= Signal_PropertyChanged;
signal.PropertyChanged += Signal_PropertyChanged;
_signalOwners[signal] = device;
}

if (merge.AddedCount == 0 && merge.EnrichedExistingCount == 0)
return;

device.RecountSelectedSignals();
device.RefreshComputed();
RaiseWorkspaceCounts();

if (merge.AddedCount > 0)
{
AddLog(
"INFO",
device.Name,
$"ARIEC DataSet authority restored {merge.AddedCount} mandatory primary signal(s) to the selection inventory; user selection was not changed.");
}
}
}
217 changes: 217 additions & 0 deletions Services/Iec61850DataSetSignalInventoryService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

public sealed record Iec61850DataSetSignalInventoryMergeResult(
IReadOnlyList<SignalDefinition> AddedSignals,
int EnrichedExistingCount,
int MandatoryCatalogCount)
{
public int AddedCount => AddedSignals.Count;
}

/// <summary>
/// Application-side projection of ARIEC-owned DataSet signal authority.
///
/// ARIEC decides which IEC 61850 signals are mandatory primary DataSet members.
/// ARSAS only guarantees that those already-classified engine signals are present in
/// the user-visible signal inventory. No IEC reference guessing, fuzzy matching, or
/// DataSet semantic inference is performed here.
/// </summary>
public static class Iec61850DataSetSignalInventoryService
{
public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals(
Iec61850MonitorDevice device)
{
ArgumentNullException.ThrowIfNull(device);

if (device.LiveDiscoveryModel is null)
return new Iec61850DataSetSignalInventoryMergeResult(Array.Empty<SignalDefinition>(), 0, 0);

var catalog = Iec61850SignalCatalogBuilder.Build(device.LiveDiscoveryModel);
var mandatory = catalog.GetMandatoryPrimarySignals();
if (mandatory.Count == 0)
return new Iec61850DataSetSignalInventoryMergeResult(Array.Empty<SignalDefinition>(), 0, 0);

// Keep application matching literal. The engine owns IEC 61850 reference
// canonicalization; ARSAS only compares the reference forms that the engine has
// already exposed on the descriptor.
var existing = device.Signals
.Where(signal => !string.IsNullOrWhiteSpace(signal.ObjectReference))
.GroupBy(signal => LiteralReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);

var added = new List<SignalDefinition>();
var enriched = 0;

foreach (var descriptor in mandatory)
{
var engineReferences = EngineReferenceCandidates(descriptor).ToArray();
var current = engineReferences
.Select(reference => existing.TryGetValue(reference, out var signal) ? signal : null)
.FirstOrDefault(signal => signal is not null);

if (current is not null)
{
if (ApplyEngineDataSetAuthority(current, descriptor))
enriched++;
continue;
}

var reference = FirstNonEmpty(
descriptor.PrimaryValueReference,
descriptor.DesignReference,
descriptor.ObservedReference);
if (string.IsNullOrWhiteSpace(reference))
continue;

var signal = CreateSignal(descriptor, reference);
device.Signals.Add(signal);
foreach (var key in EngineReferenceCandidates(descriptor).Append(LiteralReference(reference)))
existing.TryAdd(key, signal);
added.Add(signal);
}

return new Iec61850DataSetSignalInventoryMergeResult(added, enriched, mandatory.Count);
}

private static SignalDefinition CreateSignal(
Iec61850SignalDescriptor descriptor,
string reference)
{
var primaryMembership = FirstMembership(descriptor);
var report = descriptor.ReportMemberships.FirstOrDefault();
var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown");
return new SignalDefinition
{
Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, reference),
ObjectReference = reference,
FunctionalConstraint = descriptor.FunctionalConstraint,
DataType = dataType,
Category = "DataSet",
Confidence = "High",
DataSetReference = primaryMembership?.DataSetReference ?? string.Empty,
ReportControlReference = report?.ReportControlReference ?? string.Empty,
QualityReference = descriptor.QualityReference,
TimestampReference = descriptor.TimestampReference,
Source = "ARIEC61850 signal catalog • mandatory static DataSet member",
IsSelected = false,
IsReportCapable = true,
ReportCoverage = report is null
? "Static DataSet member • MMS polling fallback"
: "Static report/DataSet • polling fallback",
ReportCoverageReason = BuildCoverageReason(descriptor),
ProbeStatus = "Not probed",
Value = "-",
Quality = "Unknown",
DeviceTimestamp = "-"
};
}

private static bool ApplyEngineDataSetAuthority(
SignalDefinition signal,
Iec61850SignalDescriptor descriptor)
{
var changed = false;
var membership = FirstMembership(descriptor);
var report = descriptor.ReportMemberships.FirstOrDefault();

if (membership is not null &&
!string.Equals(signal.DataSetReference, membership.DataSetReference, StringComparison.OrdinalIgnoreCase))
{
signal.DataSetReference = membership.DataSetReference;
changed = true;
Comment on lines +120 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve a consistent DataSet and RCB membership pair

When a signal belongs to multiple DataSets, this unconditionally replaces an existing DataSetReference with the alphabetically first membership, while ApplyEngineDataSetAuthority preserves any nonempty ReportControlReference. A previously valid DataSet/RCB pair can therefore become an RCB for one DataSet paired with another DataSet, and the report planner will build a static plan from that mismatched pair. Select a membership associated with the chosen RCB, or retain an existing authoritative pair when both references are already populated.

Useful? React with 👍 / 👎.

}

if (report is not null && string.IsNullOrWhiteSpace(signal.ReportControlReference))
{
signal.ReportControlReference = report.ReportControlReference;
changed = true;
}

if (!signal.IsReportCapable)
{
signal.IsReportCapable = true;
changed = true;
}

if ((string.IsNullOrWhiteSpace(signal.ReportCoverage) ||
signal.ReportCoverage.Equals("Polling fallback", StringComparison.OrdinalIgnoreCase)) &&
(membership is not null || report is not null))
{
signal.ReportCoverage = report is null
? "Static DataSet member • MMS polling fallback"
: "Static report/DataSet • polling fallback";
changed = true;
}

var reason = BuildCoverageReason(descriptor);
if (!string.Equals(signal.ReportCoverageReason, reason, StringComparison.Ordinal))
{
signal.ReportCoverageReason = reason;
changed = true;
}

if (string.IsNullOrWhiteSpace(signal.QualityReference) && !string.IsNullOrWhiteSpace(descriptor.QualityReference))
{
signal.QualityReference = descriptor.QualityReference;
changed = true;
}

if (string.IsNullOrWhiteSpace(signal.TimestampReference) && !string.IsNullOrWhiteSpace(descriptor.TimestampReference))
{
signal.TimestampReference = descriptor.TimestampReference;
changed = true;
}

return changed;
}

private static IEnumerable<string> EngineReferenceCandidates(Iec61850SignalDescriptor descriptor)
{
var values = new[]
{
descriptor.PrimaryValueReference,
descriptor.DesignReference,
descriptor.ObservedReference,
descriptor.PrimaryValueMmsReference,
descriptor.CanonicalMmsReference,
descriptor.EffectiveMmsReference,
descriptor.ObservedMmsReference
};

return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(LiteralReference)
.Distinct(StringComparer.OrdinalIgnoreCase);
}

private static Iec61850SignalDataSetMembership? FirstMembership(Iec61850SignalDescriptor descriptor)
=> descriptor.DataSetMemberships
.OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase)
.ThenBy(membership => membership.MemberIndex)
.FirstOrDefault();

private static string BuildCoverageReason(Iec61850SignalDescriptor descriptor)
{
var memberships = descriptor.DataSetMemberships
.OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase)
.ThenBy(membership => membership.MemberIndex)
.Select(membership => $"{membership.DataSetReference}[{membership.MemberIndex}]")
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var membershipText = memberships.Length == 0
? "static DataSet membership"
: string.Join(", ", memberships);

return $"ARIEC61850 mandatory primary DataSet signal: {membershipText}. " +
"Inventory presence is engine-authoritative; user selection remains independent.";
}

private static string LiteralReference(string? reference)
=> (reference ?? string.Empty).Trim();

private static string FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
}
32 changes: 32 additions & 0 deletions SignalSelectionWizardWindow.DataSetAuthority.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Windows;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ArIED61850Tester;

public partial class SignalSelectionWizardWindow
{
protected override void OnInitialized(EventArgs e)
{
var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(_device);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Merge mandatory signals before gating the wizard

Running recovery only from OnInitialized is too late for the flows that need it: ConnectAndConfigureDeviceAsync opens the wizard only when device.SignalCount > 0, and OpenSignalSelectionWizardAsync also returns early for an empty inventory, so an IED whose mandatory DataSet signals are all removed by normal curation can never reach this call. The Connect All path additionally performs discovery with openWizard: false, so it never recovers these signals or restores their saved selections. Merge the catalog into the inventory immediately after discovery and before selection restoration and zero-count checks.

Useful? React with 👍 / 👎.

foreach (var signal in merge.AddedSignals)
{
signal.DisplayReference = Iec61850MonitorPoint.StripIedNamePrefix(signal.ObjectReference, _device.Name);
signal.PropertyChanged += Signal_PropertyChanged;
}

// Window initialization can run inside InitializeComponent(), before the caller's
// object initializer assigns Owner. Register recovered rows through the actual
// application MainWindow so they receive the same owner/property-change lifecycle
// as rows produced by the normal discovery pipeline.
if (Application.Current?.MainWindow is MainWindow mainWindow)
mainWindow.RegisterRecoveredDataSetSignals(_device, merge);
else
{
_device.RecountSelectedSignals();
_device.RefreshComputed();
}

base.OnInitialized(e);
}
}
Loading
Loading