Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 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
ca41ca2
feat(reporting): add JIT configured-static readback proof policy (#144)
masarray Oct 7, 2026
c2c2799
fix(reporting): fail closed when configured-static RptEna readback is…
masarray Oct 7, 2026
ad6bfc5
test(reporting): lock configured-static JIT readback proof contract (…
masarray Oct 7, 2026
97363b9
perf(reporting): add minimal JIT RCB snapshot fast path (#144)
masarray Oct 7, 2026
ac6094e
feat(reporting): retain exact JIT RCB state evidence in snapshots (#144)
masarray Oct 7, 2026
7c6f3f6
perf(reporting): prove configured static activation with one exact re…
masarray Oct 7, 2026
86915ca
perf(reporting): use minimal JIT preflight and single exact post-enab…
masarray Oct 7, 2026
f7675a6
test(reporting): lock minimal one-readback configured-static proof (#…
masarray Oct 7, 2026
12bb6bb
test(reporting): fix busy-evidence fixture construction (#144)
masarray Oct 7, 2026
327d499
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
048d39d
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
1b64dfb
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
b33ce7f
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
7ad1193
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
e78f007
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
d129faa
ci(#144): overlay targeted hot-path slice
masarray Oct 7, 2026
1722093
ci(#144): refresh targeted hot-path read-budget fixes
masarray Oct 7, 2026
bad81df
ci(#144): refresh targeted hot-path read-budget fixes
masarray Oct 7, 2026
1a033ab
ci(#144): refresh structure type-prewarm optimization
masarray Oct 7, 2026
7cef117
ci(#144): refresh structure type-prewarm optimization
masarray Oct 7, 2026
a39e932
ci(#144): overlay asynchronous armed-to-traffic proof state
masarray Oct 7, 2026
f5e8a45
ci(#144): overlay canonical-to-live RCB target resolver
masarray Oct 7, 2026
7f7328e
ci(#144): overlay canonical-to-live RCB target resolver
masarray Oct 7, 2026
4c31f11
ci(#144): refresh canonical targeted-RCB integration
masarray Oct 7, 2026
c69bebf
ci(#144): refresh canonical targeted-RCB integration
masarray Oct 7, 2026
1868ea8
ci(#144): refresh canonical targeted-RCB integration
masarray Oct 7, 2026
c0609d5
ci(#144): refresh canonical targeted-RCB integration
masarray Oct 7, 2026
cba751f
perf(reporting): integrate event-driven report wake for exact-head CI…
masarray Oct 7, 2026
471c1f1
perf(reporting): integrate event-driven report wake for exact-head CI…
masarray Oct 7, 2026
d5d82c5
perf(reporting): integrate event-driven report wake for exact-head CI…
masarray Oct 7, 2026
71a8a52
test(reporting): prove burst reports coalesce without stale wake (#144)
masarray Oct 7, 2026
9bb2f93
feat(reporting): integrate P6 source-aware acquisition preflight for …
masarray Oct 7, 2026
93e1a80
feat(reporting): integrate P6 source-aware acquisition preflight for …
masarray Oct 7, 2026
edf41a8
feat(reporting): integrate source-bound P6 preflight for exact-head C…
masarray Oct 7, 2026
959ca81
feat(reporting): integrate source-bound P6 preflight for exact-head C…
masarray Oct 7, 2026
060a217
feat(reporting): integrate source-bound P6 preflight for exact-head C…
masarray Oct 7, 2026
866b10d
feat(reporting): integrate source-bound P6 preflight for exact-head C…
masarray Oct 7, 2026
89c655e
test(reporting): prove live MMS inventory authority is stamped at dis…
masarray Oct 7, 2026
cd7002d
feat(reporting): integrate canonical structural inventory projection …
masarray Oct 7, 2026
b0860f6
feat(reporting): integrate canonical structural inventory projection …
masarray Oct 7, 2026
4d97751
feat(reporting): integrate canonical structural inventory projection …
masarray Oct 7, 2026
b8ff24b
test(reporting): use canonical SCL evidence enum name (#144)
masarray Oct 7, 2026
51fa709
test(reporting): use canonical SCL evidence enum name (#144)
masarray Oct 7, 2026
5858a86
feat(reporting): integrate P6.1 targeted live RCB reconciliation for …
masarray Oct 7, 2026
9c52925
feat(reporting): integrate P6.1 targeted live RCB reconciliation for …
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
36 changes: 33 additions & 3 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 Expand Up @@ -150,7 +169,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 +186,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 +251,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