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
4 changes: 4 additions & 0 deletions src/AR.Iec61850/Mms/MmsRcbAvailability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ public sealed class MmsRcbAvailabilitySnapshot
public string Owner { get; init; } = string.Empty;
public bool DataSetDirectoryRead { get; init; }
public bool DataSetDirectorySuccess { get; init; }
public bool? DataSetIsDeletable { get; init; }
public int DataSetMemberCount { get; init; }
public IReadOnlyList<MmsDataSetDirectoryMember> DataSetMembers { get; init; } = Array.Empty<MmsDataSetDirectoryMember>();
public MmsRcbOperationalAvailability Availability { get; init; }
public MmsRcbAvailabilityConfidence Confidence { get; init; } = MmsRcbAvailabilityConfidence.Unknown;
public string Reason { get; init; } = string.Empty;
Expand Down Expand Up @@ -161,7 +163,9 @@ public static MmsRcbAvailabilitySnapshot Evaluate(
Owner = candidate.Owner,
DataSetDirectoryRead = directoryRead,
DataSetDirectorySuccess = directorySuccess,
DataSetIsDeletable = dataSetDirectory?.IsDeletable,
DataSetMemberCount = memberCount,
DataSetMembers = dataSetDirectory?.Members.ToArray() ?? Array.Empty<MmsDataSetDirectoryMember>(),
Availability = availability,
Confidence = confidence,
Reason = reason,
Expand Down
129 changes: 129 additions & 0 deletions src/AR.Iec61850/Scl/Export/LiveRcbDataSetEvidenceMerger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Mms;

namespace AR.Iec61850.Scl.Export;

/// <summary>
/// Reconciles a live discovery model with the exact DataSet directory read during
/// the passive RCB availability check. Some IEDs expose a DataSet count during the
/// initial scan but only return the FCDA member references through an explicit
/// GetDataSetDirectory request. The source model is never mutated.
/// </summary>
public static class LiveRcbDataSetEvidenceMerger
{
public static LiveIedModelDiscoveryDocument MergeSelectedDataSetDirectory(
LiveIedModelDiscoveryDocument source,
string selectedReportControlReference,
MmsRcbAvailabilityResult availability)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentException.ThrowIfNullOrWhiteSpace(selectedReportControlReference);
ArgumentNullException.ThrowIfNull(availability);

var selectedReference = Normalize(selectedReportControlReference);
var selected = source.ReportControls.FirstOrDefault(control =>
Normalize(control.Reference).Equals(selectedReference, StringComparison.OrdinalIgnoreCase));
if (selected is null)
throw new InvalidOperationException($"ReportControl '{selectedReportControlReference}' was not found in the live model.");

var evidence = availability.ReportControls.FirstOrDefault(snapshot =>
Normalize(snapshot.Reference).Equals(selectedReference, StringComparison.OrdinalIgnoreCase));
if (evidence is null)
throw new InvalidOperationException(
$"No live DataSet directory evidence is available for '{selectedReportControlReference}'. Run Check Availability before exporting this RCB.");
if (!evidence.DataSetDirectorySuccess)
throw new InvalidOperationException(
$"The DataSet directory for '{selectedReportControlReference}' was not read successfully. Run Check Availability again before export.");
if (evidence.DataSetMembers.Count == 0)
throw new InvalidOperationException(
$"The live DataSet directory for '{selectedReportControlReference}' contains no exportable members.");

var dataSetReference = FirstNonEmpty(evidence.DataSetReference, selected.DataSetReference);
var normalizedDataSet = Normalize(dataSetReference);
var sourceDataSet = source.DataSets.FirstOrDefault(dataSet =>
Normalize(dataSet.Reference).Equals(normalizedDataSet, StringComparison.OrdinalIgnoreCase));
if (sourceDataSet is null)
throw new InvalidOperationException(
$"The live model does not contain DataSet '{dataSetReference}' referenced by '{selectedReportControlReference}'.");

var members = evidence.DataSetMembers
.Select((member, index) => new LiveIedDataSetMemberModel
{
Index = index,
Reference = FirstNonEmpty(member.UserReference, member.MmsReference),
FunctionalConstraint = member.FunctionalConstraint,
MmsReference = member.MmsReference,
Confidence = member.Confidence >= 90
? LiveIedDiscoveryConfidenceLevel.Exact
: LiveIedDiscoveryConfidenceLevel.High
})
.Where(member => !string.IsNullOrWhiteSpace(member.Reference))
.GroupBy(
member => $"{Normalize(member.Reference)}|{member.FunctionalConstraint}",
StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.Select((member, index) => new LiveIedDataSetMemberModel
{
Index = index,
Reference = member.Reference,
FunctionalConstraint = member.FunctionalConstraint,
MmsReference = member.MmsReference,
Confidence = member.Confidence
})
.ToArray();
if (members.Length == 0)
throw new InvalidOperationException(
$"The live DataSet directory for '{selectedReportControlReference}' did not contain usable member references.");

var mergedDataSet = new LiveIedDataSetModel
{
Reference = sourceDataSet.Reference,
Domain = sourceDataSet.Domain,
LogicalNode = sourceDataSet.LogicalNode,
Name = sourceDataSet.Name,
IsDeletable = evidence.DataSetIsDeletable ?? sourceDataSet.IsDeletable,
MemberCount = members.Length,
Members = members,
UsedByReportControls = sourceDataSet.UsedByReportControls,
UsedByGooseControls = sourceDataSet.UsedByGooseControls,
UsedBySampledValueControls = sourceDataSet.UsedBySampledValueControls
};

var mergedDataSets = source.DataSets
.Select(dataSet => Normalize(dataSet.Reference).Equals(normalizedDataSet, StringComparison.OrdinalIgnoreCase)
? mergedDataSet
: dataSet)
.ToArray();

return new LiveIedModelDiscoveryDocument
{
SchemaVersion = source.SchemaVersion,
GeneratedAtUtc = source.GeneratedAtUtc,
Source = source.Source,
Host = source.Host,
Port = source.Port,
IedName = source.IedName,
IedIdentity = source.IedIdentity,
AccessPointName = source.AccessPointName,
Summary = $"{source.Summary} Live DataSet directory evidence merged for selected-RCB export.",
Coverage = source.Coverage,
LogicalDevices = source.LogicalDevices,
FileDirectory = source.FileDirectory,
DataSets = mergedDataSets,
ReportControls = source.ReportControls,
GooseControlBlocks = source.GooseControlBlocks,
SampledValueControlBlocks = source.SampledValueControlBlocks,
SettingGroupControls = source.SettingGroupControls,
LogControls = source.LogControls,
TypeTemplates = source.TypeTemplates,
VariableTypeDiscoveries = source.VariableTypeDiscoveries,
Warnings = source.Warnings
};
}

private static string Normalize(string? reference)
=> (reference ?? string.Empty).Trim().Replace('$', '.');

private static string FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
}
118 changes: 118 additions & 0 deletions tests/AR.Iec61850.Tests/Scl/LiveRcbDataSetEvidenceMergerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Mms;
using AR.Iec61850.Scl.Export;

namespace AR.Iec61850.Tests.Scl;

public sealed class LiveRcbDataSetEvidenceMergerTests
{
[Fact]
public void MergeSelectedDataSetDirectory_Replaces_Incomplete_Members_Without_Mutating_Source()
{
var dataSet = new LiveIedDataSetModel
{
Reference = "IED1LD0/LLN0.DataSet",
Domain = "IED1LD0",
LogicalNode = "LLN0",
Name = "DataSet",
MemberCount = 1,
Members = Array.Empty<LiveIedDataSetMemberModel>()
};
var reportControl = new LiveIedReportControlModel
{
Reference = "IED1LD0/LLN0.RP.URCB01",
Domain = "IED1LD0",
LogicalNode = "LLN0",
Name = "URCB01",
Buffered = false,
DataSetReference = dataSet.Reference
};
var source = new LiveIedModelDiscoveryDocument
{
IedName = "IED1",
DataSets = new[] { dataSet },
ReportControls = new[] { reportControl },
Coverage = new LiveIedModelDiscoveryCoverage
{
DataSetCount = 1,
ReportControlCount = 1,
UnbufferedReportControlCount = 1
}
};
var availability = new MmsRcbAvailabilityResult
{
ReportControls = new[]
{
new MmsRcbAvailabilitySnapshot
{
Reference = reportControl.Reference,
DataSetReference = dataSet.Reference,
DataSetDirectoryRead = true,
DataSetDirectorySuccess = true,
DataSetMemberCount = 1,
DataSetMembers = new[]
{
new MmsDataSetDirectoryMember
{
Domain = "IED1LD0",
MmsItemName = "XCBR1$ST$Pos$stVal",
UserReference = "IED1LD0/XCBR1.Pos.stVal",
FunctionalConstraint = "ST"
}
}
}
}
};

var merged = LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory(
source,
reportControl.Reference,
availability);
var filtered = SclReportControlFilter.FilterLiveModel(merged, reportControl.Reference);

var mergedDataSet = Assert.Single(filtered.DataSets);
var member = Assert.Single(mergedDataSet.Members);
Assert.Equal("IED1LD0/XCBR1.Pos.stVal", member.Reference);
Assert.Equal("ST", member.FunctionalConstraint);
Assert.Empty(source.DataSets[0].Members);
Assert.Single(filtered.ReportControls);
}

[Fact]
public void MergeSelectedDataSetDirectory_Requires_Check_Availability_Evidence()
{
var source = new LiveIedModelDiscoveryDocument
{
DataSets = new[]
{
new LiveIedDataSetModel
{
Reference = "IED1LD0/LLN0.DataSet",
Domain = "IED1LD0",
LogicalNode = "LLN0",
Name = "DataSet",
MemberCount = 1
}
},
ReportControls = new[]
{
new LiveIedReportControlModel
{
Reference = "IED1LD0/LLN0.RP.URCB01",
Domain = "IED1LD0",
LogicalNode = "LLN0",
Name = "URCB01",
DataSetReference = "IED1LD0/LLN0.DataSet"
}
}
};

var error = Assert.Throws<InvalidOperationException>(() =>
LiveRcbDataSetEvidenceMerger.MergeSelectedDataSetDirectory(
source,
"IED1LD0/LLN0.RP.URCB01",
new MmsRcbAvailabilityResult()));

Assert.Contains("Run Check Availability", error.Message, StringComparison.OrdinalIgnoreCase);
}
}
Loading