From b7f4c447e506ebc1bae3526a309a711ca6ac8b65 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:06:56 +0700 Subject: [PATCH 1/5] fix(m2.13f): fence stale canonical projections on catalog swaps and preserve IEC case identity --- MainWindow.IedBrowser.cs | 10 +- MainWindow.xaml.cs | 4 +- Models/IedBrowserReadBindingIndex.cs | 10 +- Models/MonitorModels.cs | 17 +++ .../CanonicalLiveValueSsotM213FTests.cs | 135 ++++++++++++++++++ 5 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index edaeef9a3..623cbeb4e 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -230,7 +230,9 @@ private async void IedBrowserReadValues_Click(object sender, RoutedEventArgs e) return; } - var bindingIndex = new IedBrowserReadBindingIndex(device); + // Build after a possible cached-model connect; connection may replace + // the catalog and invalidate a pre-connect binding snapshot. + IedBrowserReadBindingIndex? bindingIndex = null; var cts = CancellationTokenSource.CreateLinkedTokenSource(_applicationCancellation.Token); // Read All may include thousands of attributes. It is cancellable by the // operator, never a new continuous acquisition/polling process. @@ -257,8 +259,9 @@ void DrainReadResults() readValues.Drain(result => { if (!ReferenceEquals(_iedBrowserReadOperation, cts) || - sourceDevice.LiveValues.Association != readAssociation) - return; // new association or replaced read flight + sourceDevice.LiveValues.Association != readAssociation || + bindingIndex is not { IsCurrent: true }) + return; // superseded association, catalog or read flight if (result.Error.Length != 0 || string.IsNullOrWhiteSpace(result.Value)) { failures++; @@ -297,6 +300,7 @@ void DrainReadResults() } readAssociation = sourceDevice.LiveValues.Association; + bindingIndex = new IedBrowserReadBindingIndex(sourceDevice); var readProgress = new Progress(p => { if (ReferenceEquals(_iedBrowserReadOperation, cts) && diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index ed62bf0c8..9f65ceeb8 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -26,12 +26,12 @@ public partial class MainWindow : Window, INotifyPropertyChanged private readonly CancellationTokenSource _applicationCancellation = new(); private readonly Dictionary> _pendingProjectSelections = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _signalOwners = new(); - private readonly ConcurrentDictionary _pendingPointSnapshots = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _pendingPointSnapshots = new(StringComparer.Ordinal); // Monitoring-lifetime O(1) immutable reference/FC lookup for SignalDefinition. // Rebuilt after the selected signal/point model is materialized. private readonly Dictionary _canonicalSignalProjections = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _pointIndex = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _pointIndex = new(StringComparer.Ordinal); private readonly ConcurrentQueue _pendingEvents = new(); private readonly ConcurrentQueue _pendingDiagnostics = new(); private readonly Dictionary _reportPulseUntil = new(StringComparer.OrdinalIgnoreCase); diff --git a/Models/IedBrowserReadBindingIndex.cs b/Models/IedBrowserReadBindingIndex.cs index 78f71f26a..3e311d864 100644 --- a/Models/IedBrowserReadBindingIndex.cs +++ b/Models/IedBrowserReadBindingIndex.cs @@ -10,6 +10,11 @@ namespace ArIED61850Tester.Models; public sealed class IedBrowserReadBindingIndex { private readonly Iec61850MonitorDevice _device; + private readonly long _projectionEpoch; + + // Fail closed when the engineering catalog or its bound rows are replaced. + // Comparing one monotonic epoch costs O(1) on each Report/Read projection. + public bool IsCurrent => _projectionEpoch == _device.BrowserProjectionEpoch; private readonly Dictionary _signals; private readonly Dictionary _points; @@ -45,6 +50,7 @@ public IedBrowserReadBindingIndex(Iec61850MonitorDevice device) StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + _projectionEpoch = device.BrowserProjectionEpoch; } private static void Add(Dictionary> index, @@ -69,7 +75,7 @@ public int Apply(IedBrowserReadResult read, IedBrowserLiveValues live, { ArgumentNullException.ThrowIfNull(read); ArgumentNullException.ThrowIfNull(live); - if (_device.IsMonitoring || !_device.IsConnected || + if (!IsCurrent || _device.IsMonitoring || !_device.IsConnected || !string.IsNullOrWhiteSpace(read.Error) || string.IsNullOrWhiteSpace(read.Value) || live.Association != association || @@ -118,7 +124,7 @@ public int ProjectReportedSignals(IedBrowserLiveValues live, long association, string reference, string fc) { ArgumentNullException.ThrowIfNull(live); - if (!_device.IsMonitoring || !_device.IsConnected || + if (!IsCurrent || !_device.IsMonitoring || !_device.IsConnected || association != live.Association || live.IsOffline || string.IsNullOrWhiteSpace(reference) || string.IsNullOrWhiteSpace(fc) || !_signals.TryGetValue(Key(reference, fc), out var signals) || diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 5caf4f014..08e86821a 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -56,6 +56,21 @@ public sealed class Iec61850MonitorDevice : ObservableObject // identical reference/FC keys; not persisted across associations. [JsonIgnore] public IedBrowserLiveValues LiveValues { get; } = new(); + + // Collection-change notifications (including same-count ReplaceAll/Reset) + // and schema replacement invalidate captured reference/FC projection indexes. + // One monotonic revision avoids scans or per-row event subscriptions on ingress. + private long _browserProjectionEpoch; + public long BrowserProjectionEpoch => + System.Threading.Interlocked.Read(ref _browserProjectionEpoch); + + public Iec61850MonitorDevice() + { + Signals.CollectionChanged += (_, _) => + System.Threading.Interlocked.Increment(ref _browserProjectionEpoch); + Points.CollectionChanged += (_, _) => + System.Threading.Interlocked.Increment(ref _browserProjectionEpoch); + } public Iec61850DeviceDiagnosticSnapshot LastDiagnosticSnapshot { get; set; } = new(); [JsonIgnore] @@ -83,6 +98,7 @@ public SclIedWorkspace? SclWorkspace { if (ReferenceEquals(_sclWorkspace, value)) return; _sclWorkspace = value; + System.Threading.Interlocked.Increment(ref _browserProjectionEpoch); RefreshAuthoritativeSclComparison(); RefreshComputed(); } @@ -95,6 +111,7 @@ public LiveIedModelDiscoveryDocument? LiveDiscoveryModel { if (ReferenceEquals(_liveDiscoveryModel, value)) return; _liveDiscoveryModel = value; + System.Threading.Interlocked.Increment(ref _browserProjectionEpoch); RefreshAuthoritativeSclComparison(); RefreshComputed(); } diff --git a/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs new file mode 100644 index 000000000..48c9c3161 --- /dev/null +++ b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs @@ -0,0 +1,135 @@ +using System.Collections.Concurrent; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// M2.13F: same-size model swaps, Read/reconnect and exact IEC identity. +public sealed class CanonicalLiveValueSsotM213FTests +{ + private static IedBrowserReadResult Read(string reference, string value = "999") + => new(new IedBrowserReadTarget(reference, "MX", "FLOAT32"), + value, "", "", ""); + + [Fact] + public void SameCountCatalogReloadCannotUpdateDetachedReadSignalsOrPoints() + { + const string reference = "LD/MMXU1.Hz.mag.f"; + var device = new Iec61850MonitorDevice { IsConnected = true }; + var oldSignal = new SignalDefinition { ObjectReference = reference, FunctionalConstraint = "MX" }; + var oldPoint = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, IecReference = reference, + FunctionalConstraint = "MX" + }; + device.Signals.Add(oldSignal); + device.Points.Add(oldPoint); + var oldIndex = new IedBrowserReadBindingIndex(device); + var association = device.LiveValues.Association; + Assert.True(oldIndex.IsCurrent); + // Replace with the SAME number of elements and SAME IEC reference. + // Count-based invalidation would silently retain the detached objects. + var replacementSignal = new SignalDefinition { ObjectReference = reference, FunctionalConstraint = "MX" }; + var replacementPoint = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, IecReference = reference, + FunctionalConstraint = "MX" + }; + device.Signals.ReplaceAll([replacementSignal]); + device.Points.ReplaceAll([replacementPoint]); + Assert.False(oldIndex.IsCurrent); + Assert.True(device.LiveValues.PublishForAssociation(association, reference, "MX", + "50", "Good", "T1", "MMS Read")); + Assert.Equal(0, oldIndex.Apply(Read(reference), device.LiveValues, association)); + Assert.Equal("-", oldSignal.Value); + Assert.Equal("-", replacementSignal.Value); + Assert.Equal("-", oldPoint.Value); + Assert.Equal("-", replacementPoint.Value); + + var current = new IedBrowserReadBindingIndex(device); + Assert.True(current.IsCurrent); + Assert.Equal(2, current.Apply(Read(reference), device.LiveValues, association)); + Assert.Equal("50", replacementSignal.Value); + Assert.Equal("50", replacementPoint.Value); + Assert.Equal("-", oldSignal.Value); + } + + [Fact] + public void SameCountCatalogReloadCannotReanimateOldReportSignal() + { + const string reference = "LD/MMXU1.A.phsA.cVal.mag.f"; + var device = new Iec61850MonitorDevice { IsConnected = true, IsMonitoring = true }; + var previous = new SignalDefinition { ObjectReference = reference, FunctionalConstraint = "MX" }; + device.Signals.Add(previous); + var oldIndex = new IedBrowserReadBindingIndex(device); + var replacement = new SignalDefinition { ObjectReference = reference, FunctionalConstraint = "MX" }; + device.Signals.ReplaceAll([replacement]); + var association = device.LiveValues.Association; + Assert.True(device.LiveValues.PublishForAssociation(association, reference, "MX", + "10", "Good", "T1", "Report")); + Assert.Equal(0, oldIndex.ProjectReportedSignals(device.LiveValues, association, reference, "MX")); + Assert.Equal("-", previous.Value); + Assert.Equal("-", replacement.Value); + var newIndex = new IedBrowserReadBindingIndex(device); + Assert.Equal(1, newIndex.ProjectReportedSignals(device.LiveValues, association, reference, "MX")); + Assert.Equal("10", replacement.Value); + Assert.Equal("-", previous.Value); + } + + [Fact] + public void ExactCaseAndFcRemainDistinctUnderBurstCoalescing() + { + var live = new IedBrowserLiveValues(); + var association = live.BeginAssociation(); + var upper = "LD/CSWI1.Pos.stVal"; + var lower = "LD/CSWI1.pos.stVal"; + Assert.True(live.PublishForAssociation(association, upper, "ST", "Open", "", "", "Report")); + Assert.True(live.PublishForAssociation(association, lower, "ST", "Closed", "", "", "Report")); + Assert.True(live.PublishForAssociation(association, upper, "MX", "22", "", "", "Report")); + Assert.True(live.TryGet(upper, "ST", out var a)); + Assert.True(live.TryGet(lower, "ST", out var b)); + Assert.True(live.TryGet(upper, "MX", out var c)); + Assert.Equal("Open", a.Value); + Assert.Equal("Closed", b.Value); + Assert.Equal("22", c.Value); + + var batches = new ConcurrentDictionary(StringComparer.Ordinal); + foreach (var reference in new[] { upper, lower }) + { + var point = new Iec61850MonitorPoint { DeviceId = "IED", IecReference = reference, FunctionalConstraint = "ST" }; + var snapshot = new Iec61850PointSnapshot + { + Point = point, ObservationAssociation = association, + SnapshotRevision = 1, Sequence = 1, Value = reference + }; + batches.AddOrUpdate(point.PointKey, + _ => new CanonicalPointSnapshotBatch(snapshot, false), + (_, prior) => CanonicalPointSnapshotAuthority.Merge(prior, snapshot)); + } + Assert.Equal(2, batches.Count); + + // Protect the concrete WPF hot path, not only a test-side dictionary. + var source = FindSource("MainWindow.xaml.cs"); + Assert.Contains("_pendingPointSnapshots = new(StringComparer.Ordinal);", source); + Assert.Contains("_pointIndex = new(StringComparer.Ordinal);", source); + } + + [Fact] + public void ReadBindsAfterConnectAndFencesCatalogBeforeEvidenceAdmission() + { + var source = FindSource("MainWindow.IedBrowser.cs"); + Assert.Contains("bindingIndex = new IedBrowserReadBindingIndex(sourceDevice);", source); + Assert.Contains("bindingIndex is not { IsCurrent: true }", source); + } + + private static string FindSource(string path) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir != null) + { + var file = Path.Combine(dir.FullName, path); + if (File.Exists(file)) return File.ReadAllText(file); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +} From 9b743ee48e9cba6ef71257af9929e2dfb066d088 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:08:34 +0700 Subject: [PATCH 2/5] fix(m2.13f): use exact reference and FC as snapshot coalescing key --- MainWindow.xaml.cs | 4 ++-- Models/CanonicalPointSnapshotAuthority.cs | 13 ++++++++++++ .../CanonicalLiveValueSsotM213FTests.cs | 21 ++++++++++++++++--- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 9f65ceeb8..ec9c88103 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -31,7 +31,7 @@ public partial class MainWindow : Window, INotifyPropertyChanged // Rebuilt after the selected signal/point model is materialized. private readonly Dictionary _canonicalSignalProjections = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _pointIndex = new(StringComparer.Ordinal); + private readonly Dictionary _pointIndex = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentQueue _pendingEvents = new(); private readonly ConcurrentQueue _pendingDiagnostics = new(); private readonly Dictionary _reportPulseUntil = new(StringComparer.OrdinalIgnoreCase); @@ -2373,7 +2373,7 @@ private void EnsureOfflinePointProjection(Iec61850MonitorDevice device) private void Runtime_PointUpdated(Iec61850PointSnapshot snapshot) { _pendingPointSnapshots.AddOrUpdate( - snapshot.Point.PointKey, + CanonicalPointSnapshotAuthority.UiQueueKey(snapshot.Point), _ => new CanonicalPointSnapshotBatch(snapshot, snapshot.IsValueEdge), (_, current) => CanonicalPointSnapshotAuthority.Merge(current, snapshot)); } diff --git a/Models/CanonicalPointSnapshotAuthority.cs b/Models/CanonicalPointSnapshotAuthority.cs index 4e36fffb4..848ebfba4 100644 --- a/Models/CanonicalPointSnapshotAuthority.cs +++ b/Models/CanonicalPointSnapshotAuthority.cs @@ -16,6 +16,19 @@ public sealed record CanonicalPointSnapshotBatch( public static class CanonicalPointSnapshotAuthority { + /// + /// Exact IEC reference + FC key for coalescing observational snapshots. + /// Legacy PointKey intentionally has display/search normalization and is + /// NOT an authority key: it lowercases references and omits FC. + /// + public static string UiQueueKey(Iec61850MonitorPoint point) + { + ArgumentNullException.ThrowIfNull(point); + return point.DeviceId + "|" + + IedBrowserLiveValues.CanonicalReference(point.IecReference) + "|" + + (point.FunctionalConstraint ?? "").Trim().ToUpperInvariant(); + } + public static Iec61850PointSnapshot NewerSnapshot( Iec61850PointSnapshot previous, Iec61850PointSnapshot incoming) { diff --git a/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs index 48c9c3161..8c1f9ff7d 100644 --- a/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs @@ -101,16 +101,31 @@ public void ExactCaseAndFcRemainDistinctUnderBurstCoalescing() Point = point, ObservationAssociation = association, SnapshotRevision = 1, Sequence = 1, Value = reference }; - batches.AddOrUpdate(point.PointKey, + batches.AddOrUpdate(CanonicalPointSnapshotAuthority.UiQueueKey(point), _ => new CanonicalPointSnapshotBatch(snapshot, false), (_, prior) => CanonicalPointSnapshotAuthority.Merge(prior, snapshot)); } Assert.Equal(2, batches.Count); + var mx = new Iec61850MonitorPoint + { + DeviceId = "IED", IecReference = upper, FunctionalConstraint = "MX" + }; + var st = new Iec61850MonitorPoint + { + DeviceId = "IED", IecReference = upper, FunctionalConstraint = "ST" + }; + Assert.NotEqual(CanonicalPointSnapshotAuthority.UiQueueKey(st), + CanonicalPointSnapshotAuthority.UiQueueKey(mx)); + Assert.NotEqual(CanonicalPointSnapshotAuthority.UiQueueKey(st), + CanonicalPointSnapshotAuthority.UiQueueKey(new Iec61850MonitorPoint + { + DeviceId = "IED", IecReference = lower, FunctionalConstraint = "ST" + })); - // Protect the concrete WPF hot path, not only a test-side dictionary. + // Protect the real WPF receive seam rather than only a test dictionary. var source = FindSource("MainWindow.xaml.cs"); Assert.Contains("_pendingPointSnapshots = new(StringComparer.Ordinal);", source); - Assert.Contains("_pointIndex = new(StringComparer.Ordinal);", source); + Assert.Contains("CanonicalPointSnapshotAuthority.UiQueueKey(snapshot.Point)", source); } [Fact] From 1bce9a313704d03a024538eda316b5c7cca98719 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:13:12 +0700 Subject: [PATCH 3/5] fix(m2.13f): reject obsolete pre-connect Read plans and keep model epoch transient --- MainWindow.IedBrowser.cs | 9 +++++++++ Models/MonitorModels.cs | 2 +- tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 623cbeb4e..f42746a37 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -299,6 +299,12 @@ void DrainReadResults() throw new InvalidOperationException("MMS connection was not established."); } + // A cached connect may rebuild schema/model. Never execute Read + // targets from a superseded model against the new association. + if (!ReferenceEquals(model, + sourceDevice.LiveDiscoveryModel ?? sourceDevice.SclWorkspace?.DesignModel)) + throw new InvalidOperationException( + "Browser model changed during connection; select the current model and retry Read."); readAssociation = sourceDevice.LiveValues.Association; bindingIndex = new IedBrowserReadBindingIndex(sourceDevice); var readProgress = new Progress(p => @@ -318,6 +324,9 @@ await _runtime.ReadBrowserValuesAsync(device, targets, // All producers have returned: consume the final queued results // synchronously before calculating counts and closing this flight. DrainReadResults(); + if (bindingIndex is not { IsCurrent: true }) + throw new InvalidOperationException( + "Browser catalog changed during Read; obsolete result rows were not admitted."); if (!cts.IsCancellationRequested && ReferenceEquals(SelectedDevice, sourceDevice)) { device.ApplyBrowserReadProgress(targets.Count, targets.Count); diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 08e86821a..b629e87b6 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -61,7 +61,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject // and schema replacement invalidate captured reference/FC projection indexes. // One monotonic revision avoids scans or per-row event subscriptions on ingress. private long _browserProjectionEpoch; - public long BrowserProjectionEpoch => + internal long BrowserProjectionEpoch => System.Threading.Interlocked.Read(ref _browserProjectionEpoch); public Iec61850MonitorDevice() diff --git a/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs index 8c1f9ff7d..970454214 100644 --- a/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs @@ -134,6 +134,8 @@ public void ReadBindsAfterConnectAndFencesCatalogBeforeEvidenceAdmission() var source = FindSource("MainWindow.IedBrowser.cs"); Assert.Contains("bindingIndex = new IedBrowserReadBindingIndex(sourceDevice);", source); Assert.Contains("bindingIndex is not { IsCurrent: true }", source); + Assert.Contains("Browser model changed during connection", source); + Assert.Contains("Browser catalog changed during Read", source); } private static string FindSource(string path) From 9a1f0093ab3240c72f62ee609c8e565c6ceec64e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:21:12 +0700 Subject: [PATCH 4/5] fix(m2.13g): reject ambiguous legacy point-key identities before monitor restart and preserve final read drain contract --- Models/MonitorModels.cs | 263 +++++++++++++++++- Services/Iec61850MonitorRuntime.cs | 38 ++- ...CanonicalRuntimePointIdentityM213GTests.cs | 74 +++++ .../IedBrowserReadResultInboxTests.cs | 13 +- 4 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 tests/ARSAS.Tests/CanonicalRuntimePointIdentityM213GTests.cs diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index b629e87b6..ec1435b73 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -949,8 +949,267 @@ private static string NormalizeSemanticValue(string? value) return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); } - private static string NormalizeReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('$', '.').Replace("..", ".").ToLowerInvariant(); + // Legacy presentation/planner PointKey normalization. This is NOT a + // canonical IEC identity: case and FC are intentionally not encoded. + // Startup collision validation protects runtime dictionaries from aliasing. + internal static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('(string? reference, string? iedName) + { + var text = (reference ?? string.Empty).Trim().Replace('$', '.'); + var name = (iedName ?? string.Empty).Trim(); + var slash = text.IndexOf('/'); + if (slash <= 0 || string.IsNullOrWhiteSpace(name)) + return text; + + var domain = text[..slash]; + if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) || domain.Length <= name.Length) + return text; + + return domain[name.Length..] + text[slash..]; + } +} + +public sealed class Iec61850PointSnapshot +{ + public required Iec61850MonitorPoint Point { get; init; } + public string PreviousValue { get; init; } = "-"; + public string Value { get; init; } = "-"; + public string Quality { get; init; } = "Unknown"; + public string DeviceTimestamp { get; init; } = "-"; + public string SourceMode { get; init; } = "MMS Polling"; + public string Reason { get; init; } = "cyclic"; + public string Status { get; init; } = "Live"; + /// + /// True only when the runtime observed a real semantic process-value transition. + /// This flag survives UI batching so a fast report/poll sequence cannot erase the + /// three-second commissioning highlight before WPF renders it. + /// + public bool IsValueEdge { get; init; } + public bool IsReportTraffic { get; init; } + // Association identity is stamped at runtime ingress, NOT reconstructed + // by the UI when a queued snapshot is eventually processed. + public long ObservationAssociation { get; init; } + // Per-point monotonic publication order, including metadata-only frames. + // Separate from Sequence, which is reserved for semantic process edges/SOE. + public long SnapshotRevision { get; init; } + // Association-local aggregate sourced only from real decoded report ingress. + // Immutable copy when published: safe across the runtime / WPF dispatcher. + public IReadOnlyList RoutedReportControlReferences { get; init; } = Array.Empty(); + public long Sequence { get; init; } +} + +public sealed class Iec61850EventEntry +{ + public long Sequence { get; init; } + public string DeviceId { get; init; } = string.Empty; + public string PointKey { get; init; } = string.Empty; + public string DeviceTimestamp { get; init; } = "-"; + public string DeviceName { get; init; } = string.Empty; + public string IpAddress { get; init; } = string.Empty; + public string SignalName { get; init; } = string.Empty; + public string IecReference { get; init; } = string.Empty; + public string IecDataType { get; init; } = string.Empty; + public string OldValue { get; init; } = "-"; + public string NewValue { get; init; } = "-"; + public string Quality { get; init; } = "Unknown"; + public string SourceMode { get; init; } = string.Empty; + public string Reason { get; init; } = string.Empty; + // Raw reporting identity, copied ONLY from a routed live report, never + // fabricated from static DataSet labels or a process-value edge. + public string ReportId { get; init; } = string.Empty; + public string ReportControlReference { get; init; } = string.Empty; + public IReadOnlyList RoutedReportControlReferences { get; init; } = Array.Empty(); + // Assigned only from future independently proven buffered replay evidence. + // BRCB membership or receipt of a buffered report does not prove replay. + public bool VerifiedBufferedReplay { get; init; } + // Operator labels are deliberately short. Full source evidence remains + // available in the export and the native report diagnostics. + public string EventCause => Iec61850EventOriginPolicy.Cause(Reason, SourceMode, VerifiedBufferedReplay); + public string AcquisitionShort => Iec61850EventOriginPolicy.Acquisition( + ReportId, ReportControlReference, SourceMode, RoutedReportControlReferences); + public string ReportChannel => Iec61850EventOriginPolicy.Channel(SourceMode); + public string EdgeType + { + get + { + if (TryParseBinaryState(OldValue, out var oldState) && + TryParseBinaryState(NewValue, out var newState) && + oldState != newState) + { + return newState ? "Rising" : "Falling"; + } + + return "State change"; + } + } + + public string ChangeText => $"{EdgeType} · {OldValue} → {NewValue}"; + public string EventValue => string.IsNullOrWhiteSpace(NewValue) ? "-" : NewValue; + public string DisplayValue => EventValue; + public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); + public string ValueTone => Iec61850ValueStatePresentation.Classify(EventValue, IecDataType); + public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( + EventValue, + IecDataType, + string.Empty, + IecReference); + public string QualityTone => Iec61850QualityPresentation.Classify(Quality); + + public string IecTelegram => Iec61850MonitorPoint.StripIedNamePrefix(IecReference, DeviceName); + + private static bool TryParseBinaryState(string? value, out bool state) + { + state = false; + var text = (value ?? string.Empty).Trim(); + if (bool.TryParse(text, out state)) + return true; + + if (text.Equals("1", StringComparison.OrdinalIgnoreCase) || + text.Equals("1.0", StringComparison.OrdinalIgnoreCase) || + text.Equals("on", StringComparison.OrdinalIgnoreCase) || + text.Equals("active", StringComparison.OrdinalIgnoreCase) || + text.Equals("asserted", StringComparison.OrdinalIgnoreCase)) + { + state = true; + return true; + } + + if (text.Equals("0", StringComparison.OrdinalIgnoreCase) || + text.Equals("0.0", StringComparison.OrdinalIgnoreCase) || + text.Equals("off", StringComparison.OrdinalIgnoreCase) || + text.Equals("inactive", StringComparison.OrdinalIgnoreCase) || + text.Equals("deasserted", StringComparison.OrdinalIgnoreCase)) + { + state = false; + return true; + } + + return false; + } +} + +public sealed class Iec61850TesterProject +{ + public int SchemaVersion { get; set; } = 3; + public string ProjectName { get; set; } = "ARSAS Session"; + public int DefaultPollingIntervalMs { get; set; } = 1000; + public List Devices { get; set; } = new(); +} + +public sealed class Iec61850TesterDeviceProfile +{ + public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); + public string Name { get; set; } = "IED"; + public string IdentitySource { get; set; } = string.Empty; + public string LogicalDeviceSummary { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public int Port { get; set; } = 102; + public bool AllowDynamicDataSetWrites { get; set; } = true; + public bool DiscoverySucceeded { get; set; } + public string SclSourcePath { get; set; } = string.Empty; + public string SclSourceSha256 { get; set; } = string.Empty; + public string SclIedName { get; set; } = string.Empty; + public string SclAccessPointName { get; set; } = string.Empty; + public List SelectedReferences { get; set; } = new(); + public List AnnunciatorReferences { get; set; } = new(); + public List CachedSignals { get; set; } = new(); +} + +/// +/// Stable, JSON-friendly discovery snapshot. It deliberately stores model metadata, +/// not live values, so an opened project can reconnect immediately without presenting +/// stale process data as current. +/// +public sealed class Iec61850CachedSignalProfile +{ + public string Name { get; set; } = string.Empty; + public string ObjectReference { get; set; } = string.Empty; + public string DisplayReference { get; set; } = string.Empty; + public string FunctionalConstraint { get; set; } = string.Empty; + public string DataType { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string Unit { get; set; } = string.Empty; + public string Confidence { get; set; } = "Medium"; + public string DataSetReference { get; set; } = string.Empty; + public string ReportControlReference { get; set; } = string.Empty; + public string ReportCoverageReason { get; set; } = string.Empty; + public string QualityReference { get; set; } = string.Empty; + public string TimestampReference { get; set; } = string.Empty; + public string Source { get; set; } = "Project cache"; + public bool IsReportCapable { get; set; } + public string ReportCoverage { get; set; } = "Polling fallback"; + public bool IsControlSignal { get; set; } + public string ControlCdc { get; set; } = string.Empty; + public string ControlModelReference { get; set; } = string.Empty; + public string ControlStatusReference { get; set; } = string.Empty; + public string ControlModelText { get; set; } = "Auto-detect"; + public string ControlValueType { get; set; } = string.Empty; + public bool IsSelected { get; set; } + + public static Iec61850CachedSignalProfile FromSignal(SignalDefinition signal) + { + ArgumentNullException.ThrowIfNull(signal); + return new Iec61850CachedSignalProfile + { + Name = signal.Name, + ObjectReference = signal.ObjectReference, + DisplayReference = signal.DisplayReference, + FunctionalConstraint = signal.FunctionalConstraint, + DataType = signal.DataType, + Category = signal.Category, + Unit = signal.Unit, + Confidence = signal.Confidence, + DataSetReference = signal.DataSetReference, + ReportControlReference = signal.ReportControlReference, + ReportCoverageReason = signal.ReportCoverageReason, + QualityReference = signal.QualityReference, + TimestampReference = signal.TimestampReference, + Source = signal.Source, + IsReportCapable = signal.IsReportCapable, + ReportCoverage = signal.ReportCoverage, + IsControlSignal = signal.IsControlSignal, + ControlCdc = signal.ControlCdc, + ControlModelReference = signal.ControlModelReference, + ControlStatusReference = signal.ControlStatusReference, + ControlModelText = signal.ControlModelText, + ControlValueType = signal.ControlValueType, + IsSelected = signal.IsSelected + }; + } + + public SignalDefinition ToSignal() + => new() + { + Name = Name, + ObjectReference = ObjectReference, + DisplayReference = DisplayReference, + FunctionalConstraint = FunctionalConstraint, + DataType = DataType, + Category = Category, + Unit = Unit, + Confidence = Confidence, + DataSetReference = DataSetReference, + ReportControlReference = ReportControlReference, + ReportCoverageReason = ReportCoverageReason, + QualityReference = QualityReference, + TimestampReference = TimestampReference, + Source = string.IsNullOrWhiteSpace(Source) ? "Project cache" : Source, + IsReportCapable = IsReportCapable, + ReportCoverage = ReportCoverage, + IsControlSignal = IsControlSignal, + ControlCdc = ControlCdc, + ControlModelReference = ControlModelReference, + ControlStatusReference = ControlStatusReference, + ControlModelText = ControlModelText, + ControlValueType = ControlValueType, + IsSelected = IsSelected, + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-", + ProbeStatus = "Restored from saved discovery model" + }; +} +, '.').Replace("..", ".").ToLowerInvariant(); internal static string StripIedNamePrefix(string? reference, string? iedName) { diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 212c7cf2e..ed7067290 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -619,9 +619,15 @@ public async Task> StartMonitoringAsync( throw new InvalidOperationException($"{device.Name} is not connected. Connect and discover this IED first."); var staticDataSetReportOnly = Iec61850MonitoringModeRegistry.IsStaticDataSetReportOnly(device); - var selected = selectedSignals + var eligible = selectedSignals .Where(signal => signal.IsSelected && signal.CanPublishToRuntime) .Where(signal => !staticDataSetReportOnly || !string.IsNullOrWhiteSpace(signal.DataSetReference)) + .ToArray(); + // PointKey is a historical normalized reference without FC. Validate + // BEFORE grouping or stopping the active monitor: two distinct IEC + // identities must never overwrite each other in runtime state maps. + EnsureDistinctRuntimePointIdentities(eligible); + var selected = eligible .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) .Select(group => group .OrderByDescending(signal => !string.IsNullOrWhiteSpace(signal.DataSetReference)) @@ -2899,6 +2905,36 @@ private static bool IsDegradedIecQuality(string? quality) normalized.Contains("reserved", StringComparison.Ordinal); } + // O(selected signals), once at explicit Start; no per-Report scans. + // Preserve legacy PointKey consumers until a coordinated migration can + // update SOE, planners, point lookup, persistence and UI atomically. + internal static void EnsureDistinctRuntimePointIdentities( + IEnumerable selectedSignals) + { + ArgumentNullException.ThrowIfNull(selectedSignals); + var seen = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var signal in selectedSignals) + { + var reference = IedBrowserLiveValues.CanonicalReference(signal.ObjectReference); + var fc = (signal.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant(); + var legacyKey = Iec61850MonitorPoint.NormalizeReference(signal.ObjectReference); + if (seen.TryGetValue(legacyKey, out var prior)) + { + if (!string.Equals(prior.Reference, reference, StringComparison.Ordinal) || + !string.Equals(prior.Fc, fc, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Cannot monitor ambiguous IEC identities: {prior.Reference} [{prior.Fc}] and " + + $"{reference} [{fc}] share legacy point key '{legacyKey}'. " + + "Review selection; values cannot be safely routed to separate points."); + } + else + { + seen.Add(legacyKey, (reference, fc)); + } + } + } + private static void IndexPointReference(DeviceSession session, Iec61850MonitorPoint point) { foreach (var key in GetReferenceKeys(point.IecReference)) diff --git a/tests/ARSAS.Tests/CanonicalRuntimePointIdentityM213GTests.cs b/tests/ARSAS.Tests/CanonicalRuntimePointIdentityM213GTests.cs new file mode 100644 index 000000000..face6c5c2 --- /dev/null +++ b/tests/ARSAS.Tests/CanonicalRuntimePointIdentityM213GTests.cs @@ -0,0 +1,74 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class CanonicalRuntimePointIdentityM213GTests +{ + private static SignalDefinition Signal(string reference, string fc) => new() + { + ObjectReference = reference, FunctionalConstraint = fc + }; + + [Fact] + public void SameLegacyKeyWithDistinctIecCaseFailsClosedBeforeStateConstruction() + { + var upper = Signal("LD/CSWI1.Pos.stVal", "ST"); + var lower = Signal("LD/CSWI1.pos.stVal", "ST"); + Assert.NotEqual( + IedBrowserLiveValues.CanonicalReference(upper.ObjectReference), + IedBrowserLiveValues.CanonicalReference(lower.ObjectReference)); + Assert.Equal( + Iec61850MonitorPoint.NormalizeReference(upper.ObjectReference), + Iec61850MonitorPoint.NormalizeReference(lower.ObjectReference)); + var ex = Assert.Throws(() => + Iec61850MonitorRuntime.EnsureDistinctRuntimePointIdentities([upper, lower])); + Assert.Contains("ambiguous IEC identities", ex.Message); + Assert.Contains("[ST]", ex.Message); + } + + [Fact] + public void DifferentFcOnSameObjectCannotAliasOneRuntimePoint() + { + var first = Signal("LD/MMXU1.Hz.mag.f", "MX"); + var second = Signal("LD/MMXU1.Hz.mag.f", "CF"); + var ex = Assert.Throws(() => + Iec61850MonitorRuntime.EnsureDistinctRuntimePointIdentities([first, second])); + Assert.Contains("[MX]", ex.Message); + Assert.Contains("[CF]", ex.Message); + } + + [Fact] + public void SameExactIdentityCanAppearTwiceWithoutFalseConflict() + { + Iec61850MonitorRuntime.EnsureDistinctRuntimePointIdentities([ + Signal("LD/MMXU1.Hz.mag.f", "mx"), + Signal("LD/MMXU1.Hz.mag.f", "MX"), + Signal("LD/MMXU1.A.phsA.cVal.mag.f", "MX"), + Signal("LD/MMXU1.A.phsB.cVal.mag.f", "MX") + ]); + } + + [Fact] + public void CollisionDetectionPrecedesDestructiveMonitorRestart() + { + var source = Source("Services/Iec61850MonitorRuntime.cs"); + var validate = source.IndexOf("EnsureDistinctRuntimePointIdentities(eligible);", + StringComparison.Ordinal); + var stop = source.IndexOf("await StopMonitoringSessionAsync(session, preservePointDefinitions: false)", + StringComparison.Ordinal); + Assert.True(validate >= 0 && stop > validate); + } + + private static string Source(string path) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var file = Path.Combine(directory.FullName, path); + if (File.Exists(file)) return File.ReadAllText(file); + directory = directory.Parent; + } + throw new FileNotFoundException(path); + } +} diff --git a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs index a0f25d5a2..5fdcc46b8 100644 --- a/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs +++ b/tests/ARSAS.Tests/IedBrowserReadResultInboxTests.cs @@ -76,9 +76,18 @@ public void UiReadHandlerUsesExplicitInboxCompletionBeforeResultsSubtitle() Assert.DoesNotContain("new Progress", source); // Checkout on Windows uses CRLF; source-based contracts are newline-neutral. var normalized = source.Replace("\r\n", "\n", StringComparison.Ordinal); - var finalDrain = normalized.IndexOf("DrainReadResults();\n if (!cts.IsCancellationRequested", StringComparison.Ordinal); + // Guarding a replaced model after the synchronous drain is valid and + // mandatory; the final drain must precede the success summary, not + // necessarily be adjacent to the success conditional. + var finishedProducers = normalized.IndexOf( + "// All producers have returned:", StringComparison.Ordinal); + var finalDrain = normalized.IndexOf( + " DrainReadResults();", finishedProducers, StringComparison.Ordinal); + var catalogFence = normalized.IndexOf( + "if (bindingIndex is not { IsCurrent: true })", finalDrain, StringComparison.Ordinal); var summary = normalized.IndexOf("Read complete ·", StringComparison.Ordinal); - Assert.True(finalDrain >= 0 && summary > finalDrain); + Assert.True(finishedProducers >= 0 && finalDrain > finishedProducers && + catalogFence > finalDrain && summary > catalogFence); Assert.Contains("sourceDevice.LiveValues.Association != readAssociation", source); } } From 2fadd9869a226a33c97f7ee10302e10da5bab023 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:23:54 +0700 Subject: [PATCH 5/5] fix(m2.13g): restore exact legacy reference normalization after safe source patch --- Models/MonitorModels.cs | 258 +--------------------------------------- 1 file changed, 1 insertion(+), 257 deletions(-) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index ec1435b73..5d62b7b64 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -953,263 +953,7 @@ private static string NormalizeSemanticValue(string? value) // canonical IEC identity: case and FC are intentionally not encoded. // Startup collision validation protects runtime dictionaries from aliasing. internal static string NormalizeReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('(string? reference, string? iedName) - { - var text = (reference ?? string.Empty).Trim().Replace('$', '.'); - var name = (iedName ?? string.Empty).Trim(); - var slash = text.IndexOf('/'); - if (slash <= 0 || string.IsNullOrWhiteSpace(name)) - return text; - - var domain = text[..slash]; - if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase) || domain.Length <= name.Length) - return text; - - return domain[name.Length..] + text[slash..]; - } -} - -public sealed class Iec61850PointSnapshot -{ - public required Iec61850MonitorPoint Point { get; init; } - public string PreviousValue { get; init; } = "-"; - public string Value { get; init; } = "-"; - public string Quality { get; init; } = "Unknown"; - public string DeviceTimestamp { get; init; } = "-"; - public string SourceMode { get; init; } = "MMS Polling"; - public string Reason { get; init; } = "cyclic"; - public string Status { get; init; } = "Live"; - /// - /// True only when the runtime observed a real semantic process-value transition. - /// This flag survives UI batching so a fast report/poll sequence cannot erase the - /// three-second commissioning highlight before WPF renders it. - /// - public bool IsValueEdge { get; init; } - public bool IsReportTraffic { get; init; } - // Association identity is stamped at runtime ingress, NOT reconstructed - // by the UI when a queued snapshot is eventually processed. - public long ObservationAssociation { get; init; } - // Per-point monotonic publication order, including metadata-only frames. - // Separate from Sequence, which is reserved for semantic process edges/SOE. - public long SnapshotRevision { get; init; } - // Association-local aggregate sourced only from real decoded report ingress. - // Immutable copy when published: safe across the runtime / WPF dispatcher. - public IReadOnlyList RoutedReportControlReferences { get; init; } = Array.Empty(); - public long Sequence { get; init; } -} - -public sealed class Iec61850EventEntry -{ - public long Sequence { get; init; } - public string DeviceId { get; init; } = string.Empty; - public string PointKey { get; init; } = string.Empty; - public string DeviceTimestamp { get; init; } = "-"; - public string DeviceName { get; init; } = string.Empty; - public string IpAddress { get; init; } = string.Empty; - public string SignalName { get; init; } = string.Empty; - public string IecReference { get; init; } = string.Empty; - public string IecDataType { get; init; } = string.Empty; - public string OldValue { get; init; } = "-"; - public string NewValue { get; init; } = "-"; - public string Quality { get; init; } = "Unknown"; - public string SourceMode { get; init; } = string.Empty; - public string Reason { get; init; } = string.Empty; - // Raw reporting identity, copied ONLY from a routed live report, never - // fabricated from static DataSet labels or a process-value edge. - public string ReportId { get; init; } = string.Empty; - public string ReportControlReference { get; init; } = string.Empty; - public IReadOnlyList RoutedReportControlReferences { get; init; } = Array.Empty(); - // Assigned only from future independently proven buffered replay evidence. - // BRCB membership or receipt of a buffered report does not prove replay. - public bool VerifiedBufferedReplay { get; init; } - // Operator labels are deliberately short. Full source evidence remains - // available in the export and the native report diagnostics. - public string EventCause => Iec61850EventOriginPolicy.Cause(Reason, SourceMode, VerifiedBufferedReplay); - public string AcquisitionShort => Iec61850EventOriginPolicy.Acquisition( - ReportId, ReportControlReference, SourceMode, RoutedReportControlReferences); - public string ReportChannel => Iec61850EventOriginPolicy.Channel(SourceMode); - public string EdgeType - { - get - { - if (TryParseBinaryState(OldValue, out var oldState) && - TryParseBinaryState(NewValue, out var newState) && - oldState != newState) - { - return newState ? "Rising" : "Falling"; - } - - return "State change"; - } - } - - public string ChangeText => $"{EdgeType} · {OldValue} → {NewValue}"; - public string EventValue => string.IsNullOrWhiteSpace(NewValue) ? "-" : NewValue; - public string DisplayValue => EventValue; - public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); - public string ValueTone => Iec61850ValueStatePresentation.Classify(EventValue, IecDataType); - public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( - EventValue, - IecDataType, - string.Empty, - IecReference); - public string QualityTone => Iec61850QualityPresentation.Classify(Quality); - - public string IecTelegram => Iec61850MonitorPoint.StripIedNamePrefix(IecReference, DeviceName); - - private static bool TryParseBinaryState(string? value, out bool state) - { - state = false; - var text = (value ?? string.Empty).Trim(); - if (bool.TryParse(text, out state)) - return true; - - if (text.Equals("1", StringComparison.OrdinalIgnoreCase) || - text.Equals("1.0", StringComparison.OrdinalIgnoreCase) || - text.Equals("on", StringComparison.OrdinalIgnoreCase) || - text.Equals("active", StringComparison.OrdinalIgnoreCase) || - text.Equals("asserted", StringComparison.OrdinalIgnoreCase)) - { - state = true; - return true; - } - - if (text.Equals("0", StringComparison.OrdinalIgnoreCase) || - text.Equals("0.0", StringComparison.OrdinalIgnoreCase) || - text.Equals("off", StringComparison.OrdinalIgnoreCase) || - text.Equals("inactive", StringComparison.OrdinalIgnoreCase) || - text.Equals("deasserted", StringComparison.OrdinalIgnoreCase)) - { - state = false; - return true; - } - - return false; - } -} - -public sealed class Iec61850TesterProject -{ - public int SchemaVersion { get; set; } = 3; - public string ProjectName { get; set; } = "ARSAS Session"; - public int DefaultPollingIntervalMs { get; set; } = 1000; - public List Devices { get; set; } = new(); -} - -public sealed class Iec61850TesterDeviceProfile -{ - public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); - public string Name { get; set; } = "IED"; - public string IdentitySource { get; set; } = string.Empty; - public string LogicalDeviceSummary { get; set; } = string.Empty; - public string IpAddress { get; set; } = string.Empty; - public int Port { get; set; } = 102; - public bool AllowDynamicDataSetWrites { get; set; } = true; - public bool DiscoverySucceeded { get; set; } - public string SclSourcePath { get; set; } = string.Empty; - public string SclSourceSha256 { get; set; } = string.Empty; - public string SclIedName { get; set; } = string.Empty; - public string SclAccessPointName { get; set; } = string.Empty; - public List SelectedReferences { get; set; } = new(); - public List AnnunciatorReferences { get; set; } = new(); - public List CachedSignals { get; set; } = new(); -} - -/// -/// Stable, JSON-friendly discovery snapshot. It deliberately stores model metadata, -/// not live values, so an opened project can reconnect immediately without presenting -/// stale process data as current. -/// -public sealed class Iec61850CachedSignalProfile -{ - public string Name { get; set; } = string.Empty; - public string ObjectReference { get; set; } = string.Empty; - public string DisplayReference { get; set; } = string.Empty; - public string FunctionalConstraint { get; set; } = string.Empty; - public string DataType { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; - public string Unit { get; set; } = string.Empty; - public string Confidence { get; set; } = "Medium"; - public string DataSetReference { get; set; } = string.Empty; - public string ReportControlReference { get; set; } = string.Empty; - public string ReportCoverageReason { get; set; } = string.Empty; - public string QualityReference { get; set; } = string.Empty; - public string TimestampReference { get; set; } = string.Empty; - public string Source { get; set; } = "Project cache"; - public bool IsReportCapable { get; set; } - public string ReportCoverage { get; set; } = "Polling fallback"; - public bool IsControlSignal { get; set; } - public string ControlCdc { get; set; } = string.Empty; - public string ControlModelReference { get; set; } = string.Empty; - public string ControlStatusReference { get; set; } = string.Empty; - public string ControlModelText { get; set; } = "Auto-detect"; - public string ControlValueType { get; set; } = string.Empty; - public bool IsSelected { get; set; } - - public static Iec61850CachedSignalProfile FromSignal(SignalDefinition signal) - { - ArgumentNullException.ThrowIfNull(signal); - return new Iec61850CachedSignalProfile - { - Name = signal.Name, - ObjectReference = signal.ObjectReference, - DisplayReference = signal.DisplayReference, - FunctionalConstraint = signal.FunctionalConstraint, - DataType = signal.DataType, - Category = signal.Category, - Unit = signal.Unit, - Confidence = signal.Confidence, - DataSetReference = signal.DataSetReference, - ReportControlReference = signal.ReportControlReference, - ReportCoverageReason = signal.ReportCoverageReason, - QualityReference = signal.QualityReference, - TimestampReference = signal.TimestampReference, - Source = signal.Source, - IsReportCapable = signal.IsReportCapable, - ReportCoverage = signal.ReportCoverage, - IsControlSignal = signal.IsControlSignal, - ControlCdc = signal.ControlCdc, - ControlModelReference = signal.ControlModelReference, - ControlStatusReference = signal.ControlStatusReference, - ControlModelText = signal.ControlModelText, - ControlValueType = signal.ControlValueType, - IsSelected = signal.IsSelected - }; - } - - public SignalDefinition ToSignal() - => new() - { - Name = Name, - ObjectReference = ObjectReference, - DisplayReference = DisplayReference, - FunctionalConstraint = FunctionalConstraint, - DataType = DataType, - Category = Category, - Unit = Unit, - Confidence = Confidence, - DataSetReference = DataSetReference, - ReportControlReference = ReportControlReference, - ReportCoverageReason = ReportCoverageReason, - QualityReference = QualityReference, - TimestampReference = TimestampReference, - Source = string.IsNullOrWhiteSpace(Source) ? "Project cache" : Source, - IsReportCapable = IsReportCapable, - ReportCoverage = ReportCoverage, - IsControlSignal = IsControlSignal, - ControlCdc = ControlCdc, - ControlModelReference = ControlModelReference, - ControlStatusReference = ControlStatusReference, - ControlModelText = ControlModelText, - ControlValueType = ControlValueType, - IsSelected = IsSelected, - Value = "-", - Quality = "Unknown", - DeviceTimestamp = "-", - ProbeStatus = "Restored from saved discovery model" - }; -} -, '.').Replace("..", ".").ToLowerInvariant(); + => (reference ?? string.Empty).Trim().Replace('$', '.').Replace("..", ".").ToLowerInvariant(); internal static string StripIedNamePrefix(string? reference, string? iedName) {