From ba0adf0cbdb62d6bb69c648166da826f6e4aa84f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:22:59 +0700 Subject: [PATCH 01/15] model: add immutable static DataSet capability snapshot --- Models/Iec61850DataSetCapabilityModels.cs | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 Models/Iec61850DataSetCapabilityModels.cs diff --git a/Models/Iec61850DataSetCapabilityModels.cs b/Models/Iec61850DataSetCapabilityModels.cs new file mode 100644 index 000000000..adedab2aa --- /dev/null +++ b/Models/Iec61850DataSetCapabilityModels.cs @@ -0,0 +1,69 @@ +namespace ArIED61850Tester.Models; + +public sealed class Iec61850DataSetMemberCapability +{ + public int Index { get; init; } + public string Reference { get; init; } = string.Empty; + public string FunctionalConstraint { get; init; } = string.Empty; + public string MmsReference { get; init; } = string.Empty; +} + +public sealed class Iec61850ReportControlCapability +{ + public string Reference { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public bool Buffered { get; init; } + public bool Indexed { get; init; } + public string ReportId { get; init; } = string.Empty; + public string ConfRev { get; init; } = string.Empty; + public string TriggerOptions { get; init; } = string.Empty; + public string OptionalFields { get; init; } = string.Empty; + public string BufferTimeMs { get; init; } = string.Empty; + public string IntegrityPeriodMs { get; init; } = string.Empty; +} + +public sealed class Iec61850DataSetCapability +{ + public string Reference { get; init; } = string.Empty; + public string Domain { get; init; } = string.Empty; + public string LogicalNode { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public bool? IsDeletable { get; init; } + public string Fingerprint { get; init; } = string.Empty; + public IReadOnlyList Members { get; init; } = + Array.Empty(); + public IReadOnlyList ReportControls { get; init; } = + Array.Empty(); + + public int MemberCount => Members.Count; + public int StructurallyResolvedMemberCount => Members.Count(member => + !string.IsNullOrWhiteSpace(member.Reference) && + !string.IsNullOrWhiteSpace(member.FunctionalConstraint)); + public bool HasConfiguredReportControl => ReportControls.Count > 0; +} + +public sealed class Iec61850DataSetCapabilityIndex +{ + public long Generation { get; init; } + public string Source { get; init; } = string.Empty; + public string IedName { get; init; } = string.Empty; + public string AccessPointName { get; init; } = string.Empty; + public string ModelFingerprint { get; init; } = string.Empty; + public string DataSetFingerprint { get; init; } = string.Empty; + public string ReportBindingFingerprint { get; init; } = string.Empty; + public IReadOnlyList DataSets { get; init; } = + Array.Empty(); + + public int DataSetCount => DataSets.Count; + public int MemberCount => DataSets.Sum(dataSet => dataSet.MemberCount); + public int ReportReadyDataSetCount => DataSets.Count(dataSet => dataSet.HasConfiguredReportControl); + public bool HasDataSets => DataSetCount > 0; +} + +public sealed class Iec61850DataSetCapabilityParity +{ + public bool DataSetsMatch { get; init; } + public bool ReportBindingsMatch { get; init; } + public bool IsEquivalent => DataSetsMatch && ReportBindingsMatch; + public string Summary { get; init; } = string.Empty; +} From 24625d96c5c1858f8940503a12e1e3bd526c9afe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:23:02 +0700 Subject: [PATCH 02/15] model: add deterministic DataSet capability worker and parity fingerprints --- .../Iec61850DataSetCapabilityIndexBuilder.cs | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 Services/Iec61850DataSetCapabilityIndexBuilder.cs diff --git a/Services/Iec61850DataSetCapabilityIndexBuilder.cs b/Services/Iec61850DataSetCapabilityIndexBuilder.cs new file mode 100644 index 000000000..81f2edd1e --- /dev/null +++ b/Services/Iec61850DataSetCapabilityIndexBuilder.cs @@ -0,0 +1,221 @@ +using System.Security.Cryptography; +using System.Text; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Builds an immutable, source-neutral view of static DataSet/report capabilities. +/// +/// Open SCL and Smart Discovery both produce LiveIedModelDiscoveryDocument. This worker +/// intentionally consumes only stable configuration facts from that shared model so the +/// two sources can be compared deterministically. Runtime-mutating RCB state such as +/// RptEna, reservation and owner state is deliberately excluded from fingerprints. +/// +public static class Iec61850DataSetCapabilityIndexBuilder +{ + public static Task BuildAsync( + LiveIedModelDiscoveryDocument model, + long generation, + string source, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(model); + return Task.Run( + () => Build(model, generation, source, cancellationToken), + cancellationToken); + } + + public static Iec61850DataSetCapabilityIndex Build( + LiveIedModelDiscoveryDocument model, + long generation, + string source, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + cancellationToken.ThrowIfCancellationRequested(); + + var reportCapabilities = model.ReportControls + .Select(ToReportCapability) + .OrderBy(report => report.Reference, StringComparer.Ordinal) + .ThenBy(report => report.DataSetReference, StringComparer.Ordinal) + .ToArray(); + + var reportsByDataSet = reportCapabilities + .Where(report => !string.IsNullOrWhiteSpace(report.DataSetReference)) + .GroupBy(report => NormalizeReference(report.DataSetReference), StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)group.ToArray(), + StringComparer.OrdinalIgnoreCase); + + var dataSets = new List(model.DataSets.Count); + foreach (var dataSet in model.DataSets + .OrderBy(item => NormalizeReference(item.Reference), StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var members = dataSet.Members + .OrderBy(member => member.Index) + .Select(member => new Iec61850DataSetMemberCapability + { + Index = member.Index, + Reference = NormalizeReference(member.Reference), + FunctionalConstraint = NormalizeToken(member.FunctionalConstraint), + MmsReference = NormalizeReference(member.MmsReference) + }) + .ToArray(); + + var key = NormalizeReference(dataSet.Reference); + reportsByDataSet.TryGetValue(key, out var reports); + reports ??= Array.Empty(); + + dataSets.Add(new Iec61850DataSetCapability + { + Reference = key, + Domain = NormalizeToken(dataSet.Domain), + LogicalNode = NormalizeToken(dataSet.LogicalNode), + Name = NormalizeToken(dataSet.Name), + IsDeletable = dataSet.IsDeletable, + Members = members, + ReportControls = reports, + Fingerprint = HashTokens(BuildDataSetTokens(dataSet, members)) + }); + } + + var dataSetFingerprint = HashTokens( + dataSets.SelectMany(dataSet => new[] + { + dataSet.Reference, + dataSet.Fingerprint + })); + + var reportBindingFingerprint = HashTokens( + reportCapabilities.SelectMany(BuildReportTokens)); + + var modelFingerprint = HashTokens(new[] + { + NormalizeToken(model.SchemaVersion), + NormalizeToken(model.IedName), + NormalizeToken(model.AccessPointName), + dataSetFingerprint, + reportBindingFingerprint + }); + + return new Iec61850DataSetCapabilityIndex + { + Generation = generation, + Source = NormalizeToken(source), + IedName = NormalizeToken(model.IedName), + AccessPointName = NormalizeToken(model.AccessPointName), + ModelFingerprint = modelFingerprint, + DataSetFingerprint = dataSetFingerprint, + ReportBindingFingerprint = reportBindingFingerprint, + DataSets = dataSets + }; + } + + public static Iec61850DataSetCapabilityParity Compare( + Iec61850DataSetCapabilityIndex left, + Iec61850DataSetCapabilityIndex right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + + var dataSetsMatch = string.Equals( + left.DataSetFingerprint, + right.DataSetFingerprint, + StringComparison.Ordinal); + var reportBindingsMatch = string.Equals( + left.ReportBindingFingerprint, + right.ReportBindingFingerprint, + StringComparison.Ordinal); + + return new Iec61850DataSetCapabilityParity + { + DataSetsMatch = dataSetsMatch, + ReportBindingsMatch = reportBindingsMatch, + Summary = + $"DataSets={(dataSetsMatch ? "match" : "mismatch")}; " + + $"ReportBindings={(reportBindingsMatch ? "match" : "mismatch")}; " + + $"left={left.Source}[{left.DataSetCount} DS/{left.MemberCount} members], " + + $"right={right.Source}[{right.DataSetCount} DS/{right.MemberCount} members]." + }; + } + + private static Iec61850ReportControlCapability ToReportCapability(LiveIedReportControlModel report) + => new() + { + Reference = NormalizeReference(report.Reference), + DataSetReference = NormalizeReference(report.DataSetReference), + Buffered = report.Buffered, + Indexed = report.Indexed, + ReportId = NormalizeToken(report.ReportId), + ConfRev = NormalizeToken(report.ConfRev), + TriggerOptions = NormalizeToken(report.TriggerOptions), + OptionalFields = NormalizeToken(report.OptionalFields), + BufferTimeMs = NormalizeToken(report.BufferTimeMs), + IntegrityPeriodMs = NormalizeToken(report.IntegrityPeriodMs) + }; + + private static IEnumerable BuildDataSetTokens( + LiveIedDataSetModel dataSet, + IReadOnlyList members) + { + yield return "DataSet"; + yield return NormalizeReference(dataSet.Reference); + yield return NormalizeToken(dataSet.Domain); + yield return NormalizeToken(dataSet.LogicalNode); + yield return NormalizeToken(dataSet.Name); + yield return dataSet.IsDeletable?.ToString() ?? "null"; + yield return members.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); + + foreach (var member in members) + { + yield return member.Index.ToString(System.Globalization.CultureInfo.InvariantCulture); + yield return member.Reference; + yield return member.FunctionalConstraint; + yield return member.MmsReference; + } + } + + private static IEnumerable BuildReportTokens(Iec61850ReportControlCapability report) + { + yield return "ReportControl"; + yield return report.Reference; + yield return report.DataSetReference; + yield return report.Buffered ? "BRCB" : "URCB"; + yield return report.Indexed ? "indexed" : "exact"; + yield return report.ReportId; + yield return report.ConfRev; + yield return report.TriggerOptions; + yield return report.OptionalFields; + yield return report.BufferTimeMs; + yield return report.IntegrityPeriodMs; + } + + private static string HashTokens(IEnumerable tokens) + { + var canonical = new StringBuilder(); + foreach (var token in tokens) + { + var value = token ?? string.Empty; + canonical + .Append(value.Length) + .Append(':') + .Append(value) + .Append('|'); + } + + return Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()))) + .ToLowerInvariant(); + } + + private static string NormalizeReference(string? value) + => (value ?? string.Empty).Trim(); + + private static string NormalizeToken(string? value) + => (value ?? string.Empty).Trim(); +} From 3c2ca099515419464ddf51f8bdf5db43414d0ca6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:23:14 +0700 Subject: [PATCH 03/15] model: generation-scope prepared DataSet capabilities --- Models/MonitorModels.cs | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index e37fc187e..7627bf518 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -40,6 +40,8 @@ public sealed class Iec61850MonitorDevice : ObservableObject private string _sclSourceSha256 = string.Empty; private string _sclIedName = string.Empty; private string _sclAccessPointName = string.Empty; + private long _modelGeneration; + private Iec61850DataSetCapabilityIndex? _dataSetCapabilityIndex; public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); public BulkObservableCollection Signals { get; } = new(); @@ -54,6 +56,7 @@ public SclIedWorkspace? SclWorkspace { if (ReferenceEquals(_sclWorkspace, value)) return; _sclWorkspace = value; + InvalidateDataSetCapabilityIndex(); RefreshAuthoritativeSclComparison(); RefreshComputed(); } @@ -66,6 +69,7 @@ public LiveIedModelDiscoveryDocument? LiveDiscoveryModel { if (ReferenceEquals(_liveDiscoveryModel, value)) return; _liveDiscoveryModel = value; + InvalidateDataSetCapabilityIndex(); RefreshAuthoritativeSclComparison(); RefreshComputed(); } @@ -106,6 +110,45 @@ private void RefreshAuthoritativeSclComparison() _sclComparison = comparison; } + public long ModelGeneration => System.Threading.Interlocked.Read(ref _modelGeneration); + + public Iec61850DataSetCapabilityIndex? DataSetCapabilityIndex + { + get => _dataSetCapabilityIndex; + private set + { + if (ReferenceEquals(_dataSetCapabilityIndex, value)) + return; + + _dataSetCapabilityIndex = value; + Raise(nameof(DataSetCapabilityIndex)); + Raise(nameof(HasPreparedDataSetCapabilities)); + Raise(nameof(PreparedDataSetCount)); + Raise(nameof(PreparedReportReadyDataSetCount)); + } + } + + public bool HasPreparedDataSetCapabilities => DataSetCapabilityIndex is not null; + public int PreparedDataSetCount => DataSetCapabilityIndex?.DataSetCount ?? 0; + public int PreparedReportReadyDataSetCount => DataSetCapabilityIndex?.ReportReadyDataSetCount ?? 0; + + public bool TryApplyDataSetCapabilityIndex(Iec61850DataSetCapabilityIndex index) + { + ArgumentNullException.ThrowIfNull(index); + if (index.Generation != ModelGeneration) + return false; + + DataSetCapabilityIndex = index; + return true; + } + + private void InvalidateDataSetCapabilityIndex() + { + System.Threading.Interlocked.Increment(ref _modelGeneration); + DataSetCapabilityIndex = null; + Raise(nameof(ModelGeneration)); + } + public string SclSourcePath { get => _sclSourcePath; From 8c4f576f37ded49b4afa0f91d648ad633b03ef8a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:23:39 +0700 Subject: [PATCH 04/15] model: prepare DataSet capabilities off the UI path --- MainWindow.DataSetCapabilityIndex.cs | 87 ++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 MainWindow.DataSetCapabilityIndex.cs diff --git a/MainWindow.DataSetCapabilityIndex.cs b/MainWindow.DataSetCapabilityIndex.cs new file mode 100644 index 000000000..55734058c --- /dev/null +++ b/MainWindow.DataSetCapabilityIndex.cs @@ -0,0 +1,87 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private readonly object _dataSetCapabilityBuildSync = new(); + private readonly Dictionary _dataSetCapabilityBuildTokens = + new(StringComparer.OrdinalIgnoreCase); + + private void QueueDataSetCapabilityRefresh(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var model = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (model is null) + return; + + var generation = device.ModelGeneration; + var source = device.SclWorkspace?.DesignModel is not null + ? "OpenScl" + : "SmartDiscovery"; + + CancellationTokenSource cancellation; + lock (_dataSetCapabilityBuildSync) + { + if (_dataSetCapabilityBuildTokens.Remove(device.DeviceId, out var previous)) + { + previous.Cancel(); + previous.Dispose(); + } + + cancellation = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); + _dataSetCapabilityBuildTokens[device.DeviceId] = cancellation; + } + + _ = BuildDataSetCapabilityIndexAsync( + device, + model, + generation, + source, + cancellation); + } + + private async Task BuildDataSetCapabilityIndexAsync( + Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument model, + long generation, + string source, + CancellationTokenSource cancellation) + { + try + { + var index = await Iec61850DataSetCapabilityIndexBuilder.BuildAsync( + model, + generation, + source, + cancellation.Token); + + cancellation.Token.ThrowIfCancellationRequested(); + + // Generation is the final authority. A late worker from an older SCL/discovery + // model is silently discarded instead of repainting capability state with stale + // DataSet/RCB information. + device.TryApplyDataSetCapabilityIndex(index); + } + catch (OperationCanceledException) + { + // Superseded model generations are expected during reconnect/reopen. + } + finally + { + lock (_dataSetCapabilityBuildSync) + { + if (_dataSetCapabilityBuildTokens.TryGetValue(device.DeviceId, out var current) && + ReferenceEquals(current, cancellation)) + { + _dataSetCapabilityBuildTokens.Remove(device.DeviceId); + } + } + + cancellation.Dispose(); + } + } +} From cb2284e33452a2dbaa6c40a1427eca1c7ebabaa7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:23:43 +0700 Subject: [PATCH 05/15] model: warm DataSet capability index for SCL and discovery --- MainWindow.xaml.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 997ef5381..664be3413 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -393,6 +393,7 @@ private void ApplySclWorkspaceToDevice( device.RecountSelectedSignals(); device.RefreshComputed(); ScheduleGooseBindingRefreshFromWorkspace(); + QueueDataSetCapabilityRefresh(device); } private static string BuildSclWorkspaceSummary(SclIedWorkspace workspace) @@ -753,6 +754,7 @@ private async Task ConnectAndConfigureDeviceAsync( var restoredCount = RestoreSignalSelection(device); device.RefreshComputed(); RaiseWorkspaceCounts(); + QueueDataSetCapabilityRefresh(device); SetStatus($"{device.Name}: discovery complete, {device.SignalCount} readable signal(s), {restoredCount} saved selection(s) restored."); // Let the card-local bar visibly settle at 100%, then release the card From 86664a8e33ead1fa9d4785695fdc660d3e117c8e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:23:47 +0700 Subject: [PATCH 06/15] ux: consume prepared DataSet capability index when ready --- SclSignalSelectionModeWindow.xaml.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SclSignalSelectionModeWindow.xaml.cs b/SclSignalSelectionModeWindow.xaml.cs index f7a7fa089..2368a90f7 100644 --- a/SclSignalSelectionModeWindow.xaml.cs +++ b/SclSignalSelectionModeWindow.xaml.cs @@ -36,8 +36,9 @@ public SclSignalSelectionModeWindow(int iedCount, Iec61850MonitorDevice? targetD else { var model = targetDevice.SclWorkspace?.DesignModel ?? targetDevice.LiveDiscoveryModel; - var dataSetCount = model?.DataSets.Count ?? 0; - var reportBackedCount = + var prepared = targetDevice.DataSetCapabilityIndex; + var dataSetCount = prepared?.DataSetCount ?? model?.DataSets.Count ?? 0; + var reportBackedCount = prepared?.ReportReadyDataSetCount ?? Iec61850StaticDataSetAuthoritySelection.BuildReportBackedDataSetReferences(targetDevice).Count; CanUseStaticDataSet = dataSetCount > 0; From b4b16ac3d4803d89b957ca7f31414cb222a270db Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:24:15 +0700 Subject: [PATCH 07/15] tests: cover source-neutral DataSet capability fingerprints --- .../DataSetCapabilityIndexTests.cs | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 tests/ARSAS.Tests/DataSetCapabilityIndexTests.cs diff --git a/tests/ARSAS.Tests/DataSetCapabilityIndexTests.cs b/tests/ARSAS.Tests/DataSetCapabilityIndexTests.cs new file mode 100644 index 000000000..b07903bfb --- /dev/null +++ b/tests/ARSAS.Tests/DataSetCapabilityIndexTests.cs @@ -0,0 +1,222 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class DataSetCapabilityIndexTests +{ + [Fact] + public void Fingerprints_AreSourceNeutralAndIgnoreCollectionOrdering() + { + var left = Model( + dataSets: new[] + { + DataSet("IEDLD/LLN0.Digital", Member(0, "IEDLD/GGIO1.Ind1", "ST")), + DataSet("IEDLD/LLN0.Analog", Member(0, "IEDLD/MMXU1.A.phsA", "MX")) + }, + reports: new[] + { + Report("IEDLD/LLN0.BufDigital", "IEDLD/LLN0.Digital", buffered: true), + Report("IEDLD/LLN0.BufAnalog", "IEDLD/LLN0.Analog", buffered: true) + }); + + var right = Model( + dataSets: left.DataSets.Reverse().ToArray(), + reports: left.ReportControls.Reverse().ToArray()); + + var discovered = Iec61850DataSetCapabilityIndexBuilder.Build(left, 1, "SmartDiscovery"); + var opened = Iec61850DataSetCapabilityIndexBuilder.Build(right, 9, "OpenScl"); + + Assert.Equal(discovered.DataSetFingerprint, opened.DataSetFingerprint); + Assert.Equal(discovered.ReportBindingFingerprint, opened.ReportBindingFingerprint); + Assert.Equal(discovered.ModelFingerprint, opened.ModelFingerprint); + Assert.True(Iec61850DataSetCapabilityIndexBuilder.Compare(discovered, opened).IsEquivalent); + } + + [Fact] + public void DataSetFingerprint_PreservesCanonicalMemberIndexOrder() + { + var baseline = Model( + dataSets: new[] + { + DataSet( + "IEDLD/LLN0.Analog", + Member(0, "IEDLD/MMXU1.A.phsA", "MX"), + Member(1, "IEDLD/MMXU1.A.phsB", "MX")) + }); + + var swapped = Model( + dataSets: new[] + { + DataSet( + "IEDLD/LLN0.Analog", + Member(0, "IEDLD/MMXU1.A.phsB", "MX"), + Member(1, "IEDLD/MMXU1.A.phsA", "MX")) + }); + + var a = Iec61850DataSetCapabilityIndexBuilder.Build(baseline, 1, "SmartDiscovery"); + var b = Iec61850DataSetCapabilityIndexBuilder.Build(swapped, 1, "OpenScl"); + + Assert.NotEqual(a.DataSetFingerprint, b.DataSetFingerprint); + Assert.False(Iec61850DataSetCapabilityIndexBuilder.Compare(a, b).DataSetsMatch); + } + + [Fact] + public void ReportFingerprint_IgnoresMutableRuntimeRcbState() + { + var a = Model( + reports: new[] + { + Report( + "IEDLD/LLN0.Buffer", + "IEDLD/LLN0.Analog", + buffered: true, + enabled: "false", + reservation: "free") + }); + + var b = Model( + reports: new[] + { + Report( + "IEDLD/LLN0.Buffer", + "IEDLD/LLN0.Analog", + buffered: true, + enabled: "true", + reservation: "reserved") + }); + + var left = Iec61850DataSetCapabilityIndexBuilder.Build(a, 1, "SmartDiscovery"); + var right = Iec61850DataSetCapabilityIndexBuilder.Build(b, 2, "OpenScl"); + + Assert.Equal(left.ReportBindingFingerprint, right.ReportBindingFingerprint); + } + + [Fact] + public void ReportFingerprint_ChangesWhenStableConfigurationChanges() + { + var a = Model(reports: new[] + { + Report("IEDLD/LLN0.Buffer", "IEDLD/LLN0.Analog", buffered: true, triggerOptions: "dchg") + }); + var b = Model(reports: new[] + { + Report("IEDLD/LLN0.Buffer", "IEDLD/LLN0.Analog", buffered: true, triggerOptions: "dchg qchg") + }); + + var left = Iec61850DataSetCapabilityIndexBuilder.Build(a, 1, "SmartDiscovery"); + var right = Iec61850DataSetCapabilityIndexBuilder.Build(b, 1, "OpenScl"); + + Assert.NotEqual(left.ReportBindingFingerprint, right.ReportBindingFingerprint); + Assert.False(Iec61850DataSetCapabilityIndexBuilder.Compare(left, right).ReportBindingsMatch); + } + + [Fact] + public void Device_RejectsCapabilityWorkerResultFromOlderModelGeneration() + { + var device = new Iec61850MonitorDevice(); + var first = Model(dataSets: new[] { DataSet("IEDLD/LLN0.First", Member(0, "IEDLD/GGIO1.Ind1", "ST")) }); + device.LiveDiscoveryModel = first; + var firstGeneration = device.ModelGeneration; + var stale = Iec61850DataSetCapabilityIndexBuilder.Build(first, firstGeneration, "SmartDiscovery"); + + device.LiveDiscoveryModel = Model(dataSets: new[] { DataSet("IEDLD/LLN0.Second", Member(0, "IEDLD/GGIO1.Ind2", "ST")) }); + + Assert.True(device.ModelGeneration > firstGeneration); + Assert.False(device.TryApplyDataSetCapabilityIndex(stale)); + Assert.Null(device.DataSetCapabilityIndex); + } + + [Fact] + public void PreparedIndex_ReportsDataSetAndConfiguredRcbCapability() + { + var model = Model( + dataSets: new[] + { + DataSet( + "IEDLD/LLN0.Analog", + Member(0, "IEDLD/MMXU1.A.phsA", "MX"), + Member(1, "IEDLD/MMXU1.A.phsB", "MX")), + DataSet( + "IEDLD/LLN0.Unreported", + Member(0, "IEDLD/GGIO1.Ind1", "ST")) + }, + reports: new[] + { + Report("IEDLD/LLN0.Buffer", "IEDLD/LLN0.Analog", buffered: true) + }); + + var index = Iec61850DataSetCapabilityIndexBuilder.Build(model, 7, "SmartDiscovery"); + + Assert.Equal(2, index.DataSetCount); + Assert.Equal(3, index.MemberCount); + Assert.Equal(1, index.ReportReadyDataSetCount); + Assert.True(index.HasDataSets); + Assert.Single(index.DataSets.Where(dataSet => dataSet.HasConfiguredReportControl)); + } + + private static LiveIedModelDiscoveryDocument Model( + IReadOnlyList? dataSets = null, + IReadOnlyList? reports = null) + => new() + { + SchemaVersion = "live-ied-model-v1", + IedName = "IED1", + AccessPointName = "AP1", + Source = "test", + DataSets = dataSets ?? Array.Empty(), + ReportControls = reports ?? Array.Empty() + }; + + private static LiveIedDataSetModel DataSet( + string reference, + params LiveIedDataSetMemberModel[] members) + => new() + { + Reference = reference, + Domain = reference.Split('/')[0], + LogicalNode = "LLN0", + Name = reference.Split('.').Last(), + MemberCount = members.Length, + Members = members + }; + + private static LiveIedDataSetMemberModel Member( + int index, + string reference, + string functionalConstraint) + => new() + { + Index = index, + Reference = reference, + FunctionalConstraint = functionalConstraint, + MmsReference = reference.Replace('.', '$') + }; + + private static LiveIedReportControlModel Report( + string reference, + string dataSetReference, + bool buffered, + string triggerOptions = "dchg qchg", + string enabled = "", + string reservation = "") + => new() + { + Reference = reference, + Domain = reference.Split('/')[0], + LogicalNode = "LLN0", + Name = reference.Split('.').Last(), + Buffered = buffered, + Indexed = true, + DataSetReference = dataSetReference, + ReportId = "RID", + ConfRev = "1", + TriggerOptions = triggerOptions, + OptionalFields = "seqNum timeStamp dataSet", + BufferTimeMs = "0", + IntegrityPeriodMs = "1000", + EnabledState = enabled, + ReservationState = reservation + }; +} From ece3001045b2ec800f3c366f5b6fe9c80808d2e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:24:18 +0700 Subject: [PATCH 08/15] tests: lock generation-scoped capability worker contract --- .../DataSetCapabilityWorkerRegressionTests.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/ARSAS.Tests/DataSetCapabilityWorkerRegressionTests.cs diff --git a/tests/ARSAS.Tests/DataSetCapabilityWorkerRegressionTests.cs b/tests/ARSAS.Tests/DataSetCapabilityWorkerRegressionTests.cs new file mode 100644 index 000000000..6ff934333 --- /dev/null +++ b/tests/ARSAS.Tests/DataSetCapabilityWorkerRegressionTests.cs @@ -0,0 +1,69 @@ +namespace ARSAS.Tests; + +public sealed class DataSetCapabilityWorkerRegressionTests +{ + [Fact] + public void OpenSclAndDiscovery_BothQueueTheSameCapabilityPreparation() + { + var main = Read("MainWindow.xaml.cs"); + + var sclStart = main.IndexOf("private void ApplySclWorkspaceToDevice", StringComparison.Ordinal); + var sclEnd = main.IndexOf("private static string BuildSclWorkspaceSummary", sclStart, StringComparison.Ordinal); + Assert.True(sclStart >= 0 && sclEnd > sclStart); + Assert.Contains("QueueDataSetCapabilityRefresh(device);", main[sclStart..sclEnd], StringComparison.Ordinal); + + var discoveryStart = main.IndexOf("private async Task ConnectAndConfigureDeviceAsync", StringComparison.Ordinal); + var discoveryEnd = main.IndexOf("private async Task ConnectUsingSavedModelAsync", discoveryStart, StringComparison.Ordinal); + Assert.True(discoveryStart >= 0 && discoveryEnd > discoveryStart); + Assert.Contains("QueueDataSetCapabilityRefresh(device);", main[discoveryStart..discoveryEnd], StringComparison.Ordinal); + } + + [Fact] + public void CapabilityWorker_IsGenerationScopedAndDoesNotPollMms() + { + var worker = Read("MainWindow.DataSetCapabilityIndex.cs"); + var builder = Read("Services/Iec61850DataSetCapabilityIndexBuilder.cs"); + + Assert.Contains("var generation = device.ModelGeneration", worker, StringComparison.Ordinal); + Assert.Contains("device.TryApplyDataSetCapabilityIndex(index)", worker, StringComparison.Ordinal); + Assert.Contains("previous.Cancel()", worker, StringComparison.Ordinal); + Assert.Contains("Task.Run", builder, StringComparison.Ordinal); + + Assert.DoesNotContain("ReadValueAsync", worker, StringComparison.Ordinal); + Assert.DoesNotContain("GetDataSetDirectoriesAsync", worker, StringComparison.Ordinal); + Assert.DoesNotContain("StartMonitoring", worker, StringComparison.Ordinal); + Assert.DoesNotContain("ReadValueAsync", builder, StringComparison.Ordinal); + Assert.DoesNotContain("GetDataSetDirectoriesAsync", builder, StringComparison.Ordinal); + } + + [Fact] + public void CapabilityFingerprint_ExcludesMutableRcbRuntimeState() + { + var builder = Read("Services/Iec61850DataSetCapabilityIndexBuilder.cs"); + + Assert.Contains("report.TriggerOptions", builder, StringComparison.Ordinal); + Assert.Contains("report.OptionalFields", builder, StringComparison.Ordinal); + Assert.Contains("report.IntegrityPeriodMs", builder, StringComparison.Ordinal); + + Assert.DoesNotContain("report.EnabledState", builder, StringComparison.Ordinal); + Assert.DoesNotContain("report.ReservationState", builder, StringComparison.Ordinal); + Assert.DoesNotContain("report.ReservationTimeSeconds", builder, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} From 8837055dfd49d2f55bec8c15b8d0c39781d6e936 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:25:04 +0700 Subject: [PATCH 09/15] tests: inherit P0 smart-route transform contract --- ...scoveryStaticWorkflowParityRegressionTests.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/DiscoveryStaticWorkflowParityRegressionTests.cs b/tests/ARSAS.Tests/DiscoveryStaticWorkflowParityRegressionTests.cs index 1d81f17e0..7a1641881 100644 --- a/tests/ARSAS.Tests/DiscoveryStaticWorkflowParityRegressionTests.cs +++ b/tests/ARSAS.Tests/DiscoveryStaticWorkflowParityRegressionTests.cs @@ -9,12 +9,14 @@ public void CandidateDiscovery_UsesEvidenceGatedFieldVerifiedSmartRoute() var patcher = Read("scripts/enable-smart-discovery-capture.ps1"); var smartSource = Read("Services/NativeIec61850Client.SmartDiscoveryCapture.cs"); - // P0 remains a field-test candidate. Do not silently promote the capture route into - // ordinary tracked source before fresh physical acceptance. - Assert.DoesNotContain( + // Ordinary tracked source remains promotion-gated, while field-capture/R7 builds + // intentionally transform this same file before compiling. The regression contract + // therefore accepts both pre-transform and post-transform source, but requires the + // exact guarded route and lifecycle reset markers whenever the route is installed. + var routeInstalled = source.Contains( "return await DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress)", - source, StringComparison.Ordinal); + Assert.Contains( "DiscoverSignalsSmartForCaptureAsync(cancellationToken, progress)", patcher, @@ -22,6 +24,12 @@ public void CandidateDiscovery_UsesEvidenceGatedFieldVerifiedSmartRoute() Assert.Contains("__P0_5C_CONNECT_RESET__", patcher, StringComparison.Ordinal); Assert.Contains("__P0_5C_DISPOSE_RESET__", patcher, StringComparison.Ordinal); + if (routeInstalled) + { + Assert.Contains("__P0_5C_CONNECT_RESET__", source, StringComparison.Ordinal); + Assert.Contains("__P0_5C_DISPOSE_RESET__", source, StringComparison.Ordinal); + } + Assert.Contains("P0-R9-STRUCTURAL", smartSource, StringComparison.Ordinal); Assert.Contains("DiscoverSmartSingleFlightAsync", smartSource, StringComparison.Ordinal); Assert.Contains("discoveryValues=deferred", smartSource, StringComparison.Ordinal); From f3aabd3b8f2da7a31ac2ea01dce6d7222b1cb0eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:26:59 +0700 Subject: [PATCH 10/15] model: let superseded capability worker own token disposal --- MainWindow.DataSetCapabilityIndex.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/MainWindow.DataSetCapabilityIndex.cs b/MainWindow.DataSetCapabilityIndex.cs index 55734058c..96b8eaa42 100644 --- a/MainWindow.DataSetCapabilityIndex.cs +++ b/MainWindow.DataSetCapabilityIndex.cs @@ -28,8 +28,10 @@ private void QueueDataSetCapabilityRefresh(Iec61850MonitorDevice device) { if (_dataSetCapabilityBuildTokens.Remove(device.DeviceId, out var previous)) { + // The superseded worker owns disposal in its own finally block. Cancelling + // here is sufficient and avoids disposing a token source while that worker + // is still observing the token. previous.Cancel(); - previous.Dispose(); } cancellation = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); From b2cce7e3f3ffe5911a4164361917d29fd9330fb3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:27:58 +0700 Subject: [PATCH 11/15] model: refresh capability index after fast model connect --- MainWindow.xaml.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 664be3413..c464a4423 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -868,6 +868,7 @@ await _runtime.ConnectUsingCachedModelAsync( progress); device.RecountSelectedSignals(); + QueueDataSetCapabilityRefresh(device); await WaitForDiscoveryProgressAnimationAsync(device, TimeSpan.FromMilliseconds(900)); RaiseWorkspaceCounts(); if (device.HasSclDesignModel) From c5acb122fae5dd005e56514bb0122044f1bf9231 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 07:28:02 +0700 Subject: [PATCH 12/15] model: warm DataSet capabilities before card actions --- MainWindow.SclQuickActions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MainWindow.SclQuickActions.cs b/MainWindow.SclQuickActions.cs index e943ea089..ebc9fdef3 100644 --- a/MainWindow.SclQuickActions.cs +++ b/MainWindow.SclQuickActions.cs @@ -86,6 +86,7 @@ internal async Task OpenIedWorkspaceActionsAsync(Iec61850MonitorDevice device) } SelectedDevice = device; + QueueDataSetCapabilityRefresh(device); var dialog = new SclSignalSelectionModeWindow(1, device) { Owner = this, From 56d866d98eb347d6fb950db5ed530432babd36cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 08:04:27 +0700 Subject: [PATCH 13/15] ui: inherit P0 report state text normalization --- Services/Iec61850MonitorRuntime.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 1abc8af02..e4620b677 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1166,7 +1166,12 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT var state = session.States[point.PointKey]; var display = update.HasValue - ? Iec61850ValueFormatter.Format(update.Value, point.IecDataType, point.Unit) + ? Iec61850ValueFormatter.FormatReportProcessValue( + update.Value, + point.IecDataType, + point.Unit, + point.Category, + point.IecReference) : state.Value; if (update.HasValue && LooksLikeReferenceEcho(display, update.Reference, point.IecReference)) continue; From 9b8f15f997143639a83bd272204aecbfda7e5d84 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 08:04:32 +0700 Subject: [PATCH 14/15] ui: inherit P0 report state text normalization --- Services/Iec61850ValueFormatter.cs | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/Services/Iec61850ValueFormatter.cs b/Services/Iec61850ValueFormatter.cs index fb45b3fb4..b1faba052 100644 --- a/Services/Iec61850ValueFormatter.cs +++ b/Services/Iec61850ValueFormatter.cs @@ -43,6 +43,78 @@ string text when TryParseInvariantNumber(text, out var numericText) => AppendUni }; } + /// + /// Normalizes report-delivered process-state text for the operator-facing live/event + /// surfaces without mutating the raw MMS/report evidence. Semantic report projection can + /// legitimately deliver lower-case strings such as "true", "off" and + /// "intermediate-state"; the live UI must preserve the established IEC 61850 state + /// vocabulary and bit-code context. + /// + public static string FormatReportProcessValue( + object? value, + string dataType, + string unit, + string category, + string reference) + { + var normalizedValue = value; + if (TryExtractStructuredScalar(normalizedValue, dataType, out var structuredScalar)) + normalizedValue = structuredScalar; + + if (IsPositionSemantic(dataType, category, reference) && + TryNormalizeDbpos(normalizedValue, out var dbpos)) + { + return FormatOperatorDbpos(dbpos); + } + + if (TryNormalizeBoolean(normalizedValue, out var boolean)) + return boolean ? "True [1]" : "False [0]"; + + return Format(normalizedValue, dataType, unit); + } + + private static bool IsPositionSemantic(string dataType, string category, string reference) + { + if (IsDbposDataType(dataType)) + return true; + + if ((category ?? string.Empty).Trim().Equals("Position", StringComparison.OrdinalIgnoreCase)) + return true; + + var normalizedReference = (reference ?? string.Empty) + .Trim() + .Replace((char)36, '.') + .ToLowerInvariant(); + + return normalizedReference.Contains(".pos.stval", StringComparison.Ordinal) || + normalizedReference.EndsWith(".pos", StringComparison.Ordinal); + } + + private static bool TryNormalizeBoolean(object? value, out bool boolean) + { + switch (value) + { + case bool typed: + boolean = typed; + return true; + case string text when bool.TryParse(text.Trim(), out var parsed): + boolean = parsed; + return true; + default: + boolean = false; + return false; + } + } + + private static string FormatOperatorDbpos(int code) => code switch + { + 0 => "Intermediate [00]", + 1 => "Open [01]", + 2 => "Close [10]", + 3 => "Bad state [11]", + _ => code.ToString(CultureInfo.InvariantCulture) + }; + public static bool TryNormalizeDbpos(object? value, out int code) { code = 0; From 2857bd6026581104a58c179d61ffdf1582981c4d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 20 Sep 2026 08:04:36 +0700 Subject: [PATCH 15/15] ui: inherit P0 report state text normalization --- .../Iec61850ValueFormatterTests.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs index 15aa43b22..4add654ce 100644 --- a/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs +++ b/tests/ARSAS.Tests/Iec61850ValueFormatterTests.cs @@ -4,6 +4,66 @@ namespace ARSAS.Tests; public sealed class Iec61850ValueFormatterTests { + [Theory] + [InlineData("true", "True [1]")] + [InlineData("false", "False [0]")] + [InlineData(true, "True [1]")] + [InlineData(false, "False [0]")] + public void FormatReportProcessValue_RestoresBooleanOperatorText(object value, string expected) + { + var formatted = Iec61850ValueFormatter.FormatReportProcessValue( + value, + "Boolean", + string.Empty, + "Status", + "IEDLD/GGIO1.SwLoc.stVal"); + + Assert.Equal(expected, formatted); + } + + [Theory] + [InlineData("off", "Open [01]")] + [InlineData("on", "Close [10]")] + [InlineData("intermediate-state", "Intermediate [00]")] + [InlineData("bad-state", "Bad state [11]")] + public void FormatReportProcessValue_RestoresDpcOperatorContext(string value, string expected) + { + var formatted = Iec61850ValueFormatter.FormatReportProcessValue( + value, + "Enum", + string.Empty, + "Position", + "IEDLD/CSWI1.Pos.stVal"); + + Assert.Equal(expected, formatted); + } + + [Fact] + public void FormatReportProcessValue_DoesNotInterpretGenericOnOffAsDpcOutsidePositionContext() + { + var formatted = Iec61850ValueFormatter.FormatReportProcessValue( + "off", + "Enum", + string.Empty, + "Status", + "IEDLD/GGIO1.AutoMode.stVal"); + + Assert.Equal("off", formatted); + } + + [Fact] + public void FormatReportProcessValue_LeavesNumericMeasurementUnchanged() + { + var formatted = Iec61850ValueFormatter.FormatReportProcessValue( + "123.45", + "Float32", + "V", + "Measurement", + "IEDLD/MMXU1.PhV.phsA.cVal.mag.f"); + + Assert.Equal("123.45 V", formatted); + } + [Fact] public void Format_Extracts_Boolean_StVal_From_Legacy_Report_Structure() {