Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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++;
Expand Down Expand Up @@ -296,7 +299,14 @@ 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<IedBrowserReadProgress>(p =>
{
if (ReferenceEquals(_iedBrowserReadOperation, cts) &&
Expand All @@ -314,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);
Expand Down
4 changes: 2 additions & 2 deletions MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public partial class MainWindow : Window, INotifyPropertyChanged
private readonly CancellationTokenSource _applicationCancellation = new();
private readonly Dictionary<string, HashSet<string>> _pendingProjectSelections = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<SignalDefinition, Iec61850MonitorDevice> _signalOwners = new();
private readonly ConcurrentDictionary<string, CanonicalPointSnapshotBatch> _pendingPointSnapshots = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CanonicalPointSnapshotBatch> _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<string, IedBrowserReadBindingIndex> _canonicalSignalProjections =
Expand Down Expand Up @@ -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));
}
Expand Down
13 changes: 13 additions & 0 deletions Models/CanonicalPointSnapshotAuthority.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ public sealed record CanonicalPointSnapshotBatch(

public static class CanonicalPointSnapshotAuthority
{
/// <summary>
/// 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.
/// </summary>
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)
{
Expand Down
10 changes: 8 additions & 2 deletions Models/IedBrowserReadBindingIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SignalDefinition[]> _signals;
private readonly Dictionary<string, Iec61850MonitorPoint[]> _points;

Expand Down Expand Up @@ -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<string, List<SignalDefinition>> index,
Expand All @@ -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 ||
Expand Down Expand Up @@ -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) ||
Expand Down
22 changes: 21 additions & 1 deletion Models/MonitorModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
internal 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]
Expand Down Expand Up @@ -83,6 +98,7 @@ public SclIedWorkspace? SclWorkspace
{
if (ReferenceEquals(_sclWorkspace, value)) return;
_sclWorkspace = value;
System.Threading.Interlocked.Increment(ref _browserProjectionEpoch);
RefreshAuthoritativeSclComparison();
RefreshComputed();
}
Expand All @@ -95,6 +111,7 @@ public LiveIedModelDiscoveryDocument? LiveDiscoveryModel
{
if (ReferenceEquals(_liveDiscoveryModel, value)) return;
_liveDiscoveryModel = value;
System.Threading.Interlocked.Increment(ref _browserProjectionEpoch);
RefreshAuthoritativeSclComparison();
RefreshComputed();
}
Expand Down Expand Up @@ -932,7 +949,10 @@ private static string NormalizeSemanticValue(string? value)
return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
}

private static string NormalizeReference(string? reference)
// 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('$', '.').Replace("..", ".").ToLowerInvariant();

internal static string StripIedNamePrefix(string? reference, string? iedName)
Expand Down
38 changes: 37 additions & 1 deletion Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -619,9 +619,15 @@
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))
Expand Down Expand Up @@ -1436,7 +1442,7 @@
{
if (update.HasValue &&
IedBrowserReportValueAuthority.SafeScalar(update.Value) &&
!LooksLikeReferenceEcho(update.Value, update.Reference, update.Reference))

Check warning on line 1445 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 1445 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 1445 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 1445 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 1445 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 1445 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.
session.Device.LiveValues.PublishForAssociation(
session.BrowserObservationAssociation,
update.Reference, reportFc, update.Value,
Expand Down Expand Up @@ -2899,6 +2905,36 @@
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<SignalDefinition> selectedSignals)
{
ArgumentNullException.ThrowIfNull(selectedSignals);
var seen = new Dictionary<string, (string Reference, string Fc)>(
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))
Expand Down
152 changes: 152 additions & 0 deletions tests/ARSAS.Tests/CanonicalLiveValueSsotM213FTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using System.Collections.Concurrent;
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

/// <summary>M2.13F: same-size model swaps, Read/reconnect and exact IEC identity.</summary>
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<string, CanonicalPointSnapshotBatch>(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(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 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("CanonicalPointSnapshotAuthority.UiQueueKey(snapshot.Point)", 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);
Assert.Contains("Browser model changed during connection", source);
Assert.Contains("Browser catalog changed during Read", 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);
}
}
Loading
Loading