Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
14eb009
perf(reporting): add exact targeted RCB availability contract (#144)
masarray Oct 7, 2026
1cf887b
perf(reporting): cache proven non-deletable DataSet directories per a…
masarray Oct 7, 2026
356797e
perf(reporting): reset static DataSet evidence on each association (#…
masarray Oct 7, 2026
247b0b1
perf(reporting): probe only requested live RCBs and reuse static Data…
masarray Oct 7, 2026
87eb663
refactor(reporting): isolate exact targeted availability selection po…
masarray Oct 7, 2026
6159922
refactor(reporting): consume pure targeted RCB selector in network pr…
masarray Oct 7, 2026
a391e78
test(reporting): lock targeted hot-path RCB selection contract (#144)
masarray Oct 7, 2026
1513295
feat(reporting): expose hot-path RCB read budget diagnostics (#144)
masarray Oct 7, 2026
ac8a589
perf(reporting): probe only runtime-critical fields for targeted RCBs…
masarray Oct 7, 2026
f3fc041
perf(reporting): switch exact-target availability to minimal runtime …
masarray Oct 7, 2026
1c88ec7
fix(reporting): scope RCB read-budget metric to targeted mode (#144)
masarray Oct 7, 2026
dc80a98
fix(reporting): align targeted RCB read-budget result field (#144)
masarray Oct 7, 2026
55f78a3
feat(reporting): resolve canonical static coverage to exact live RCB …
masarray Oct 7, 2026
2ad8fc9
test(reporting): lock canonical-to-live targeted RCB resolution (#144)
masarray Oct 7, 2026
fee187d
feat(canonical): expose source-correct SCL workspace canonical ingres…
masarray Oct 7, 2026
0891949
test(canonical): lock source-correct SCL workspace ingress (#144)
masarray Oct 7, 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
19 changes: 19 additions & 0 deletions src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Scl.Workspace;

namespace AR.Iec61850.Engineering.Canonical;

Expand Down Expand Up @@ -34,6 +35,24 @@ public static CanonicalIedModel FromLiveDiscovery(LiveIedModelDiscoveryDocument
});
}

/// <summary>
/// Public source-correct canonical ingress for an already opened SCL IED workspace.
/// Applications should use this instead of re-labeling the workspace DesignModel as
/// live discovery merely to reuse canonical planning.
/// </summary>
public static CanonicalIedModel FromSclWorkspace(SclIedWorkspace workspace)
{
ArgumentNullException.ThrowIfNull(workspace);

return FromSclProjection(
workspace.DesignModel,
sourceName: "SCL workspace",
sourceEdition: string.Empty,
iedName: workspace.IedName,
accessPointName: workspace.AccessPointName,
domainAliases: EmptyAliases);
}

internal static CanonicalIedModel FromSclProjection(
LiveIedModelDiscoveryDocument projection,
string sourceName,
Expand Down
129 changes: 129 additions & 0 deletions src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using AR.Iec61850.Engineering.Canonical;

namespace AR.Iec61850.Mms;

public sealed class MmsCanonicalStaticRcbTargetSegment
{
public string DataSetReference { get; init; } = string.Empty;
public IReadOnlyList<string> ConfiguredReportControlReferences { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> ExactLiveReportControlReferences { get; init; } = Array.Empty<string>();
public string Resolution { get; init; } = string.Empty;
}

public sealed class MmsCanonicalStaticRcbTargetResolution
{
public IReadOnlySet<string> ExactLiveReportControlReferences { get; init; }
= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<MmsCanonicalStaticRcbTargetSegment> Segments { get; init; }
= Array.Empty<MmsCanonicalStaticRcbTargetSegment>();
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();

public bool HasTargets => ExactLiveReportControlReferences.Count > 0;
public int CoveredDataSetCount => Segments.Count;
}

/// <summary>
/// Resolves canonical configured-static coverage to concrete live RCB references without
/// inventing indexed names.
///
/// Exact live identity is accepted from either:
/// 1) an exact configured RCB reference match, or
/// 2) a live RCB whose live DatSet binding exactly matches the canonical covered DataSet
/// and whose BRCB/URCB family is declared for that segment.
///
/// If neither evidence path is available the segment remains unresolved. The caller may
/// explicitly choose a broader diagnostic probe, but this resolver never guesses a sibling
/// RCB merely because its name looks similar.
/// </summary>
public static class MmsCanonicalStaticRcbTargetResolver
{
public static MmsCanonicalStaticRcbTargetResolution Resolve(
CanonicalStaticReportCoveragePlan coverage,
MmsReportInventory liveInventory)
{
ArgumentNullException.ThrowIfNull(coverage);
ArgumentNullException.ThrowIfNull(liveInventory);

var allTargets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var segments = new List<MmsCanonicalStaticRcbTargetSegment>();
var warnings = new List<string>();

foreach (var segment in coverage.Segments)
{
if (segment.SelectedSignalReferences.Length == 0)
continue;

var canonicalDataSet = Normalize(segment.DataSetReference);
var configuredReferences = segment.ReportControls
.Select(report => Normalize(report.Reference))
.Where(reference => reference.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var configuredBufferedFamilies = segment.ReportControls
.Select(report => report.Buffered)
.Distinct()
.ToHashSet();

var exactReferenceMatches = liveInventory.ReportControls
.Where(candidate => configuredReferences.Contains(Normalize(candidate.Reference)))
.ToArray();

var exactDataSetMatches = canonicalDataSet.Length == 0
? Array.Empty<MmsReportControlCandidate>()
: liveInventory.ReportControls
.Where(candidate =>
string.Equals(
Normalize(candidate.DataSetReference),
canonicalDataSet,
StringComparison.OrdinalIgnoreCase))
.Where(candidate =>
configuredBufferedFamilies.Count == 0 ||
configuredBufferedFamilies.Contains(candidate.Buffered))
.ToArray();

var matched = exactReferenceMatches
.Concat(exactDataSetMatches)
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Reference))
.DistinctBy(candidate => Normalize(candidate.Reference), StringComparer.OrdinalIgnoreCase)
.OrderBy(candidate => candidate.Reference, StringComparer.OrdinalIgnoreCase)
.ToArray();

foreach (var candidate in matched)
allTargets.Add(candidate.Reference);

var resolution = matched.Length > 0
? exactDataSetMatches.Length > 0
? "exact-live-dataset-binding"
: "exact-configured-rcb-reference"
: "unresolved";

if (matched.Length == 0)
{
warnings.Add(
$"Canonical static DataSet {segment.DataSetReference} covers selected signal(s), but no concrete live RCB could be proven by exact reference or exact live DatSet binding. No sibling/name-family guess was made.");
}

segments.Add(new MmsCanonicalStaticRcbTargetSegment
{
DataSetReference = segment.DataSetReference,
ConfiguredReportControlReferences = configuredReferences
.OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase)
.ToArray(),
ExactLiveReportControlReferences = matched
.Select(candidate => candidate.Reference)
.ToArray(),
Resolution = resolution
});
}

return new MmsCanonicalStaticRcbTargetResolution
{
ExactLiveReportControlReferences = allTargets,
Segments = segments,
Warnings = warnings
};
}

private static string Normalize(string? value)
=> MmsRcbAvailabilityEvaluator.NormalizeReference(value);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Collections.Concurrent;

namespace AR.Iec61850.Mms;

/// <summary>
/// Association-scoped cache for immutable configured DataSet directories.
///
/// Only successfully read non-deletable DataSets are cached. Dynamic/deletable or
/// unknown-mutability DataSets are never cached because their membership can change.
/// The cache is cleared on every successful MMS association.
/// </summary>
public sealed partial class MmsClientSession
{
private readonly ConcurrentDictionary<string, MmsDataSetDirectoryResult> _configuredStaticDataSetDirectoryEvidence =
new(StringComparer.OrdinalIgnoreCase);

private void ResetConfiguredStaticDataSetDirectoryEvidence()
=> _configuredStaticDataSetDirectoryEvidence.Clear();

private async Task<(MmsDataSetDirectoryResult Result, bool CacheHit)> GetAvailabilityDataSetDirectoryAsync(
string dataSetReference,
MmsIedModelDirectory? directory,
CancellationToken cancellationToken)
{
var key = MmsRcbAvailabilityEvaluator.NormalizeReference(dataSetReference);
if (key.Length > 0 &&
_configuredStaticDataSetDirectoryEvidence.TryGetValue(key, out var cached))
{
return (cached, true);
}

var result = await GetDataSetDirectoryAsync(
dataSetReference,
directory,
cancellationToken).ConfigureAwait(false);

if (key.Length > 0 &&
result.IsSuccess &&
result.IsDeletable == false)
{
_configuredStaticDataSetDirectoryEvidence[key] = result;
}

return (result, false);
}
}
65 changes: 45 additions & 20 deletions src/AR.Iec61850/Mms/MmsClientSession.RcbAvailability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,47 +16,65 @@ public async Task<MmsRcbAvailabilityResult> CheckReportControlAvailabilityAsync(
var warnings = new List<string>();
var snapshots = new List<MmsRcbAvailabilitySnapshot>();
var dataSetDirectories = new Dictionary<string, MmsDataSetDirectoryResult>(StringComparer.OrdinalIgnoreCase);
var rcbStateLogicalReads = 0;
var dataSetDirectoryNetworkReads = 0;
var dataSetDirectoryCacheHits = 0;

var callerOwned = options.CallerOwnedRcbReferences
.Select(MmsRcbAvailabilityEvaluator.NormalizeReference)
.Where(reference => reference.Length > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var max = Math.Clamp(options.MaxReportControls, 1, 4096);
var candidates = inventory.ReportControls
.OrderByDescending(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference))
.ThenByDescending(candidate => candidate.Buffered)
.ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase)
.ThenBy(candidate => candidate.LogicalNode, StringComparer.OrdinalIgnoreCase)
.ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase)
.Take(max)
.ToArray();

if (inventory.ReportControls.Count > candidates.Length)
warnings.Add($"Availability check was bounded to {candidates.Length} of {inventory.ReportControls.Count} discovered RCBs.");

foreach (var source in candidates)

var selection = MmsRcbAvailabilityTargetSelector.Select(inventory, options);
warnings.AddRange(selection.Warnings);

foreach (var source in selection.Candidates)
{
cancellationToken.ThrowIfCancellationRequested();
var candidate = CloneReportControl(source);

// Preserve discovery/SCL-derived binding only as fallback evidence. Clear the
// candidate before the forced live probe so a successful empty DatSet read can
// be distinguished from a failed read that merely left an old value in memory.
// Preserve discovery/SCL-derived binding only as fallback evidence. Volatile
// runtime state is cleared before every live check so stale discovery values
// can never masquerade as just-in-time availability evidence.
var previouslyKnownDataSetReference = candidate.DataSetReference;
candidate.DataSetReference = string.Empty;
candidate.DataSetProbeState = MmsRcbDataSetProbeState.NotAttempted;
candidate.DataSetProbeMessage = string.Empty;
candidate.EnabledState = string.Empty;
candidate.ReservationState = string.Empty;
candidate.ReservationTimeSeconds = string.Empty;
candidate.Owner = string.Empty;

if (selection.TargetFilterApplied)
{
rcbStateLogicalReads += await ProbeTargetedReportControlAvailabilityAsync(
candidate,
cancellationToken).ConfigureAwait(false);
}
else
{
await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false);
// Broad diagnostic mode intentionally retains full attribute/Owner evidence.
await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false);
}

await ProbeReportControlAttributesAsync(candidate, cancellationToken).ConfigureAwait(false);
CaptureDataSetProbeEvidence(candidate, previouslyKnownDataSetReference);
await ProbeOwnerReadOnlyAsync(candidate, cancellationToken).ConfigureAwait(false);

MmsDataSetDirectoryResult? dataSetDirectory = null;
var dataSetReference = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference);
if (options.ReadDataSetDirectories && dataSetReference.Length > 0)
{
if (!dataSetDirectories.TryGetValue(dataSetReference, out dataSetDirectory))
{
dataSetDirectory = await GetDataSetDirectoryAsync(candidate.DataSetReference, directory, cancellationToken).ConfigureAwait(false);
var directoryEvidence = await GetAvailabilityDataSetDirectoryAsync(
candidate.DataSetReference,
directory,
cancellationToken).ConfigureAwait(false);
dataSetDirectory = directoryEvidence.Result;
if (directoryEvidence.CacheHit)
dataSetDirectoryCacheHits++;
else
dataSetDirectoryNetworkReads++;
dataSetDirectories[dataSetReference] = dataSetDirectory;
}
}
Expand All @@ -71,6 +89,13 @@ public async Task<MmsRcbAvailabilityResult> CheckReportControlAvailabilityAsync(
return new MmsRcbAvailabilityResult
{
CheckedAtUtc = checkedAt,
InventoryReportControlCount = inventory.ReportControls.Count,
TargetFilterApplied = selection.TargetFilterApplied,
RequestedTargetReportControlCount = selection.RequestedTargetCount,
MatchedTargetReportControlCount = selection.MatchedTargetCount,
TargetedRcbStateLogicalReadCount = rcbStateLogicalReads,
DataSetDirectoryNetworkReadCount = dataSetDirectoryNetworkReads,
DataSetDirectoryCacheHitCount = dataSetDirectoryCacheHits,
ReportControls = snapshots
.OrderByDescending(item => item.DataSetMemberCount > 0)
.ThenBy(item => AvailabilityRank(item.Availability))
Expand Down
86 changes: 86 additions & 0 deletions src/AR.Iec61850/Mms/MmsClientSession.TargetedRcbAvailability.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
namespace AR.Iec61850.Mms;

/// <summary>
/// Minimal live-state probe used only after an exact RCB target has already been chosen.
///
/// The broad diagnostic probe reads many presentation/configuration attributes. The runtime
/// hot path instead tries one complete RCB structure read, then fills only missing DatSet,
/// RptEna, reservation and Owner evidence required for safe availability decisions.
/// </summary>
public sealed partial class MmsClientSession
{
private async Task<int> ProbeTargetedReportControlAvailabilityAsync(
MmsReportControlCandidate reportControl,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(reportControl);

var logicalReads = 0;

// Prefer one complete RCB structure read. On interoperable peers this provides
// DatSet + RptEna + reservation state in one confirmed service round trip.
await TryReadReportControlStructureAsync(reportControl, cancellationToken).ConfigureAwait(false);
logicalReads++;

if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.EnabledState) is null)
{
await ProbeReportControlAttributeAsync(
reportControl,
"RptEna",
value => reportControl.EnabledState = NormalizeReportAttributeText(value),
cancellationToken).ConfigureAwait(false);
logicalReads++;
}

if (string.IsNullOrWhiteSpace(reportControl.DataSetReference))
{
await ProbeReportControlAttributeAsync(
reportControl,
"DatSet",
value =>
{
var text = NormalizeReportAttributeText(value);
reportControl.DataSetReference = string.IsNullOrWhiteSpace(text)
? string.Empty
: NormalizeReportedDataSetReference(reportControl.Domain, text);
},
cancellationToken).ConfigureAwait(false);
logicalReads++;
}

if (reportControl.Buffered)
{
if (MmsRcbAvailabilityEvaluator.ParseUnsigned(reportControl.ReservationTimeSeconds) is null &&
reportControl.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase))
{
await ProbeReportControlAttributeAsync(
reportControl,
"ResvTms",
value => reportControl.ReservationTimeSeconds = NormalizeReportAttributeText(value),
cancellationToken).ConfigureAwait(false);
logicalReads++;
}
}
else if (MmsRcbAvailabilityEvaluator.ParseBool(reportControl.ReservationState) is null &&
reportControl.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase))
{
await ProbeReportControlAttributeAsync(
reportControl,
"Resv",
value => reportControl.ReservationState = NormalizeReportAttributeText(value),
cancellationToken).ConfigureAwait(false);
logicalReads++;
}

// Owner is outside the base structure on many devices. If exposed, one exact
// read is cheaper and safer than inferring ownership from missing metadata.
if (reportControl.Attributes.Contains("Owner", StringComparer.OrdinalIgnoreCase))
{
await ProbeOwnerReadOnlyAsync(reportControl, cancellationToken).ConfigureAwait(false);
logicalReads++;
}

reportControl.Status = "Targeted-availability-probed";
return logicalReads;
}
}
Loading