Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
1a9806c
fix(reporting): add typed RCB scalar mutation contract (#144)
masarray Oct 7, 2026
eef0614
fix(reporting): route RCB scalar writes through semantic contract (#144)
masarray Oct 7, 2026
18f8ae2
fix(reporting): route RCB scalar writes through semantic contract (#144)
masarray Oct 7, 2026
1bd8813
fix(reporting): route RCB scalar writes through semantic contract (#144)
masarray Oct 7, 2026
7fd2fa6
test(reporting): lock RCB semantic wire types (#144)
masarray Oct 7, 2026
6f66dd9
fix(reporting): complete scalar semantic write migration (#144)
masarray Oct 7, 2026
23a4b45
fix(reporting): complete scalar semantic write migration (#144)
masarray Oct 7, 2026
488e678
fix(reporting): keep ResvTms codec semantic-neutral within INT16 (#144)
masarray Oct 7, 2026
cebd6b2
test(reporting): cover full signed ResvTms INT16 representation (#144)
masarray Oct 7, 2026
5d301be
refactor(reporting): converge SCL and discovery on configured static …
masarray Oct 7, 2026
20c7b40
test(reporting): lock source-neutral configured static entry point (#…
masarray Oct 7, 2026
c5989b0
feat(reporting): add bounded live type evidence for RCB scalar mutati…
masarray Oct 7, 2026
338cd4f
feat(reporting): preserve typed write failure evidence (#144)
masarray Oct 7, 2026
794ebea
fix(reporting): scope semantic type evidence to one association (#144)
masarray Oct 7, 2026
c386bb8
test(reporting): distinguish missing from conflicting type evidence (…
masarray Oct 7, 2026
3e94749
fix(reporting): preserve exact-case identity in type-evidence cache (…
masarray Oct 7, 2026
34964f5
feat(mms): classify write failures from structured DataAccessError ev…
masarray Oct 7, 2026
fbedf54
feat(reporting): carry structured interoperability failure evidence (…
masarray Oct 7, 2026
b201497
refactor(reporting): drive type recovery from structured failure clas…
masarray Oct 7, 2026
6b81b9c
test(mms): lock structured interoperability recovery classification (…
masarray Oct 7, 2026
4e6b521
feat(canonical): add source-neutral static report coverage resolver (…
masarray Oct 7, 2026
13b84fe
test(canonical): prove source-neutral static coverage and ordered Dat…
masarray Oct 7, 2026
ac73e5e
chore(canonical): keep static coverage resolver allocation-minimal (#…
masarray Oct 7, 2026
9cfc1ae
fix(canonical): converge SCL and live configured reference syntax in …
masarray Oct 7, 2026
c8addff
test(canonical): prove Discovery and Open-SCL builder coverage conver…
masarray Oct 7, 2026
fab541d
fix(canonical): repair configured reference normalizer syntax (#144)
masarray Oct 7, 2026
8f8b024
feat(reporting): model reduced configured-static RCB eligibility (#144)
masarray Oct 7, 2026
c04c07b
feat(reporting): gate reduced configured-static eligibility explicitl…
masarray Oct 7, 2026
beda723
fix(reporting): preserve existing reduced-confidence explicit-free se…
masarray Oct 7, 2026
ba392fe
fix(reporting): rank explicit-free static eligibility without changin…
masarray Oct 7, 2026
6bdb6b7
test(reporting): lock missing-vs-blocking static RCB evidence policy …
masarray Oct 7, 2026
e1d4d34
test(reporting): prove reduced static policy is explicit and static-f…
masarray Oct 7, 2026
70e6e9e
test(reporting): keep reduced static interoperability disabled by def…
masarray Oct 7, 2026
4cc9b49
fix(reporting): route fresh static hybrid plans through configured li…
masarray Oct 7, 2026
4a915bf
feat(diagnostics): surface typed RCB mutation failure evidence (#144)
masarray Oct 7, 2026
45e8855
test(diagnostics): lock typed RCB interoperability counters (#144)
masarray Oct 7, 2026
815d226
feat(reporting): classify static activation failures from typed evide…
masarray Oct 7, 2026
706ad53
refactor(canonical): normalize configured DataSet and RCB identity at…
masarray Oct 7, 2026
18d0ac1
test(canonical): require configured identity equality across Discover…
masarray Oct 7, 2026
302f788
fix(canonical): repair configured-reference ingress normalizer after …
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
17 changes: 14 additions & 3 deletions src/AR.Iec61850/Engineering/Canonical/CanonicalLiveModelAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ private static CanonicalIedModel Build(

dataSets[i] = new CanonicalDataSet
{
Reference = RemapReference(dataSet.Reference, context.DomainAliases),
Reference = NormalizeConfiguredReference(dataSet.Reference, context.DomainAliases),
MmsDomain = RemapDomain(dataSet.Domain, context.DomainAliases),
LogicalNode = dataSet.LogicalNode,
Name = dataSet.Name,
Expand All @@ -167,12 +167,12 @@ private static CanonicalIedModel Build(
var report = source.ReportControls[i];
reportControls[i] = new CanonicalReportControl
{
Reference = RemapReference(report.Reference, context.DomainAliases),
Reference = NormalizeConfiguredReference(report.Reference, context.DomainAliases),
MmsDomain = RemapDomain(report.Domain, context.DomainAliases),
LogicalNode = report.LogicalNode,
Name = report.Name,
Buffered = report.Buffered,
DataSetReference = RemapReference(report.DataSetReference, context.DomainAliases),
DataSetReference = NormalizeConfiguredReference(report.DataSetReference, context.DomainAliases),
ReportId = report.ReportId,
ConfRev = report.ConfRev,
TriggerOptions = report.TriggerOptions,
Expand Down Expand Up @@ -232,6 +232,17 @@ private static CanonicalIedModel Build(
};
}

/// <summary>
/// Canonical DataSet/ReportControl identity uses one source-neutral dotted form.
/// SCL commonly carries '$' MMS component separators while live discovery may expose
/// the same configured resource with dots. Source projections retain their original
/// spelling; the shared CanonicalIedModel does not.
/// </summary>
private static string NormalizeConfiguredReference(
string value,
IReadOnlyDictionary<string, string> aliases)
=> RemapReference(value, aliases).Replace('$', '.');

private static string RemapDomain(string value, IReadOnlyDictionary<string, string> aliases)
{
var normalized = value?.Trim() ?? string.Empty;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
namespace AR.Iec61850.Engineering.Canonical;

public enum CanonicalStaticReportSignalCoverageStatus
{
Covered,
SignalNotFound,
AmbiguousSignalIdentity,
NoStaticDataSetMembership,
StaticDataSetWithoutReportControl
}

public sealed class CanonicalStaticReportSelection
{
public string Reference { get; init; } = string.Empty;
public string FunctionalConstraint { get; init; } = string.Empty;
}

public sealed class CanonicalStaticReportDataSetCandidate
{
public string DataSetReference { get; init; } = string.Empty;
public string SourceDataSetReference { get; init; } = string.Empty;
public int[] MemberIndexes { get; init; } = Array.Empty<int>();
public string[] ReportControlReferences { get; init; } = Array.Empty<string>();
}

public sealed class CanonicalStaticReportSignalCoverage
{
public string RequestedReference { get; init; } = string.Empty;
public string RequestedFunctionalConstraint { get; init; } = string.Empty;
public string CanonicalReference { get; init; } = string.Empty;
public string CanonicalFunctionalConstraint { get; init; } = string.Empty;
public CanonicalStaticReportSignalCoverageStatus Status { get; init; }
public CanonicalStaticReportDataSetCandidate[] DataSetCandidates { get; init; } = Array.Empty<CanonicalStaticReportDataSetCandidate>();

public bool IsCovered => Status == CanonicalStaticReportSignalCoverageStatus.Covered;
}

public sealed class CanonicalStaticReportCoverageSegment
{
public string DataSetReference { get; init; } = string.Empty;
public string SourceDataSetReference { get; init; } = string.Empty;
public CanonicalDataSetMember[] OrderedMembers { get; init; } = Array.Empty<CanonicalDataSetMember>();
public CanonicalReportControl[] ReportControls { get; init; } = Array.Empty<CanonicalReportControl>();
public string[] SelectedSignalReferences { get; init; } = Array.Empty<string>();
}

public sealed class CanonicalStaticReportCoveragePlan
{
public CanonicalIngressKind Ingress { get; init; }
public CanonicalStaticReportSignalCoverage[] Signals { get; init; } = Array.Empty<CanonicalStaticReportSignalCoverage>();
public CanonicalStaticReportCoverageSegment[] Segments { get; init; } = Array.Empty<CanonicalStaticReportCoverageSegment>();

public int RequestedSignalCount => Signals.Length;
public int CoveredSignalCount => Signals.Count(signal => signal.IsCovered);
public int UncoveredSignalCount => RequestedSignalCount - CoveredSignalCount;
}

/// <summary>
/// Source-neutral static-report coverage over the shared CanonicalIedModel.
///
/// This resolver never chooses an operational RCB instance and performs no IED writes.
/// It answers one question only: which configured static DataSet/ReportControl resources
/// already cover each exact canonical signal? Discovery and Open-SCL therefore enter the
/// same coverage logic after canonicalization.
///
/// Structured DataSet members are handled by exact reference-boundary containment. A
/// member such as A.phsA covers its typed descendants but never sibling phases. Full
/// ordered DataSet membership is retained in every segment because InformationReport
/// projection remains positional even when the application selected only a subset.
/// </summary>
public static class CanonicalStaticReportCoverageResolver
{
public static CanonicalStaticReportCoveragePlan Resolve(
CanonicalIedModel model,
IEnumerable<CanonicalStaticReportSelection> selections)
{
ArgumentNullException.ThrowIfNull(model);
ArgumentNullException.ThrowIfNull(selections);

var requested = selections.ToArray();
var signalIndex = BuildSignalIndex(model);
var reportByDataSet = model.ReportControls
.Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference))
.GroupBy(report => NormalizeConfiguredReference(report.DataSetReference), StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group
.OrderBy(report => NormalizeConfiguredReference(report.Reference), StringComparer.Ordinal)
.ToArray(),
StringComparer.Ordinal);

var results = new CanonicalStaticReportSignalCoverage[requested.Length];
for (var index = 0; index < requested.Length; index++)
results[index] = ResolveSignal(model, requested[index], signalIndex, reportByDataSet);

var segmentDataSets = results
.Where(result => result.IsCovered)
.SelectMany(result => result.DataSetCandidates)
.Select(candidate => candidate.DataSetReference)
.Distinct(StringComparer.Ordinal)
.ToArray();

var segments = segmentDataSets
.Select(dataSetReference => BuildSegment(
model,
dataSetReference,
results,
reportByDataSet))
.Where(segment => segment is not null)
.Cast<CanonicalStaticReportCoverageSegment>()
.ToArray();

return new CanonicalStaticReportCoveragePlan
{
Ingress = model.Source.Ingress,
Signals = results,
Segments = segments
};
}

private static CanonicalStaticReportSignalCoverage ResolveSignal(
CanonicalIedModel model,
CanonicalStaticReportSelection selection,
IReadOnlyDictionary<string, CanonicalSignalRow[]> signalIndex,
IReadOnlyDictionary<string, CanonicalReportControl[]> reportByDataSet)
{
var requestedReference = selection.Reference?.Trim() ?? string.Empty;
var requestedFc = NormalizeFc(selection.FunctionalConstraint);

if (!signalIndex.TryGetValue(requestedReference, out var referenceMatches))
{
return new CanonicalStaticReportSignalCoverage
{
RequestedReference = requestedReference,
RequestedFunctionalConstraint = requestedFc,
Status = CanonicalStaticReportSignalCoverageStatus.SignalNotFound
};
}

var matches = string.IsNullOrWhiteSpace(requestedFc)
? referenceMatches
: referenceMatches
.Where(signal => string.Equals(
NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint)),
requestedFc,
StringComparison.Ordinal))
.ToArray();

if (matches.Length != 1)
{
return new CanonicalStaticReportSignalCoverage
{
RequestedReference = requestedReference,
RequestedFunctionalConstraint = requestedFc,
Status = matches.Length == 0
? CanonicalStaticReportSignalCoverageStatus.SignalNotFound
: CanonicalStaticReportSignalCoverageStatus.AmbiguousSignalIdentity
};
}

var signal = matches[0];
var canonicalReference = model.Strings.Resolve(signal.ObjectReference);
var canonicalFc = NormalizeFc(model.Strings.Resolve(signal.FunctionalConstraint));

var candidates = new List<CanonicalStaticReportDataSetCandidate>();
var anyDataSet = false;
foreach (var dataSet in model.DataSets)
{
var memberIndexes = dataSet.Members
.Where(member => MemberCoversSignal(member, canonicalReference, canonicalFc))
.Select(member => member.Index)
.OrderBy(memberIndex => memberIndex)
.ToArray();
if (memberIndexes.Length == 0)
continue;

anyDataSet = true;
var canonicalDataSetReference = NormalizeConfiguredReference(dataSet.Reference);
reportByDataSet.TryGetValue(canonicalDataSetReference, out var reports);
candidates.Add(new CanonicalStaticReportDataSetCandidate
{
DataSetReference = canonicalDataSetReference,
SourceDataSetReference = dataSet.Reference,
MemberIndexes = memberIndexes,
ReportControlReferences = reports?
.Select(report => NormalizeConfiguredReference(report.Reference))
.ToArray() ?? Array.Empty<string>()
});
}

var reportBacked = candidates
.Where(candidate => candidate.ReportControlReferences.Length > 0)
.OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal)
.ToArray();

return new CanonicalStaticReportSignalCoverage
{
RequestedReference = requestedReference,
RequestedFunctionalConstraint = requestedFc,
CanonicalReference = canonicalReference,
CanonicalFunctionalConstraint = canonicalFc,
Status = reportBacked.Length > 0
? CanonicalStaticReportSignalCoverageStatus.Covered
: anyDataSet
? CanonicalStaticReportSignalCoverageStatus.StaticDataSetWithoutReportControl
: CanonicalStaticReportSignalCoverageStatus.NoStaticDataSetMembership,
DataSetCandidates = reportBacked.Length > 0
? reportBacked
: candidates.OrderBy(candidate => candidate.DataSetReference, StringComparer.Ordinal).ToArray()
};
}

private static CanonicalStaticReportCoverageSegment? BuildSegment(
CanonicalIedModel model,
string dataSetReference,
IReadOnlyList<CanonicalStaticReportSignalCoverage> results,
IReadOnlyDictionary<string, CanonicalReportControl[]> reportByDataSet)
{
var dataSet = model.DataSets.SingleOrDefault(candidate =>
string.Equals(
NormalizeConfiguredReference(candidate.Reference),
dataSetReference,
StringComparison.Ordinal));
if (dataSet is null)
return null;

reportByDataSet.TryGetValue(dataSetReference, out var reports);
reports ??= Array.Empty<CanonicalReportControl>();

var selected = results
.Where(result => result.IsCovered &&
result.DataSetCandidates.Any(candidate =>
string.Equals(candidate.DataSetReference, dataSetReference, StringComparison.Ordinal)))
.Select(result => result.CanonicalReference)
.Distinct(StringComparer.Ordinal)
.OrderBy(reference => reference, StringComparer.Ordinal)
.ToArray();

return new CanonicalStaticReportCoverageSegment
{
DataSetReference = dataSetReference,
SourceDataSetReference = dataSet.Reference,
OrderedMembers = dataSet.Members.OrderBy(member => member.Index).ToArray(),
ReportControls = reports,
SelectedSignalReferences = selected
};
}

private static IReadOnlyDictionary<string, CanonicalSignalRow[]> BuildSignalIndex(CanonicalIedModel model)
=> model.Signals
.GroupBy(signal => model.Strings.Resolve(signal.ObjectReference), StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal);

private static bool MemberCoversSignal(
CanonicalDataSetMember member,
string signalReference,
string signalFc)
{
var memberReference = member.Reference?.Trim() ?? string.Empty;
if (memberReference.Length == 0 || signalReference.Length == 0)
return false;

var memberFc = NormalizeFc(member.FunctionalConstraint);
if (memberFc.Length > 0 &&
!string.Equals(memberFc, signalFc, StringComparison.Ordinal))
{
return false;
}

return string.Equals(signalReference, memberReference, StringComparison.Ordinal) ||
signalReference.StartsWith(memberReference + ".", StringComparison.Ordinal);
}

private static string NormalizeConfiguredReference(string? value)
{
var text = (value ?? string.Empty).Trim();
return text.Length == 0 ? string.Empty : text.Replace('$', '.');
}

private static string NormalizeFc(string? value)
=> (value ?? string.Empty).Trim().ToUpperInvariant();
}
Loading