From 02a62427c57920ef858f55b4d0a1901f1a2d137d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:01:03 +0700 Subject: [PATCH 01/42] feat(p8): add defensive telemetry envelope --- Services/Iec61850TelemetryEnvelope.cs | 141 ++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 Services/Iec61850TelemetryEnvelope.cs diff --git a/Services/Iec61850TelemetryEnvelope.cs b/Services/Iec61850TelemetryEnvelope.cs new file mode 100644 index 000000000..72c0e1c26 --- /dev/null +++ b/Services/Iec61850TelemetryEnvelope.cs @@ -0,0 +1,141 @@ +using System.Globalization; + +namespace ArIED61850Tester.Services; + +/// +/// Normalized IEC 61850 telemetry boundary used between network decoding and application logic. +/// Missing/ambiguous data is never promoted to a valid process value: the source timestamp +/// stays unknown and the quality is forced Invalid while ReceivedAtUtc records local receipt. +/// +public enum Iec61850TelemetryQualityState +{ + Good, + Questionable, + Invalid +} + +public readonly record struct Iec61850TelemetryEnvelope( + object? Value, + string DisplayValue, + Iec61850TelemetryQualityState QualityState, + string QualityText, + DateTimeOffset? SourceTimestampUtc, + DateTimeOffset ReceivedAtUtc, + string SourceReference, + string Diagnostic) +{ + public bool HasProcessValue => Value is not null || !string.IsNullOrWhiteSpace(DisplayValue); + public bool IsValid => HasProcessValue && QualityState != Iec61850TelemetryQualityState.Invalid; + + public static Iec61850TelemetryEnvelope FromReadValue( + Iec61850ReadValue? read, + DateTimeOffset? receivedAtUtc = null) + { + var received = receivedAtUtc ?? DateTimeOffset.UtcNow; + if (read is null) + { + return Invalid( + received, + sourceReference: string.Empty, + diagnostic: "IEC 61850 read returned no value object."); + } + + var display = read.DisplayValue?.Trim() ?? string.Empty; + var hasValue = read.Value is not null || (display.Length > 0 && display != "-"); + var qualityText = NormalizeQualityText(read.Quality); + var qualityState = ClassifyQuality(qualityText, hasValue); + var sourceTimestamp = TryParseSourceTimestampUtc(read.DeviceTimestamp); + var sourceReference = FirstNonEmpty(read.SourceReference, read.ReadReference); + + var diagnostic = qualityState == Iec61850TelemetryQualityState.Invalid + ? hasValue + ? $"Telemetry quality is invalid ({qualityText})." + : "Telemetry contains no process value." + : sourceTimestamp is null && read.HasDeviceTimestamp + ? "Device timestamp was present but could not be parsed safely; source timestamp remains unknown." + : string.Empty; + + return new Iec61850TelemetryEnvelope( + read.Value, + display, + qualityState, + qualityText, + sourceTimestamp, + received, + sourceReference, + diagnostic); + } + + public static Iec61850TelemetryEnvelope Invalid( + DateTimeOffset receivedAtUtc, + string sourceReference, + string diagnostic, + object? safePlaceholder = null) + => new( + safePlaceholder, + safePlaceholder?.ToString() ?? "-", + Iec61850TelemetryQualityState.Invalid, + "Invalid", + SourceTimestampUtc: null, + receivedAtUtc, + sourceReference?.Trim() ?? string.Empty, + diagnostic?.Trim() ?? string.Empty); + + internal static DateTimeOffset? TryParseSourceTimestampUtc(string? value) + { + var text = value?.Trim() ?? string.Empty; + if (text.Length == 0 || text == "-") + return null; + + // IEC 61850 timestamps are UTC-based. Only publish a parsed source timestamp when + // parsing succeeds; never substitute DateTime.Now/ReceivedAtUtc for missing data. + if (!DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsed)) + { + return null; + } + + return parsed.ToUniversalTime(); + } + + private static Iec61850TelemetryQualityState ClassifyQuality(string quality, bool hasValue) + { + if (!hasValue) + return Iec61850TelemetryQualityState.Invalid; + + if (quality.Contains("invalid", StringComparison.OrdinalIgnoreCase) || + quality.Contains("failure", StringComparison.OrdinalIgnoreCase) || + quality.Contains("bad", StringComparison.OrdinalIgnoreCase) || + quality.Contains("outofrange", StringComparison.OrdinalIgnoreCase) || + quality.Contains("out-of-range", StringComparison.OrdinalIgnoreCase)) + { + return Iec61850TelemetryQualityState.Invalid; + } + + if (quality.Length == 0 || quality == "-" || + quality.Contains("unknown", StringComparison.OrdinalIgnoreCase) || + quality.Contains("questionable", StringComparison.OrdinalIgnoreCase) || + quality.Contains("olddata", StringComparison.OrdinalIgnoreCase) || + quality.Contains("old-data", StringComparison.OrdinalIgnoreCase) || + quality.Contains("substituted", StringComparison.OrdinalIgnoreCase) || + quality.Contains("test", StringComparison.OrdinalIgnoreCase)) + { + return Iec61850TelemetryQualityState.Questionable; + } + + return Iec61850TelemetryQualityState.Good; + } + + private static string NormalizeQualityText(string? value) + { + var text = value?.Trim() ?? string.Empty; + return text.Length == 0 ? "Unknown" : text; + } + + private static string FirstNonEmpty(params string?[] values) + => values.Select(value => value?.Trim() ?? string.Empty) + .FirstOrDefault(value => value.Length > 0) ?? string.Empty; +} From a939b51836b4b89868431ea18ba7311a0dc9b901 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:01:16 +0700 Subject: [PATCH 02/42] feat(p8): normalize read values without false timestamps --- Services/Iec61850ReadValue.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Services/Iec61850ReadValue.cs b/Services/Iec61850ReadValue.cs index 40207fa07..93f475965 100644 --- a/Services/Iec61850ReadValue.cs +++ b/Services/Iec61850ReadValue.cs @@ -12,8 +12,19 @@ public sealed class Iec61850ReadValue public string ReadReference { get; init; } = string.Empty; public string Projection { get; init; } = string.Empty; + /// + /// Local receipt time. This is deliberately separate from DeviceTimestamp: an absent or + /// malformed relay timestamp must never be replaced with the PC clock and presented as + /// source evidence. + /// + public DateTimeOffset ReceivedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public bool HasQuality => !string.IsNullOrWhiteSpace(Quality) && Quality != "-"; public bool HasDeviceTimestamp => !string.IsNullOrWhiteSpace(DeviceTimestamp) && DeviceTimestamp != "-"; + public DateTimeOffset? SourceTimestampUtc => Iec61850TelemetryEnvelope.TryParseSourceTimestampUtc(DeviceTimestamp); + + public Iec61850TelemetryEnvelope ToTelemetryEnvelope() + => Iec61850TelemetryEnvelope.FromReadValue(this, ReceivedAtUtc); public override string ToString() { From d76f8bbc4aec007dd7bf45b9e930090c0f2ccfcd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:01:31 +0700 Subject: [PATCH 03/42] feat(p8): add lossless-safe UI latest-value batcher --- Services/LatestValueUiBatcher.cs | 137 +++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 Services/LatestValueUiBatcher.cs diff --git a/Services/LatestValueUiBatcher.cs b/Services/LatestValueUiBatcher.cs new file mode 100644 index 000000000..38d99f32e --- /dev/null +++ b/Services/LatestValueUiBatcher.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; + +namespace ArIED61850Tester.Services; + +/// +/// Coalesces only the UI projection of high-rate telemetry. Raw report/GOOSE/SOE processing +/// must remain upstream and event-by-event; this class intentionally keeps only the latest +/// visual value per key until the next bounded flush. +/// +public sealed class LatestValueUiBatcher : IAsyncDisposable + where TKey : notnull +{ + public static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(150); + + private sealed record PendingValue(long Version, TValue Value); + + private readonly ConcurrentDictionary _pending = new(); + private readonly Func, CancellationToken, ValueTask> _flushAsync; + private readonly TimeSpan _interval; + private readonly CancellationTokenSource _stop = new(); + private readonly Task _pump; + private long _version; + private long _published; + private long _flushed; + private int _disposeStarted; + + public LatestValueUiBatcher( + Func, CancellationToken, ValueTask> flushAsync, + TimeSpan? interval = null) + { + _flushAsync = flushAsync ?? throw new ArgumentNullException(nameof(flushAsync)); + _interval = interval ?? DefaultInterval; + if (_interval < TimeSpan.FromMilliseconds(50) || _interval > TimeSpan.FromSeconds(2)) + throw new ArgumentOutOfRangeException(nameof(interval), "UI batch interval must stay between 50 ms and 2 s."); + + _pump = Task.Run(PumpAsync); + } + + public long PublishedCount => Interlocked.Read(ref _published); + public long FlushedCount => Interlocked.Read(ref _flushed); + public long CoalescedCount => Math.Max(0, PublishedCount - FlushedCount - _pending.Count); + public int PendingKeyCount => _pending.Count; + + public bool TryPublish(TKey key, TValue value) + { + if (Volatile.Read(ref _disposeStarted) != 0) + return false; + + var version = Interlocked.Increment(ref _version); + _pending[key] = new PendingValue(version, value); + Interlocked.Increment(ref _published); + return true; + } + + public async ValueTask FlushNowAsync(CancellationToken cancellationToken = default) + { + if (Volatile.Read(ref _disposeStarted) != 0 && _pending.IsEmpty) + return; + + var batch = DrainLatest(); + if (batch.Count == 0) + return; + + await _flushAsync(batch, cancellationToken).ConfigureAwait(false); + Interlocked.Add(ref _flushed, batch.Count); + } + + private async Task PumpAsync() + { + using var timer = new PeriodicTimer(_interval); + try + { + while (await timer.WaitForNextTickAsync(_stop.Token).ConfigureAwait(false)) + await FlushNowAsync(_stop.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) + { + } + catch + { + // The UI projection is non-authoritative. A presentation failure must not tear + // down network acquisition; a later explicit FlushNowAsync can still drain data. + } + } + + private List DrainLatest() + { + var batch = new List(_pending.Count); + var collection = (ICollection>)_pending; + + foreach (var pair in _pending.ToArray()) + { + // ICollection.Remove(KeyValuePair) is an atomic key+value conditional removal for + // ConcurrentDictionary. If a newer value arrived after ToArray(), removal fails + // and the newer value remains pending for the next flush instead of being lost. + if (collection.Remove(pair)) + batch.Add(pair.Value.Value); + } + + return batch; + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + return; + + _stop.Cancel(); + try + { + await _pump.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + + // Best-effort final visual delivery. Disposal is not a process-evidence boundary; + // authoritative report/SOE data has already been processed upstream. + try + { + var finalBatch = DrainLatest(); + if (finalBatch.Count > 0) + { + await _flushAsync(finalBatch, CancellationToken.None).ConfigureAwait(false); + Interlocked.Add(ref _flushed, finalBatch.Count); + } + } + catch + { + } + finally + { + _pending.Clear(); + _stop.Dispose(); + } + } +} From 27687fecf0a917ae5a57ba7f8c0207b5fab0a2de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:04:22 +0700 Subject: [PATCH 04/42] feat(p8): add pooled transient byte-buffer lease --- Services/PooledByteBufferLease.cs | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Services/PooledByteBufferLease.cs diff --git a/Services/PooledByteBufferLease.cs b/Services/PooledByteBufferLease.cs new file mode 100644 index 000000000..5ee3ba07c --- /dev/null +++ b/Services/PooledByteBufferLease.cs @@ -0,0 +1,60 @@ +using System.Buffers; + +namespace ArIED61850Tester.Services; + +/// +/// ArrayPool-backed lease for transient network/codec buffers owned by ARSAS. +/// Do not use this for buffers whose lifetime is owned by ARIEC61850, Npcap, WPF bindings, +/// report snapshots, or evidence objects. Pooling is deliberately limited to byte buffers +/// with a clear rent/use/return lifetime so use-after-return cannot corrupt process data. +/// +public sealed class PooledByteBufferLease : IDisposable +{ + private byte[]? _buffer; + private readonly int _length; + private readonly bool _clearOnReturn; + + private PooledByteBufferLease(int minimumLength, bool clearOnReturn) + { + if (minimumLength <= 0) + throw new ArgumentOutOfRangeException(nameof(minimumLength)); + + _buffer = ArrayPool.Shared.Rent(minimumLength); + _length = minimumLength; + _clearOnReturn = clearOnReturn; + } + + public int Length => _length; + + public Memory Memory + { + get + { + var buffer = Volatile.Read(ref _buffer) + ?? throw new ObjectDisposedException(nameof(PooledByteBufferLease)); + return buffer.AsMemory(0, _length); + } + } + + public Span Span + { + get + { + var buffer = Volatile.Read(ref _buffer) + ?? throw new ObjectDisposedException(nameof(PooledByteBufferLease)); + return buffer.AsSpan(0, _length); + } + } + + public static PooledByteBufferLease Rent(int minimumLength, bool clearOnReturn = false) + => new(minimumLength, clearOnReturn); + + public void Dispose() + { + var buffer = Interlocked.Exchange(ref _buffer, null); + if (buffer is null) + return; + + ArrayPool.Shared.Return(buffer, _clearOnReturn); + } +} From 621fe1d569d755faa062b0d1b6c06dc690a431f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:04:32 +0700 Subject: [PATCH 05/42] feat(p8): add low-overhead runtime allocation metrics --- Services/RuntimeAllocationSnapshot.cs | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 Services/RuntimeAllocationSnapshot.cs diff --git a/Services/RuntimeAllocationSnapshot.cs b/Services/RuntimeAllocationSnapshot.cs new file mode 100644 index 000000000..98f5d2ef1 --- /dev/null +++ b/Services/RuntimeAllocationSnapshot.cs @@ -0,0 +1,52 @@ +namespace ArIED61850Tester.Services; + +/// +/// Low-overhead allocation/GC snapshot for field diagnostics and regression baselines. +/// Capturing a snapshot does not force a collection and is safe on the monitoring path. +/// +public readonly record struct RuntimeAllocationSnapshot( + DateTimeOffset CapturedAtUtc, + long TotalAllocatedBytes, + long HeapSizeBytes, + long FragmentedBytes, + int Gen0Collections, + int Gen1Collections, + int Gen2Collections) +{ + public static RuntimeAllocationSnapshot Capture() + { + var memory = GC.GetGCMemoryInfo(); + return new RuntimeAllocationSnapshot( + DateTimeOffset.UtcNow, + GC.GetTotalAllocatedBytes(precise: false), + memory.HeapSizeBytes, + memory.FragmentedBytes, + GC.CollectionCount(0), + GC.CollectionCount(1), + GC.CollectionCount(2)); + } + + public RuntimeAllocationDelta DeltaFrom(RuntimeAllocationSnapshot earlier) + => new( + Math.Max(0, TotalAllocatedBytes - earlier.TotalAllocatedBytes), + HeapSizeBytes - earlier.HeapSizeBytes, + FragmentedBytes - earlier.FragmentedBytes, + Math.Max(0, Gen0Collections - earlier.Gen0Collections), + Math.Max(0, Gen1Collections - earlier.Gen1Collections), + Math.Max(0, Gen2Collections - earlier.Gen2Collections), + CapturedAtUtc - earlier.CapturedAtUtc); +} + +public readonly record struct RuntimeAllocationDelta( + long AllocatedBytes, + long HeapSizeDeltaBytes, + long FragmentedBytesDelta, + int Gen0Collections, + int Gen1Collections, + int Gen2Collections, + TimeSpan Elapsed) +{ + public double AllocatedMegabytes => AllocatedBytes / (1024d * 1024d); + public double AllocatedMegabytesPerSecond => + Elapsed.TotalSeconds <= 0d ? 0d : AllocatedMegabytes / Elapsed.TotalSeconds; +} From dd6171213f5739450898caedbd3dfe09a35aa680 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:04:45 +0700 Subject: [PATCH 06/42] test(p8): cover defensive telemetry normalization --- tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs diff --git a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs new file mode 100644 index 000000000..9a76cac49 --- /dev/null +++ b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs @@ -0,0 +1,97 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class P8TelemetryEnvelopeTests +{ + [Fact] + public void NullRead_IsInvalid_AndDoesNotInventSourceTimestamp() + { + var received = new DateTimeOffset(2026, 9, 11, 0, 0, 0, TimeSpan.Zero); + + var envelope = Iec61850TelemetryEnvelope.FromReadValue(null, received); + + Assert.False(envelope.IsValid); + Assert.Equal(Iec61850TelemetryQualityState.Invalid, envelope.QualityState); + Assert.Null(envelope.SourceTimestampUtc); + Assert.Equal(received, envelope.ReceivedAtUtc); + } + + [Fact] + public void MissingProcessValue_RemainsInvalid_EvenWhenQualitySaysGood() + { + var read = new Iec61850ReadValue + { + Value = null, + DisplayValue = "-", + Quality = "Good", + DeviceTimestamp = "2026-09-11T01:02:03Z" + }; + + var envelope = read.ToTelemetryEnvelope(); + + Assert.False(envelope.IsValid); + Assert.Equal(Iec61850TelemetryQualityState.Invalid, envelope.QualityState); + } + + [Fact] + public void MalformedRelayTimestamp_RemainsUnknown_InsteadOfUsingPcClock() + { + var received = new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero); + var read = new Iec61850ReadValue + { + Value = true, + DisplayValue = "True", + Quality = "Good", + DeviceTimestamp = "not-a-relay-timestamp", + ReceivedAtUtc = received + }; + + var envelope = read.ToTelemetryEnvelope(); + + Assert.True(envelope.IsValid); + Assert.Null(envelope.SourceTimestampUtc); + Assert.Equal(received, envelope.ReceivedAtUtc); + Assert.Contains("could not be parsed", envelope.Diagnostic, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GoodTelemetry_PreservesSourceTimestampSeparatelyFromReceiptTime() + { + var received = new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero); + var read = new Iec61850ReadValue + { + Value = 1, + DisplayValue = "1", + Quality = "Good", + DeviceTimestamp = "2026-09-11T01:02:03.125Z", + SourceReference = "LD0/LLN0.Mod.stVal", + ReceivedAtUtc = received + }; + + var envelope = read.ToTelemetryEnvelope(); + + Assert.True(envelope.IsValid); + Assert.Equal(Iec61850TelemetryQualityState.Good, envelope.QualityState); + Assert.Equal(new DateTimeOffset(2026, 9, 11, 1, 2, 3, 125, TimeSpan.Zero), envelope.SourceTimestampUtc); + Assert.Equal(received, envelope.ReceivedAtUtc); + Assert.Equal("LD0/LLN0.Mod.stVal", envelope.SourceReference); + } + + [Theory] + [InlineData("Invalid")] + [InlineData("Bad / communication error")] + [InlineData("Failure")] + public void DegradedQuality_IsNeverPromotedToGood(string quality) + { + var envelope = Iec61850TelemetryEnvelope.FromReadValue(new Iec61850ReadValue + { + Value = 1, + DisplayValue = "1", + Quality = quality + }); + + Assert.Equal(Iec61850TelemetryQualityState.Invalid, envelope.QualityState); + Assert.False(envelope.IsValid); + } +} From 013dd0ac5a8fb1f8f3d2864bb5b1066f72b41061 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:05:00 +0700 Subject: [PATCH 07/42] test(p8): prove latest-value UI batching without edge loss contract --- .../P8LatestValueUiBatcherTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/ARSAS.Tests/P8LatestValueUiBatcherTests.cs diff --git a/tests/ARSAS.Tests/P8LatestValueUiBatcherTests.cs b/tests/ARSAS.Tests/P8LatestValueUiBatcherTests.cs new file mode 100644 index 000000000..6a9c90cee --- /dev/null +++ b/tests/ARSAS.Tests/P8LatestValueUiBatcherTests.cs @@ -0,0 +1,66 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class P8LatestValueUiBatcherTests +{ + [Fact] + public async Task SameVisualKey_IsCoalescedToLatestValue() + { + var flushed = new List(); + await using var batcher = new LatestValueUiBatcher( + (batch, _) => + { + lock (flushed) + flushed.AddRange(batch); + return ValueTask.CompletedTask; + }, + TimeSpan.FromSeconds(2)); + + for (var value = 1; value <= 100; value++) + Assert.True(batcher.TryPublish("IED1|LD0/XCBR1.Pos.stVal", value)); + + await batcher.FlushNowAsync(); + + Assert.Single(flushed); + Assert.Equal(100, flushed[0]); + Assert.Equal(100, batcher.PublishedCount); + Assert.Equal(1, batcher.FlushedCount); + Assert.True(batcher.CoalescedCount >= 99); + } + + [Fact] + public async Task IndependentKeys_AreFlushedIndependently() + { + var flushed = new List(); + await using var batcher = new LatestValueUiBatcher( + (batch, _) => + { + lock (flushed) + flushed.AddRange(batch); + return ValueTask.CompletedTask; + }, + TimeSpan.FromSeconds(2)); + + batcher.TryPublish("IED1|A", "IED1-new"); + batcher.TryPublish("IED2|B", "IED2-new"); + await batcher.FlushNowAsync(); + + Assert.Equal(2, flushed.Count); + Assert.Contains("IED1-new", flushed); + Assert.Contains("IED2-new", flushed); + } + + [Fact] + public async Task Dispose_StopsAcceptingNewVisualUpdates() + { + var batcher = new LatestValueUiBatcher( + (_, _) => ValueTask.CompletedTask, + TimeSpan.FromSeconds(2)); + + Assert.True(batcher.TryPublish("IED1|A", 1)); + await batcher.DisposeAsync(); + + Assert.False(batcher.TryPublish("IED1|A", 2)); + } +} From f4edd70d3a4deb703ec254e440001b00f538be57 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:05:09 +0700 Subject: [PATCH 08/42] test(p8): cover pooled buffers and allocation snapshots --- tests/ARSAS.Tests/P8MemoryPerformanceTests.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/ARSAS.Tests/P8MemoryPerformanceTests.cs diff --git a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs new file mode 100644 index 000000000..a26158e0f --- /dev/null +++ b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs @@ -0,0 +1,37 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class P8MemoryPerformanceTests +{ + [Fact] + public void PooledByteBufferLease_ExposesRequestedLength_AndDisposeIsIdempotent() + { + var lease = PooledByteBufferLease.Rent(1024, clearOnReturn: true); + Assert.Equal(1024, lease.Length); + Assert.Equal(1024, lease.Memory.Length); + + lease.Span[0] = 0x61; + Assert.Equal((byte)0x61, lease.Span[0]); + + lease.Dispose(); + lease.Dispose(); + Assert.Throws(() => _ = lease.Memory); + } + + [Fact] + public void AllocationSnapshot_DeltaNeverReportsNegativeAllocatedBytesOrCollectionCounts() + { + var before = RuntimeAllocationSnapshot.Capture(); + _ = new byte[4096]; + var after = RuntimeAllocationSnapshot.Capture(); + + var delta = after.DeltaFrom(before); + + Assert.True(delta.AllocatedBytes >= 0); + Assert.True(delta.Gen0Collections >= 0); + Assert.True(delta.Gen1Collections >= 0); + Assert.True(delta.Gen2Collections >= 0); + Assert.True(delta.AllocatedMegabytesPerSecond >= 0d); + } +} From 883d5287c6a5864cbd9bd13d28c1c301a1da613a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:05:28 +0700 Subject: [PATCH 09/42] test(p8): lock runtime isolation batching virtualization and disposal contracts --- .../P8RuntimeArchitectureRegressionTests.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs diff --git a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs new file mode 100644 index 000000000..139452d0b --- /dev/null +++ b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs @@ -0,0 +1,99 @@ +namespace ARSAS.Tests; + +public sealed class P8RuntimeArchitectureRegressionTests +{ + [Fact] + public void IecRuntimeFacade_IsolatesLifecycleOperationsPerIed_OffDispatcherThread() + { + var source = Read("Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"); + + Assert.Contains("ConcurrentDictionary", source, StringComparison.Ordinal); + Assert.Contains("OperationGate", source, StringComparison.Ordinal); + Assert.Contains("StopGate", source, StringComparison.Ordinal); + Assert.Contains("RunPreemptiveStopAsync", source, StringComparison.Ordinal); + Assert.Contains("Task.Run(", source, StringComparison.Ordinal); + Assert.Contains("ActiveOperationCancellation", source, StringComparison.Ordinal); + Assert.Contains("Generation", source, StringComparison.Ordinal); + } + + [Fact] + public void MonitoringRuntime_KeepsReconnectStateInsideEachDeviceSession() + { + var source = Read("Services/Iec61850MonitorRuntime.cs"); + + Assert.Contains("ConcurrentDictionary", source, StringComparison.Ordinal); + Assert.Contains("CancellationTokenSource MonitorCancellation", source, StringComparison.Ordinal); + Assert.Contains("DateTime NextReconnectUtc", source, StringComparison.Ordinal); + Assert.Contains("int ConsecutiveReconnectFailures", source, StringComparison.Ordinal); + Assert.Contains("TryReconnectAsync(DeviceSession session", source, StringComparison.Ordinal); + Assert.Contains("SmartReconnectPolicy.GetRetryDelay(attempt)", source, StringComparison.Ordinal); + } + + [Fact] + public void MainWindow_CoalescesOnlyPointProjection_WhileSoeRemainsQueuedLosslessly() + { + var source = Read("MainWindow.xaml.cs"); + + Assert.Contains("ConcurrentDictionary _pendingPointSnapshots", source, StringComparison.Ordinal); + Assert.Contains("ConcurrentQueue _pendingEvents", source, StringComparison.Ordinal); + Assert.Contains("Interval = TimeSpan.FromMilliseconds(200)", source, StringComparison.Ordinal); + Assert.Contains("_pendingPointSnapshots.AddOrUpdate", source, StringComparison.Ordinal); + Assert.Contains("_pendingEvents.Enqueue(entry)", source, StringComparison.Ordinal); + Assert.Contains("while (eventBatch.Count < 1000 && _pendingEvents.TryDequeue", source, StringComparison.Ordinal); + } + + [Fact] + public void FatGrid_UsesRowAndColumnRecyclingVirtualization() + { + var source = Read("IoListTestingWindow.xaml"); + + Assert.Contains("EnableRowVirtualization=\"True\"", source, StringComparison.Ordinal); + Assert.Contains("EnableColumnVirtualization=\"True\"", source, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.IsVirtualizing=\"True\"", source, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.VirtualizationMode=\"Recycling\"", source, StringComparison.Ordinal); + Assert.Contains("ScrollViewer.CanContentScroll=\"True\"", source, StringComparison.Ordinal); + } + + [Fact] + public void Shutdown_IsBounded_AndRuntimeOwnsAsyncDisposal() + { + var mainWindow = Read("MainWindow.xaml.cs"); + var facade = Read("Services/UiResponsiveIec61850MonitorRuntimeFacade.cs"); + + Assert.Contains("_uiFlushTimer.Stop();", mainWindow, StringComparison.Ordinal); + Assert.Contains("_progressAnimationTimer.Stop();", mainWindow, StringComparison.Ordinal); + Assert.Contains("_applicationCancellation.Cancel();", mainWindow, StringComparison.Ordinal); + Assert.Contains("_runtime.DisposeAsync().AsTask()", mainWindow, StringComparison.Ordinal); + Assert.Contains("DisposeBudget = TimeSpan.FromSeconds(3)", facade, StringComparison.Ordinal); + Assert.Contains("activeCancellation?.Cancel();", facade, StringComparison.Ordinal); + Assert.Contains("_deviceSlots.Clear();", facade, StringComparison.Ordinal); + } + + [Fact] + public void DefensiveTelemetry_DoesNotReplaceMissingRelayTimeWithPcTime() + { + var source = Read("Services/Iec61850TelemetryEnvelope.cs"); + + Assert.Contains("DateTimeOffset? SourceTimestampUtc", source, StringComparison.Ordinal); + Assert.Contains("DateTimeOffset ReceivedAtUtc", source, StringComparison.Ordinal); + Assert.Contains("SourceTimestampUtc: null", source, StringComparison.Ordinal); + Assert.DoesNotContain("SourceTimestampUtc = DateTime", source, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(Path.Combine(FindRepoRoot(), relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "MainWindow.xaml")) && + Directory.Exists(Path.Combine(directory.FullName, "tests", "ARSAS.Tests"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate repository root."); + } +} From 78c5d009f74ca36d1ab9dd1e3c51ab8de4a78314 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:06:02 +0700 Subject: [PATCH 10/42] docs(p8): document runtime performance and resilience invariants --- docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md | 75 +++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md diff --git a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md new file mode 100644 index 000000000..4613dc435 --- /dev/null +++ b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md @@ -0,0 +1,75 @@ +# P8 — Runtime Performance & Resilience + +P8 hardens ARSAS for long-running, multi-IED IEC 61850 work without trading process evidence for UI smoothness. + +## Non-negotiable invariants + +1. **Network/process evidence is lossless.** IEC 61850 Report/GOOSE/SOE processing stays event-by-event. Only the visual latest-value projection may be coalesced. +2. **Missing data is never promoted to a valid measurement.** A missing value or unusable quality becomes `Invalid`; an absent relay timestamp stays unknown. `ReceivedAtUtc` is separate local receipt metadata. +3. **One IED cannot stall another.** Lifecycle gates, monitor cancellation, reconnect state, client instances, and report recovery are scoped per device. +4. **Native/vendor work does not execute its synchronous prefix on the WPF Dispatcher.** The UI-facing runtime facade offloads IEC 61850 lifecycle operations and provides a pre-emptive stop lane. +5. **Shutdown is bounded.** Native teardown is best effort and may not freeze the application indefinitely. +6. **Pooling is ownership-safe.** Only ARSAS-owned transient byte buffers with a strict rent/use/return lifetime may use `ArrayPool`. ARIEC61850/Npcap-owned frames and WPF-bound objects are not pooled by ARSAS. + +## P8 coverage + +### P8.1 — Per-IED async connection and reconnect isolation + +The existing runtime already uses a per-device operation slot in the UI facade and a per-device `DeviceSession` in the monitor runtime. P8 regression tests lock these contracts so future refactors cannot accidentally introduce one global lifecycle/reconnect gate. + +Reconnect replaces only the affected device client, uses bounded cleanup/connect budgets, resets only that session's report state, and re-arms reporting asynchronously after MMS association recovery. + +### P8.2 — UI throttling and batching + +The Engineering live workspace already separates acquisition from presentation: + +- point snapshots are coalesced by point key, +- SOE entries remain in a FIFO `ConcurrentQueue`, +- diagnostics remain queued separately, +- the WPF projection flushes every 200 ms at `DispatcherPriority.Background`, +- event batches preserve every queued SOE edge. + +`LatestValueUiBatcher` is a reusable P8 primitive for additional high-rate UI surfaces. It keeps only the latest visual value per key and uses conditional removal so a newer concurrent update cannot be deleted by an older drain. + +### P8.3 — Virtualized large grids + +The FAT DataGrid uses row and column virtualization, recycling mode, and content scrolling. P8 adds regression coverage for these settings. Large future grids should use the same contract rather than rendering all rows/cells. + +### P8.4 — Memory/leak prevention and bounded disposal + +Main-window timers are stopped during shutdown; the application CTS is cancelled; GOOSE and IEC 61850 runtimes are asynchronously disposed behind bounded shutdown waits. The facade cancels every active per-device operation before disposing the inner runtime. + +### P8.5 — Defensive telemetry envelope + +`Iec61850TelemetryEnvelope` normalizes nullable network read output. It keeps three distinct concepts: + +- process value, +- source/relay timestamp (`SourceTimestampUtc`), +- local receipt time (`ReceivedAtUtc`). + +A missing process value is `Invalid` even if a malformed upstream object claims `Good`. A malformed source timestamp remains `null`; ARSAS does not fabricate relay evidence from the PC clock. + +### P8.6 — Allocation profiling + +`RuntimeAllocationSnapshot` captures total allocated bytes, managed heap size, fragmentation, and generation collection counters without forcing GC. It provides deltas for repeatable field/performance baselines. + +### P8.7 — Ownership-safe buffer pooling + +`PooledByteBufferLease` provides an idempotent `ArrayPool` lease for ARSAS-owned transient codec/network buffers. It is intentionally not applied blindly to report snapshots, signal models, GOOSE frame objects, WPF ViewModels, or buffers owned by ARIEC61850/Npcap. + +Before converting an additional hot path to pooling, capture a P8.6 allocation baseline and prove that ARSAS owns the complete buffer lifetime. This avoids use-after-return corruption in protection/control evidence. + +## Regression tests + +P8 adds tests for: + +- null/malformed telemetry normalization, +- no fabricated relay timestamp, +- latest-value UI coalescing semantics, +- independent UI keys, +- pooled-buffer lifetime and idempotent disposal, +- allocation-snapshot deltas, +- per-IED runtime/reconnect isolation source contracts, +- lossless SOE queue versus coalesced point projection, +- FAT grid virtualization, +- bounded shutdown/disposal contracts. From 196b8a8e7d79809e17a72048d38ec70aeaf0029c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:08:37 +0700 Subject: [PATCH 11/42] fix(p8): keep empty placeholder outside process-value semantics --- Services/Iec61850TelemetryEnvelope.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Services/Iec61850TelemetryEnvelope.cs b/Services/Iec61850TelemetryEnvelope.cs index 72c0e1c26..b0a1acb12 100644 --- a/Services/Iec61850TelemetryEnvelope.cs +++ b/Services/Iec61850TelemetryEnvelope.cs @@ -24,7 +24,8 @@ public readonly record struct Iec61850TelemetryEnvelope( string SourceReference, string Diagnostic) { - public bool HasProcessValue => Value is not null || !string.IsNullOrWhiteSpace(DisplayValue); + public bool HasProcessValue => Value is not null || + (!string.IsNullOrWhiteSpace(DisplayValue) && DisplayValue != "-"); public bool IsValid => HasProcessValue && QualityState != Iec61850TelemetryQualityState.Invalid; public static Iec61850TelemetryEnvelope FromReadValue( @@ -76,7 +77,7 @@ public static Iec61850TelemetryEnvelope Invalid( safePlaceholder?.ToString() ?? "-", Iec61850TelemetryQualityState.Invalid, "Invalid", - SourceTimestampUtc: null, + null, receivedAtUtc, sourceReference?.Trim() ?? string.Empty, diagnostic?.Trim() ?? string.Empty); From 4c8fd8303063f2ad1fe36c5efab9610b958103ad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:08:55 +0700 Subject: [PATCH 12/42] test(p8): keep relay-time regression independent of constructor syntax --- tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs index 139452d0b..6035564b4 100644 --- a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs +++ b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs @@ -76,7 +76,8 @@ public void DefensiveTelemetry_DoesNotReplaceMissingRelayTimeWithPcTime() Assert.Contains("DateTimeOffset? SourceTimestampUtc", source, StringComparison.Ordinal); Assert.Contains("DateTimeOffset ReceivedAtUtc", source, StringComparison.Ordinal); - Assert.Contains("SourceTimestampUtc: null", source, StringComparison.Ordinal); + Assert.Contains("source timestamp remains unknown", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("never substitute DateTime.Now/ReceivedAtUtc", source, StringComparison.Ordinal); Assert.DoesNotContain("SourceTimestampUtc = DateTime", source, StringComparison.Ordinal); } From 663b5f57559e62a46c19c1d51dd04a18d2f12666 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:25:14 +0700 Subject: [PATCH 13/42] refactor(p8): remove unused UI batcher experiment --- Services/LatestValueUiBatcher.cs | 137 ------------------------------- 1 file changed, 137 deletions(-) delete mode 100644 Services/LatestValueUiBatcher.cs diff --git a/Services/LatestValueUiBatcher.cs b/Services/LatestValueUiBatcher.cs deleted file mode 100644 index 38d99f32e..000000000 --- a/Services/LatestValueUiBatcher.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Collections.Concurrent; - -namespace ArIED61850Tester.Services; - -/// -/// Coalesces only the UI projection of high-rate telemetry. Raw report/GOOSE/SOE processing -/// must remain upstream and event-by-event; this class intentionally keeps only the latest -/// visual value per key until the next bounded flush. -/// -public sealed class LatestValueUiBatcher : IAsyncDisposable - where TKey : notnull -{ - public static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(150); - - private sealed record PendingValue(long Version, TValue Value); - - private readonly ConcurrentDictionary _pending = new(); - private readonly Func, CancellationToken, ValueTask> _flushAsync; - private readonly TimeSpan _interval; - private readonly CancellationTokenSource _stop = new(); - private readonly Task _pump; - private long _version; - private long _published; - private long _flushed; - private int _disposeStarted; - - public LatestValueUiBatcher( - Func, CancellationToken, ValueTask> flushAsync, - TimeSpan? interval = null) - { - _flushAsync = flushAsync ?? throw new ArgumentNullException(nameof(flushAsync)); - _interval = interval ?? DefaultInterval; - if (_interval < TimeSpan.FromMilliseconds(50) || _interval > TimeSpan.FromSeconds(2)) - throw new ArgumentOutOfRangeException(nameof(interval), "UI batch interval must stay between 50 ms and 2 s."); - - _pump = Task.Run(PumpAsync); - } - - public long PublishedCount => Interlocked.Read(ref _published); - public long FlushedCount => Interlocked.Read(ref _flushed); - public long CoalescedCount => Math.Max(0, PublishedCount - FlushedCount - _pending.Count); - public int PendingKeyCount => _pending.Count; - - public bool TryPublish(TKey key, TValue value) - { - if (Volatile.Read(ref _disposeStarted) != 0) - return false; - - var version = Interlocked.Increment(ref _version); - _pending[key] = new PendingValue(version, value); - Interlocked.Increment(ref _published); - return true; - } - - public async ValueTask FlushNowAsync(CancellationToken cancellationToken = default) - { - if (Volatile.Read(ref _disposeStarted) != 0 && _pending.IsEmpty) - return; - - var batch = DrainLatest(); - if (batch.Count == 0) - return; - - await _flushAsync(batch, cancellationToken).ConfigureAwait(false); - Interlocked.Add(ref _flushed, batch.Count); - } - - private async Task PumpAsync() - { - using var timer = new PeriodicTimer(_interval); - try - { - while (await timer.WaitForNextTickAsync(_stop.Token).ConfigureAwait(false)) - await FlushNowAsync(_stop.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (_stop.IsCancellationRequested) - { - } - catch - { - // The UI projection is non-authoritative. A presentation failure must not tear - // down network acquisition; a later explicit FlushNowAsync can still drain data. - } - } - - private List DrainLatest() - { - var batch = new List(_pending.Count); - var collection = (ICollection>)_pending; - - foreach (var pair in _pending.ToArray()) - { - // ICollection.Remove(KeyValuePair) is an atomic key+value conditional removal for - // ConcurrentDictionary. If a newer value arrived after ToArray(), removal fails - // and the newer value remains pending for the next flush instead of being lost. - if (collection.Remove(pair)) - batch.Add(pair.Value.Value); - } - - return batch; - } - - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) - return; - - _stop.Cancel(); - try - { - await _pump.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - - // Best-effort final visual delivery. Disposal is not a process-evidence boundary; - // authoritative report/SOE data has already been processed upstream. - try - { - var finalBatch = DrainLatest(); - if (finalBatch.Count > 0) - { - await _flushAsync(finalBatch, CancellationToken.None).ConfigureAwait(false); - Interlocked.Add(ref _flushed, finalBatch.Count); - } - } - catch - { - } - finally - { - _pending.Clear(); - _stop.Dispose(); - } - } -} From 2cfa635d3e542242f49a0ef7aa891a4ecf4c14d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:25:24 +0700 Subject: [PATCH 14/42] refactor(p8): rely on production UI batching regression --- .../P8LatestValueUiBatcherTests.cs | 66 ------------------- 1 file changed, 66 deletions(-) delete mode 100644 tests/ARSAS.Tests/P8LatestValueUiBatcherTests.cs diff --git a/tests/ARSAS.Tests/P8LatestValueUiBatcherTests.cs b/tests/ARSAS.Tests/P8LatestValueUiBatcherTests.cs deleted file mode 100644 index 6a9c90cee..000000000 --- a/tests/ARSAS.Tests/P8LatestValueUiBatcherTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using ArIED61850Tester.Services; - -namespace ARSAS.Tests; - -public sealed class P8LatestValueUiBatcherTests -{ - [Fact] - public async Task SameVisualKey_IsCoalescedToLatestValue() - { - var flushed = new List(); - await using var batcher = new LatestValueUiBatcher( - (batch, _) => - { - lock (flushed) - flushed.AddRange(batch); - return ValueTask.CompletedTask; - }, - TimeSpan.FromSeconds(2)); - - for (var value = 1; value <= 100; value++) - Assert.True(batcher.TryPublish("IED1|LD0/XCBR1.Pos.stVal", value)); - - await batcher.FlushNowAsync(); - - Assert.Single(flushed); - Assert.Equal(100, flushed[0]); - Assert.Equal(100, batcher.PublishedCount); - Assert.Equal(1, batcher.FlushedCount); - Assert.True(batcher.CoalescedCount >= 99); - } - - [Fact] - public async Task IndependentKeys_AreFlushedIndependently() - { - var flushed = new List(); - await using var batcher = new LatestValueUiBatcher( - (batch, _) => - { - lock (flushed) - flushed.AddRange(batch); - return ValueTask.CompletedTask; - }, - TimeSpan.FromSeconds(2)); - - batcher.TryPublish("IED1|A", "IED1-new"); - batcher.TryPublish("IED2|B", "IED2-new"); - await batcher.FlushNowAsync(); - - Assert.Equal(2, flushed.Count); - Assert.Contains("IED1-new", flushed); - Assert.Contains("IED2-new", flushed); - } - - [Fact] - public async Task Dispose_StopsAcceptingNewVisualUpdates() - { - var batcher = new LatestValueUiBatcher( - (_, _) => ValueTask.CompletedTask, - TimeSpan.FromSeconds(2)); - - Assert.True(batcher.TryPublish("IED1|A", 1)); - await batcher.DisposeAsync(); - - Assert.False(batcher.TryPublish("IED1|A", 2)); - } -} From 31664ba0bebefc037b72bf1d2aa9379b930584ac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:25:31 +0700 Subject: [PATCH 15/42] refactor(p8): remove unproven buffer pooling surface --- Services/PooledByteBufferLease.cs | 60 ------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 Services/PooledByteBufferLease.cs diff --git a/Services/PooledByteBufferLease.cs b/Services/PooledByteBufferLease.cs deleted file mode 100644 index 5ee3ba07c..000000000 --- a/Services/PooledByteBufferLease.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Buffers; - -namespace ArIED61850Tester.Services; - -/// -/// ArrayPool-backed lease for transient network/codec buffers owned by ARSAS. -/// Do not use this for buffers whose lifetime is owned by ARIEC61850, Npcap, WPF bindings, -/// report snapshots, or evidence objects. Pooling is deliberately limited to byte buffers -/// with a clear rent/use/return lifetime so use-after-return cannot corrupt process data. -/// -public sealed class PooledByteBufferLease : IDisposable -{ - private byte[]? _buffer; - private readonly int _length; - private readonly bool _clearOnReturn; - - private PooledByteBufferLease(int minimumLength, bool clearOnReturn) - { - if (minimumLength <= 0) - throw new ArgumentOutOfRangeException(nameof(minimumLength)); - - _buffer = ArrayPool.Shared.Rent(minimumLength); - _length = minimumLength; - _clearOnReturn = clearOnReturn; - } - - public int Length => _length; - - public Memory Memory - { - get - { - var buffer = Volatile.Read(ref _buffer) - ?? throw new ObjectDisposedException(nameof(PooledByteBufferLease)); - return buffer.AsMemory(0, _length); - } - } - - public Span Span - { - get - { - var buffer = Volatile.Read(ref _buffer) - ?? throw new ObjectDisposedException(nameof(PooledByteBufferLease)); - return buffer.AsSpan(0, _length); - } - } - - public static PooledByteBufferLease Rent(int minimumLength, bool clearOnReturn = false) - => new(minimumLength, clearOnReturn); - - public void Dispose() - { - var buffer = Interlocked.Exchange(ref _buffer, null); - if (buffer is null) - return; - - ArrayPool.Shared.Return(buffer, _clearOnReturn); - } -} From 5937399b76317bf627ffe8b3f460a9a7f9effd5f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:25:42 +0700 Subject: [PATCH 16/42] test(p8): keep allocation profiling deterministic --- tests/ARSAS.Tests/P8MemoryPerformanceTests.cs | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs index a26158e0f..b81dfc121 100644 --- a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs +++ b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs @@ -4,21 +4,6 @@ namespace ARSAS.Tests; public sealed class P8MemoryPerformanceTests { - [Fact] - public void PooledByteBufferLease_ExposesRequestedLength_AndDisposeIsIdempotent() - { - var lease = PooledByteBufferLease.Rent(1024, clearOnReturn: true); - Assert.Equal(1024, lease.Length); - Assert.Equal(1024, lease.Memory.Length); - - lease.Span[0] = 0x61; - Assert.Equal((byte)0x61, lease.Span[0]); - - lease.Dispose(); - lease.Dispose(); - Assert.Throws(() => _ = lease.Memory); - } - [Fact] public void AllocationSnapshot_DeltaNeverReportsNegativeAllocatedBytesOrCollectionCounts() { @@ -34,4 +19,18 @@ public void AllocationSnapshot_DeltaNeverReportsNegativeAllocatedBytesOrCollecti Assert.True(delta.Gen2Collections >= 0); Assert.True(delta.AllocatedMegabytesPerSecond >= 0d); } + + [Fact] + public void AllocationSnapshot_DoesNotForceGarbageCollection() + { + var gen0Before = GC.CollectionCount(0); + var gen1Before = GC.CollectionCount(1); + var gen2Before = GC.CollectionCount(2); + + _ = RuntimeAllocationSnapshot.Capture(); + + Assert.Equal(gen0Before, GC.CollectionCount(0)); + Assert.Equal(gen1Before, GC.CollectionCount(1)); + Assert.Equal(gen2Before, GC.CollectionCount(2)); + } } From f825497310c214854da0e8d64fab384bd851ca1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:25:53 +0700 Subject: [PATCH 17/42] test(p8): avoid flaky GC scheduling assertions --- tests/ARSAS.Tests/P8MemoryPerformanceTests.cs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs index b81dfc121..36a01c385 100644 --- a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs +++ b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs @@ -21,16 +21,27 @@ public void AllocationSnapshot_DeltaNeverReportsNegativeAllocatedBytesOrCollecti } [Fact] - public void AllocationSnapshot_DoesNotForceGarbageCollection() + public void AllocationSnapshot_ImplementationNeverForcesCollection() { - var gen0Before = GC.CollectionCount(0); - var gen1Before = GC.CollectionCount(1); - var gen2Before = GC.CollectionCount(2); + var source = ReadRepoFile("Services/RuntimeAllocationSnapshot.cs"); - _ = RuntimeAllocationSnapshot.Capture(); + Assert.Contains("GC.GetGCMemoryInfo()", source, StringComparison.Ordinal); + Assert.Contains("GC.GetTotalAllocatedBytes(precise: false)", source, StringComparison.Ordinal); + Assert.DoesNotContain("GC.Collect(", source, StringComparison.Ordinal); + Assert.DoesNotContain("GC.WaitForPendingFinalizers", source, StringComparison.Ordinal); + } + + private static string ReadRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return File.ReadAllText(candidate).Replace("\r\n", "\n", StringComparison.Ordinal); + directory = directory.Parent; + } - Assert.Equal(gen0Before, GC.CollectionCount(0)); - Assert.Equal(gen1Before, GC.CollectionCount(1)); - Assert.Equal(gen2Before, GC.CollectionCount(2)); + throw new FileNotFoundException(relativePath); } } From c4200c8e1e7e05b455dc99d991f955a8ddb776c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:26:16 +0700 Subject: [PATCH 18/42] docs(p8): close audit with production-only hardening scope --- docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md | 49 ++++++++++++----------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md index 4613dc435..74bef5bc1 100644 --- a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md +++ b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md @@ -1,6 +1,6 @@ # P8 — Runtime Performance & Resilience -P8 hardens ARSAS for long-running, multi-IED IEC 61850 work without trading process evidence for UI smoothness. +P8 hardens ARSAS for long-running, multi-IED IEC 61850 work without trading process evidence for UI smoothness. The final P8 scope deliberately prefers regression-locking the production runtime that is already proven over introducing parallel helper frameworks that are not yet used by the application. ## Non-negotiable invariants @@ -9,35 +9,35 @@ P8 hardens ARSAS for long-running, multi-IED IEC 61850 work without trading proc 3. **One IED cannot stall another.** Lifecycle gates, monitor cancellation, reconnect state, client instances, and report recovery are scoped per device. 4. **Native/vendor work does not execute its synchronous prefix on the WPF Dispatcher.** The UI-facing runtime facade offloads IEC 61850 lifecycle operations and provides a pre-emptive stop lane. 5. **Shutdown is bounded.** Native teardown is best effort and may not freeze the application indefinitely. -6. **Pooling is ownership-safe.** Only ARSAS-owned transient byte buffers with a strict rent/use/return lifetime may use `ArrayPool`. ARIEC61850/Npcap-owned frames and WPF-bound objects are not pooled by ARSAS. +6. **Pooling requires measured proof and clear ownership.** ARSAS must not introduce `ArrayPool`/object pooling into ARIEC61850, Npcap, Report, GOOSE, SOE, evidence, or WPF-bound lifetimes merely because pooling is theoretically faster. ## P8 coverage ### P8.1 — Per-IED async connection and reconnect isolation -The existing runtime already uses a per-device operation slot in the UI facade and a per-device `DeviceSession` in the monitor runtime. P8 regression tests lock these contracts so future refactors cannot accidentally introduce one global lifecycle/reconnect gate. +The production runtime uses a per-device operation slot in the UI facade and a per-device `DeviceSession` in the monitor runtime. P8 regression tests lock these contracts so future refactors cannot accidentally introduce one global lifecycle/reconnect gate. -Reconnect replaces only the affected device client, uses bounded cleanup/connect budgets, resets only that session's report state, and re-arms reporting asynchronously after MMS association recovery. +Reconnect replaces only the affected device client, uses bounded cleanup/connect budgets, resets only that session's report state, and re-arms reporting asynchronously after MMS association recovery. Existing FAT multi-IED regressions also prove that one IED can be in a preparing/connected state without overwriting another IED's proven binding or session state. ### P8.2 — UI throttling and batching -The Engineering live workspace already separates acquisition from presentation: +The production Engineering workspace already contains the correct split between acquisition and presentation: -- point snapshots are coalesced by point key, +- point snapshots are coalesced by point key in a `ConcurrentDictionary`, - SOE entries remain in a FIFO `ConcurrentQueue`, - diagnostics remain queued separately, - the WPF projection flushes every 200 ms at `DispatcherPriority.Background`, - event batches preserve every queued SOE edge. -`LatestValueUiBatcher` is a reusable P8 primitive for additional high-rate UI surfaces. It keeps only the latest visual value per key and uses conditional removal so a newer concurrent update cannot be deleted by an older drain. +P8 therefore does **not** insert a second batching framework into the live path. The audit initially prototyped a generic latest-value batcher, but it was removed before P8 closure because it was unused and would have created a second presentation authority. Regression tests now protect the actual production batching path instead. ### P8.3 — Virtualized large grids -The FAT DataGrid uses row and column virtualization, recycling mode, and content scrolling. P8 adds regression coverage for these settings. Large future grids should use the same contract rather than rendering all rows/cells. +The FAT DataGrid uses row and column virtualization, recycling mode, and content scrolling. P8 adds regression coverage for these settings. Existing RCB virtualization regressions remain untouched and continue to protect recycled selection behavior. ### P8.4 — Memory/leak prevention and bounded disposal -Main-window timers are stopped during shutdown; the application CTS is cancelled; GOOSE and IEC 61850 runtimes are asynchronously disposed behind bounded shutdown waits. The facade cancels every active per-device operation before disposing the inner runtime. +Main-window timers are stopped during shutdown; the application CTS is cancelled; GOOSE and IEC 61850 runtimes are asynchronously disposed behind bounded shutdown waits. The facade cancels every active per-device operation before disposing the inner runtime. P8 locks these ownership boundaries without adding another global lifetime manager. ### P8.5 — Defensive telemetry envelope @@ -47,29 +47,30 @@ Main-window timers are stopped during shutdown; the application CTS is cancelled - source/relay timestamp (`SourceTimestampUtc`), - local receipt time (`ReceivedAtUtc`). -A missing process value is `Invalid` even if a malformed upstream object claims `Good`. A malformed source timestamp remains `null`; ARSAS does not fabricate relay evidence from the PC clock. +A missing process value is `Invalid` even if malformed upstream metadata claims `Good`. A malformed source timestamp remains `null`; ARSAS does not replace missing relay evidence with the PC receive clock. `Iec61850ReadValue` exposes this normalized envelope while preserving the existing runtime's report/MMS evidence path. ### P8.6 — Allocation profiling -`RuntimeAllocationSnapshot` captures total allocated bytes, managed heap size, fragmentation, and generation collection counters without forcing GC. It provides deltas for repeatable field/performance baselines. +`RuntimeAllocationSnapshot` captures total allocated bytes, managed heap size, fragmentation, and generation collection counters without forcing GC. It provides deltas for repeatable field/performance baselines. P8 intentionally avoids hard-coded allocation or timing thresholds in CI because runner scheduling and GC timing would make such tests flaky; thresholds should be established from measured field baselines. -### P8.7 — Ownership-safe buffer pooling +### P8.7 — Pooling decision after profiling audit -`PooledByteBufferLease` provides an idempotent `ArrayPool` lease for ARSAS-owned transient codec/network buffers. It is intentionally not applied blindly to report snapshots, signal models, GOOSE frame objects, WPF ViewModels, or buffers owned by ARIEC61850/Npcap. +No new production pooling is introduced in P8. -Before converting an additional hot path to pooling, capture a P8.6 allocation baseline and prove that ARSAS owns the complete buffer lifetime. This avoids use-after-return corruption in protection/control evidence. +The audit found no measured ARSAS-owned transient-buffer hotspot that justified changing lifetime semantics. A preliminary `ArrayPool` lease was therefore removed before P8 closure. This is intentional: pooling ARIEC61850/Npcap-owned frames, Report/GOOSE/SOE snapshots, FAT evidence, or WPF-bound objects without a proven ownership boundary can create use-after-return corruption that is worse than the allocation being optimized. -## Regression tests +If P8.6 profiling later identifies a real hotspot, pooling may be introduced in a separate measured change only after the full rent/use/return lifetime is proven to be owned by ARSAS. -P8 adds tests for: +## Regression coverage + +P8 protects: - null/malformed telemetry normalization, - no fabricated relay timestamp, -- latest-value UI coalescing semantics, -- independent UI keys, -- pooled-buffer lifetime and idempotent disposal, -- allocation-snapshot deltas, -- per-IED runtime/reconnect isolation source contracts, -- lossless SOE queue versus coalesced point projection, -- FAT grid virtualization, -- bounded shutdown/disposal contracts. +- production latest-value UI coalescing while SOE remains lossless FIFO, +- per-IED operation/reconnect isolation, +- existing independent multi-IED FAT state, +- FAT row/column recycling virtualization, +- bounded cancellation and shutdown/disposal ownership, +- allocation snapshots that do not force GC, +- the rule that ambiguous native/process-bus ownership paths do not receive speculative pooling. From 7b4555bb23fb0bd4642b4f6100a3d4e66ce27c15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:26:40 +0700 Subject: [PATCH 19/42] test(p8): guard production authorities and reject speculative pooling --- .../P8RuntimeArchitectureRegressionTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs index 6035564b4..107eb8d15 100644 --- a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs +++ b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs @@ -40,6 +40,8 @@ public void MainWindow_CoalescesOnlyPointProjection_WhileSoeRemainsQueuedLossles Assert.Contains("_pendingPointSnapshots.AddOrUpdate", source, StringComparison.Ordinal); Assert.Contains("_pendingEvents.Enqueue(entry)", source, StringComparison.Ordinal); Assert.Contains("while (eventBatch.Count < 1000 && _pendingEvents.TryDequeue", source, StringComparison.Ordinal); + + Assert.False(File.Exists(Path.Combine(FindRepoRoot(), "Services", "LatestValueUiBatcher.cs"))); } [Fact] @@ -81,6 +83,19 @@ public void DefensiveTelemetry_DoesNotReplaceMissingRelayTimeWithPcTime() Assert.DoesNotContain("SourceTimestampUtc = DateTime", source, StringComparison.Ordinal); } + [Fact] + public void P8_DoesNotIntroduceSpeculativePoolingIntoAmbiguousOwnershipPaths() + { + var nativeClient = Read("Services/NativeIec61850Client.cs"); + var monitor = Read("Services/Iec61850MonitorRuntime.cs"); + var goose = Read("Services/GooseSubscriberRuntime.cs"); + + Assert.DoesNotContain("ArrayPool<", nativeClient, StringComparison.Ordinal); + Assert.DoesNotContain("ArrayPool<", monitor, StringComparison.Ordinal); + Assert.DoesNotContain("ArrayPool<", goose, StringComparison.Ordinal); + Assert.False(File.Exists(Path.Combine(FindRepoRoot(), "Services", "PooledByteBufferLease.cs"))); + } + private static string Read(string relativePath) => File.ReadAllText(Path.Combine(FindRepoRoot(), relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); From 7f3e1ba3de906d58624541df968ae640e52ef493 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:27:40 +0700 Subject: [PATCH 20/42] fix(p8): never promote unknown quality to Good --- Services/Iec61850TelemetryEnvelope.cs | 35 +++++++++++++-------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/Services/Iec61850TelemetryEnvelope.cs b/Services/Iec61850TelemetryEnvelope.cs index b0a1acb12..9c04d89e0 100644 --- a/Services/Iec61850TelemetryEnvelope.cs +++ b/Services/Iec61850TelemetryEnvelope.cs @@ -5,7 +5,7 @@ namespace ArIED61850Tester.Services; /// /// Normalized IEC 61850 telemetry boundary used between network decoding and application logic. /// Missing/ambiguous data is never promoted to a valid process value: the source timestamp -/// stays unknown and the quality is forced Invalid while ReceivedAtUtc records local receipt. +/// stays unknown and quality is preserved conservatively while ReceivedAtUtc records local receipt. /// public enum Iec61850TelemetryQualityState { @@ -52,9 +52,11 @@ public static Iec61850TelemetryEnvelope FromReadValue( ? hasValue ? $"Telemetry quality is invalid ({qualityText})." : "Telemetry contains no process value." - : sourceTimestamp is null && read.HasDeviceTimestamp - ? "Device timestamp was present but could not be parsed safely; source timestamp remains unknown." - : string.Empty; + : qualityState == Iec61850TelemetryQualityState.Questionable + ? $"Telemetry quality is not proven Good ({qualityText})." + : sourceTimestamp is null && read.HasDeviceTimestamp + ? "Device timestamp was present but could not be parsed safely; source timestamp remains unknown." + : string.Empty; return new Iec61850TelemetryEnvelope( read.Value, @@ -88,8 +90,9 @@ public static Iec61850TelemetryEnvelope Invalid( if (text.Length == 0 || text == "-") return null; - // IEC 61850 timestamps are UTC-based. Only publish a parsed source timestamp when - // parsing succeeds; never substitute DateTime.Now/ReceivedAtUtc for missing data. + // ARIEC61850's decoded IEC UtcTime display can be zone-less even though IEC UtcTime + // is semantically UTC. AssumeUniversal therefore preserves engine semantics here; + // parsing failure still stays null and is never replaced by ReceivedAtUtc/PC time. if (!DateTimeOffset.TryParse( text, CultureInfo.InvariantCulture, @@ -107,27 +110,23 @@ private static Iec61850TelemetryQualityState ClassifyQuality(string quality, boo if (!hasValue) return Iec61850TelemetryQualityState.Invalid; + // Never infer Good from an unknown/vendor token. IEC validity is only considered + // Good when the decoder explicitly said Good. This prevents missing or future + // quality representations from being silently promoted to trustworthy evidence. + if (quality.Equals("Good", StringComparison.OrdinalIgnoreCase)) + return Iec61850TelemetryQualityState.Good; + if (quality.Contains("invalid", StringComparison.OrdinalIgnoreCase) || quality.Contains("failure", StringComparison.OrdinalIgnoreCase) || quality.Contains("bad", StringComparison.OrdinalIgnoreCase) || + quality.Contains("reserved", StringComparison.OrdinalIgnoreCase) || quality.Contains("outofrange", StringComparison.OrdinalIgnoreCase) || quality.Contains("out-of-range", StringComparison.OrdinalIgnoreCase)) { return Iec61850TelemetryQualityState.Invalid; } - if (quality.Length == 0 || quality == "-" || - quality.Contains("unknown", StringComparison.OrdinalIgnoreCase) || - quality.Contains("questionable", StringComparison.OrdinalIgnoreCase) || - quality.Contains("olddata", StringComparison.OrdinalIgnoreCase) || - quality.Contains("old-data", StringComparison.OrdinalIgnoreCase) || - quality.Contains("substituted", StringComparison.OrdinalIgnoreCase) || - quality.Contains("test", StringComparison.OrdinalIgnoreCase)) - { - return Iec61850TelemetryQualityState.Questionable; - } - - return Iec61850TelemetryQualityState.Good; + return Iec61850TelemetryQualityState.Questionable; } private static string NormalizeQualityText(string? value) From b6fb3c7d745a732d66e50ec676c786a2231eedcb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:27:57 +0700 Subject: [PATCH 21/42] test(p8): cover conservative quality and UTC timestamp semantics --- tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs index 9a76cac49..89d3cdb70 100644 --- a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs +++ b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs @@ -31,6 +31,7 @@ public void MissingProcessValue_RemainsInvalid_EvenWhenQualitySaysGood() var envelope = read.ToTelemetryEnvelope(); Assert.False(envelope.IsValid); + Assert.False(envelope.HasProcessValue); Assert.Equal(Iec61850TelemetryQualityState.Invalid, envelope.QualityState); } @@ -78,10 +79,29 @@ public void GoodTelemetry_PreservesSourceTimestampSeparatelyFromReceiptTime() Assert.Equal("LD0/LLN0.Mod.stVal", envelope.SourceReference); } + [Fact] + public void ZoneLessArIecUtcTimeDisplay_IsInterpretedAsUtc_NotLocalPcTime() + { + var read = new Iec61850ReadValue + { + Value = 1, + DisplayValue = "1", + Quality = "Good", + DeviceTimestamp = "2026-08-13 10:00:31.2006000" + }; + + var envelope = read.ToTelemetryEnvelope(); + + Assert.Equal(TimeSpan.Zero, envelope.SourceTimestampUtc?.Offset); + Assert.Equal(2026, envelope.SourceTimestampUtc?.Year); + Assert.Equal(10, envelope.SourceTimestampUtc?.Hour); + } + [Theory] [InlineData("Invalid")] [InlineData("Bad / communication error")] [InlineData("Failure")] + [InlineData("Reserved")] public void DegradedQuality_IsNeverPromotedToGood(string quality) { var envelope = Iec61850TelemetryEnvelope.FromReadValue(new Iec61850ReadValue @@ -94,4 +114,23 @@ public void DegradedQuality_IsNeverPromotedToGood(string quality) Assert.Equal(Iec61850TelemetryQualityState.Invalid, envelope.QualityState); Assert.False(envelope.IsValid); } + + [Theory] + [InlineData("")] + [InlineData("Unknown")] + [InlineData("Questionable")] + [InlineData("vendor-future-token")] + public void UnprovenQuality_IsQuestionable_NeverSilentlyGood(string quality) + { + var envelope = Iec61850TelemetryEnvelope.FromReadValue(new Iec61850ReadValue + { + Value = 1, + DisplayValue = "1", + Quality = quality + }); + + Assert.Equal(Iec61850TelemetryQualityState.Questionable, envelope.QualityState); + Assert.NotEqual(Iec61850TelemetryQualityState.Good, envelope.QualityState); + Assert.Contains("not proven Good", envelope.Diagnostic, StringComparison.OrdinalIgnoreCase); + } } From 1752046abeb51476bede9f9bc718df6e027b8cf6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:28:18 +0700 Subject: [PATCH 22/42] test(p8): align relay-time guard with final telemetry contract --- tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs index 107eb8d15..df8dc91a1 100644 --- a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs +++ b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs @@ -79,7 +79,7 @@ public void DefensiveTelemetry_DoesNotReplaceMissingRelayTimeWithPcTime() Assert.Contains("DateTimeOffset? SourceTimestampUtc", source, StringComparison.Ordinal); Assert.Contains("DateTimeOffset ReceivedAtUtc", source, StringComparison.Ordinal); Assert.Contains("source timestamp remains unknown", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("never substitute DateTime.Now/ReceivedAtUtc", source, StringComparison.Ordinal); + Assert.Contains("never replaced by ReceivedAtUtc/PC time", source, StringComparison.Ordinal); Assert.DoesNotContain("SourceTimestampUtc = DateTime", source, StringComparison.Ordinal); } From 179cf382e95e8cdb7fe9000b7d4de048a11c2146 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:28:30 +0700 Subject: [PATCH 23/42] fix(p8): clamp reversed allocation snapshot intervals --- Services/RuntimeAllocationSnapshot.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Services/RuntimeAllocationSnapshot.cs b/Services/RuntimeAllocationSnapshot.cs index 98f5d2ef1..aa775dab1 100644 --- a/Services/RuntimeAllocationSnapshot.cs +++ b/Services/RuntimeAllocationSnapshot.cs @@ -27,14 +27,20 @@ public static RuntimeAllocationSnapshot Capture() } public RuntimeAllocationDelta DeltaFrom(RuntimeAllocationSnapshot earlier) - => new( + { + var elapsed = CapturedAtUtc >= earlier.CapturedAtUtc + ? CapturedAtUtc - earlier.CapturedAtUtc + : TimeSpan.Zero; + + return new RuntimeAllocationDelta( Math.Max(0, TotalAllocatedBytes - earlier.TotalAllocatedBytes), HeapSizeBytes - earlier.HeapSizeBytes, FragmentedBytes - earlier.FragmentedBytes, Math.Max(0, Gen0Collections - earlier.Gen0Collections), Math.Max(0, Gen1Collections - earlier.Gen1Collections), Math.Max(0, Gen2Collections - earlier.Gen2Collections), - CapturedAtUtc - earlier.CapturedAtUtc); + elapsed); + } } public readonly record struct RuntimeAllocationDelta( From fd4f340536c04d29c54f79da5d8010c198c236fa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:28:40 +0700 Subject: [PATCH 24/42] test(p8): cover defensive allocation snapshot ordering --- tests/ARSAS.Tests/P8MemoryPerformanceTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs index 36a01c385..c934f8252 100644 --- a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs +++ b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs @@ -17,9 +17,40 @@ public void AllocationSnapshot_DeltaNeverReportsNegativeAllocatedBytesOrCollecti Assert.True(delta.Gen0Collections >= 0); Assert.True(delta.Gen1Collections >= 0); Assert.True(delta.Gen2Collections >= 0); + Assert.True(delta.Elapsed >= TimeSpan.Zero); Assert.True(delta.AllocatedMegabytesPerSecond >= 0d); } + [Fact] + public void AllocationSnapshot_ReversedInputClampsElapsedAndCountersSafely() + { + var later = new RuntimeAllocationSnapshot( + new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero), + 2_000, + 1_500, + 100, + 5, + 3, + 1); + var earlier = new RuntimeAllocationSnapshot( + later.CapturedAtUtc.AddSeconds(-10), + 1_000, + 1_000, + 50, + 2, + 1, + 0); + + var reversed = earlier.DeltaFrom(later); + + Assert.Equal(TimeSpan.Zero, reversed.Elapsed); + Assert.Equal(0, reversed.AllocatedBytes); + Assert.Equal(0, reversed.Gen0Collections); + Assert.Equal(0, reversed.Gen1Collections); + Assert.Equal(0, reversed.Gen2Collections); + Assert.Equal(0d, reversed.AllocatedMegabytesPerSecond); + } + [Fact] public void AllocationSnapshot_ImplementationNeverForcesCollection() { From de39f19b3dcef0f729f1af40574ca0bad104b271 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:29:53 +0700 Subject: [PATCH 25/42] fix(p8): distinguish Good validity from questionable usability --- Services/Iec61850TelemetryEnvelope.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Services/Iec61850TelemetryEnvelope.cs b/Services/Iec61850TelemetryEnvelope.cs index 9c04d89e0..6fb7a6a40 100644 --- a/Services/Iec61850TelemetryEnvelope.cs +++ b/Services/Iec61850TelemetryEnvelope.cs @@ -26,7 +26,15 @@ public readonly record struct Iec61850TelemetryEnvelope( { public bool HasProcessValue => Value is not null || (!string.IsNullOrWhiteSpace(DisplayValue) && DisplayValue != "-"); - public bool IsValid => HasProcessValue && QualityState != Iec61850TelemetryQualityState.Invalid; + + /// + /// True only when both the process value and IEC quality are explicitly Good. + /// Questionable data may still be presented to an engineer, but it is not promoted + /// to valid evidence by this boundary. + /// + public bool IsValid => HasProcessValue && QualityState == Iec61850TelemetryQualityState.Good; + + public bool IsUsable => HasProcessValue && QualityState != Iec61850TelemetryQualityState.Invalid; public static Iec61850TelemetryEnvelope FromReadValue( Iec61850ReadValue? read, From 272909fb9c9e7435968ae7076290b5a4e1c26331 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:30:11 +0700 Subject: [PATCH 26/42] test(p8): require explicit Good before telemetry is valid --- tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs index 89d3cdb70..0453b1e07 100644 --- a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs +++ b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs @@ -12,6 +12,7 @@ public void NullRead_IsInvalid_AndDoesNotInventSourceTimestamp() var envelope = Iec61850TelemetryEnvelope.FromReadValue(null, received); Assert.False(envelope.IsValid); + Assert.False(envelope.IsUsable); Assert.Equal(Iec61850TelemetryQualityState.Invalid, envelope.QualityState); Assert.Null(envelope.SourceTimestampUtc); Assert.Equal(received, envelope.ReceivedAtUtc); @@ -31,6 +32,7 @@ public void MissingProcessValue_RemainsInvalid_EvenWhenQualitySaysGood() var envelope = read.ToTelemetryEnvelope(); Assert.False(envelope.IsValid); + Assert.False(envelope.IsUsable); Assert.False(envelope.HasProcessValue); Assert.Equal(Iec61850TelemetryQualityState.Invalid, envelope.QualityState); } @@ -51,6 +53,7 @@ public void MalformedRelayTimestamp_RemainsUnknown_InsteadOfUsingPcClock() var envelope = read.ToTelemetryEnvelope(); Assert.True(envelope.IsValid); + Assert.True(envelope.IsUsable); Assert.Null(envelope.SourceTimestampUtc); Assert.Equal(received, envelope.ReceivedAtUtc); Assert.Contains("could not be parsed", envelope.Diagnostic, StringComparison.OrdinalIgnoreCase); @@ -73,6 +76,7 @@ public void GoodTelemetry_PreservesSourceTimestampSeparatelyFromReceiptTime() var envelope = read.ToTelemetryEnvelope(); Assert.True(envelope.IsValid); + Assert.True(envelope.IsUsable); Assert.Equal(Iec61850TelemetryQualityState.Good, envelope.QualityState); Assert.Equal(new DateTimeOffset(2026, 9, 11, 1, 2, 3, 125, TimeSpan.Zero), envelope.SourceTimestampUtc); Assert.Equal(received, envelope.ReceivedAtUtc); @@ -92,9 +96,10 @@ public void ZoneLessArIecUtcTimeDisplay_IsInterpretedAsUtc_NotLocalPcTime() var envelope = read.ToTelemetryEnvelope(); - Assert.Equal(TimeSpan.Zero, envelope.SourceTimestampUtc?.Offset); - Assert.Equal(2026, envelope.SourceTimestampUtc?.Year); - Assert.Equal(10, envelope.SourceTimestampUtc?.Hour); + Assert.NotNull(envelope.SourceTimestampUtc); + Assert.Equal(TimeSpan.Zero, envelope.SourceTimestampUtc!.Value.Offset); + Assert.Equal(2026, envelope.SourceTimestampUtc.Value.Year); + Assert.Equal(10, envelope.SourceTimestampUtc.Value.Hour); } [Theory] @@ -113,6 +118,7 @@ public void DegradedQuality_IsNeverPromotedToGood(string quality) Assert.Equal(Iec61850TelemetryQualityState.Invalid, envelope.QualityState); Assert.False(envelope.IsValid); + Assert.False(envelope.IsUsable); } [Theory] @@ -130,6 +136,8 @@ public void UnprovenQuality_IsQuestionable_NeverSilentlyGood(string quality) }); Assert.Equal(Iec61850TelemetryQualityState.Questionable, envelope.QualityState); + Assert.False(envelope.IsValid); + Assert.True(envelope.IsUsable); Assert.NotEqual(Iec61850TelemetryQualityState.Good, envelope.QualityState); Assert.Contains("not proven Good", envelope.Diagnostic, StringComparison.OrdinalIgnoreCase); } From 90eb106884f06cc13364faa855b03b1dfad0bf18 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:30:30 +0700 Subject: [PATCH 27/42] docs(p8): align quality validity semantics with defensive envelope --- docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md index 74bef5bc1..b04821fdb 100644 --- a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md +++ b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md @@ -5,7 +5,7 @@ P8 hardens ARSAS for long-running, multi-IED IEC 61850 work without trading proc ## Non-negotiable invariants 1. **Network/process evidence is lossless.** IEC 61850 Report/GOOSE/SOE processing stays event-by-event. Only the visual latest-value projection may be coalesced. -2. **Missing data is never promoted to a valid measurement.** A missing value or unusable quality becomes `Invalid`; an absent relay timestamp stays unknown. `ReceivedAtUtc` is separate local receipt metadata. +2. **Missing or unproven data is never promoted to Good evidence.** A missing process value is `Invalid`; unknown/vendor quality remains `Questionable` or `Invalid`, never silently `Good`; an absent relay timestamp stays unknown. `ReceivedAtUtc` is separate local receipt metadata. 3. **One IED cannot stall another.** Lifecycle gates, monitor cancellation, reconnect state, client instances, and report recovery are scoped per device. 4. **Native/vendor work does not execute its synchronous prefix on the WPF Dispatcher.** The UI-facing runtime facade offloads IEC 61850 lifecycle operations and provides a pre-emptive stop lane. 5. **Shutdown is bounded.** Native teardown is best effort and may not freeze the application indefinitely. @@ -47,11 +47,11 @@ Main-window timers are stopped during shutdown; the application CTS is cancelled - source/relay timestamp (`SourceTimestampUtc`), - local receipt time (`ReceivedAtUtc`). -A missing process value is `Invalid` even if malformed upstream metadata claims `Good`. A malformed source timestamp remains `null`; ARSAS does not replace missing relay evidence with the PC receive clock. `Iec61850ReadValue` exposes this normalized envelope while preserving the existing runtime's report/MMS evidence path. +A missing process value is `Invalid` even if malformed upstream metadata claims `Good`. Only an explicit IEC quality of `Good` makes `IsValid` true. `Questionable` data remains separately identifiable through `IsUsable` and is never promoted to Good evidence. A malformed source timestamp remains `null`; ARSAS does not replace missing relay evidence with the PC receive clock. ARIEC's zone-less decoded IEC `UtcTime` display is interpreted as UTC by protocol semantics, not as local PC time. `Iec61850ReadValue` exposes this normalized envelope while preserving the existing runtime's report/MMS evidence path. ### P8.6 — Allocation profiling -`RuntimeAllocationSnapshot` captures total allocated bytes, managed heap size, fragmentation, and generation collection counters without forcing GC. It provides deltas for repeatable field/performance baselines. P8 intentionally avoids hard-coded allocation or timing thresholds in CI because runner scheduling and GC timing would make such tests flaky; thresholds should be established from measured field baselines. +`RuntimeAllocationSnapshot` captures total allocated bytes, managed heap size, fragmentation, and generation collection counters without forcing GC. It provides defensive deltas for repeatable field/performance baselines, including safe handling of accidentally reversed snapshots. P8 intentionally avoids hard-coded allocation or timing thresholds in CI because runner scheduling and GC timing would make such tests flaky; thresholds should be established from measured field baselines. ### P8.7 — Pooling decision after profiling audit @@ -67,6 +67,7 @@ P8 protects: - null/malformed telemetry normalization, - no fabricated relay timestamp, +- explicit Good versus Questionable/Invalid quality semantics, - production latest-value UI coalescing while SOE remains lossless FIFO, - per-IED operation/reconnect isolation, - existing independent multi-IED FAT state, From 041609ba41e33c2b80813422e3505d0cd79b600f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:31:26 +0700 Subject: [PATCH 28/42] test(p8): lock virtualization on production live grids too --- .../P8RuntimeArchitectureRegressionTests.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs index df8dc91a1..a953132d0 100644 --- a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs +++ b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs @@ -45,15 +45,23 @@ public void MainWindow_CoalescesOnlyPointProjection_WhileSoeRemainsQueuedLossles } [Fact] - public void FatGrid_UsesRowAndColumnRecyclingVirtualization() + public void LargeProductionGrids_UseRecyclingVirtualization() { - var source = Read("IoListTestingWindow.xaml"); - - Assert.Contains("EnableRowVirtualization=\"True\"", source, StringComparison.Ordinal); - Assert.Contains("EnableColumnVirtualization=\"True\"", source, StringComparison.Ordinal); - Assert.Contains("VirtualizingPanel.IsVirtualizing=\"True\"", source, StringComparison.Ordinal); - Assert.Contains("VirtualizingPanel.VirtualizationMode=\"Recycling\"", source, StringComparison.Ordinal); - Assert.Contains("ScrollViewer.CanContentScroll=\"True\"", source, StringComparison.Ordinal); + var fat = Read("IoListTestingWindow.xaml"); + var main = Read("MainWindow.xaml"); + + Assert.Contains("EnableRowVirtualization=\"True\"", fat, StringComparison.Ordinal); + Assert.Contains("EnableColumnVirtualization=\"True\"", fat, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.IsVirtualizing=\"True\"", fat, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.VirtualizationMode=\"Recycling\"", fat, StringComparison.Ordinal); + Assert.Contains("ScrollViewer.CanContentScroll=\"True\"", fat, StringComparison.Ordinal); + + Assert.Contains("x:Name=\"GlobalLiveGrid\"", main, StringComparison.Ordinal); + Assert.Contains("ItemsSource=\"{Binding Events}\"", main, StringComparison.Ordinal); + Assert.Contains("ItemsSource=\"{Binding Logs}\"", main, StringComparison.Ordinal); + Assert.Contains("EnableRowVirtualization=\"True\" EnableColumnVirtualization=\"True\"", main, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.IsVirtualizing=\"True\" VirtualizingPanel.VirtualizationMode=\"Recycling\"", main, StringComparison.Ordinal); + Assert.Contains("ScrollViewer.CanContentScroll=\"True\"", main, StringComparison.Ordinal); } [Fact] From 569ff1ab29a35fbbe643391f2657a99ab800cead Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:32:01 +0700 Subject: [PATCH 29/42] fix(p8): preserve read receipt timestamp during normalization --- Services/Iec61850TelemetryEnvelope.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Services/Iec61850TelemetryEnvelope.cs b/Services/Iec61850TelemetryEnvelope.cs index 6fb7a6a40..35adc3303 100644 --- a/Services/Iec61850TelemetryEnvelope.cs +++ b/Services/Iec61850TelemetryEnvelope.cs @@ -40,7 +40,10 @@ public static Iec61850TelemetryEnvelope FromReadValue( Iec61850ReadValue? read, DateTimeOffset? receivedAtUtc = null) { - var received = receivedAtUtc ?? DateTimeOffset.UtcNow; + // Preserve the timestamp captured when the read projection was created. Normalization + // can happen later after queuing/batching and must not move local receipt evidence + // forward to the conversion time. UtcNow is only a last resort for a null read. + var received = receivedAtUtc ?? read?.ReceivedAtUtc ?? DateTimeOffset.UtcNow; if (read is null) { return Invalid( From 35ade622c2af2f459f7886526423780251dcfc65 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:32:18 +0700 Subject: [PATCH 30/42] test(p8): preserve stored receipt time by default --- tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs index 0453b1e07..44e62a074 100644 --- a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs +++ b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs @@ -18,6 +18,23 @@ public void NullRead_IsInvalid_AndDoesNotInventSourceTimestamp() Assert.Equal(received, envelope.ReceivedAtUtc); } + [Fact] + public void NormalizationWithoutOverride_PreservesReadReceiptTime() + { + var received = new DateTimeOffset(2026, 9, 11, 1, 15, 30, TimeSpan.Zero); + var read = new Iec61850ReadValue + { + Value = true, + DisplayValue = "True", + Quality = "Good", + ReceivedAtUtc = received + }; + + var envelope = Iec61850TelemetryEnvelope.FromReadValue(read); + + Assert.Equal(received, envelope.ReceivedAtUtc); + } + [Fact] public void MissingProcessValue_RemainsInvalid_EvenWhenQualitySaysGood() { From ffea9f46ba55c9b166c26eb5bf9a28b68eb2f7df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:33:36 +0700 Subject: [PATCH 31/42] fix(p8): unify missing-value detection across telemetry envelope --- Services/Iec61850TelemetryEnvelope.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Services/Iec61850TelemetryEnvelope.cs b/Services/Iec61850TelemetryEnvelope.cs index 35adc3303..329b124d5 100644 --- a/Services/Iec61850TelemetryEnvelope.cs +++ b/Services/Iec61850TelemetryEnvelope.cs @@ -24,8 +24,7 @@ public readonly record struct Iec61850TelemetryEnvelope( string SourceReference, string Diagnostic) { - public bool HasProcessValue => Value is not null || - (!string.IsNullOrWhiteSpace(DisplayValue) && DisplayValue != "-"); + public bool HasProcessValue => IsProcessValuePresent(Value, DisplayValue); /// /// True only when both the process value and IEC quality are explicitly Good. @@ -53,7 +52,7 @@ public static Iec61850TelemetryEnvelope FromReadValue( } var display = read.DisplayValue?.Trim() ?? string.Empty; - var hasValue = read.Value is not null || (display.Length > 0 && display != "-"); + var hasValue = IsProcessValuePresent(read.Value, display); var qualityText = NormalizeQualityText(read.Quality); var qualityState = ClassifyQuality(qualityText, hasValue); var sourceTimestamp = TryParseSourceTimestampUtc(read.DeviceTimestamp); @@ -116,6 +115,15 @@ public static Iec61850TelemetryEnvelope Invalid( return parsed.ToUniversalTime(); } + private static bool IsProcessValuePresent(object? value, string? displayValue) + { + if (value is not null) + return true; + + var display = displayValue?.Trim() ?? string.Empty; + return display.Length > 0 && display != "-"; + } + private static Iec61850TelemetryQualityState ClassifyQuality(string quality, bool hasValue) { if (!hasValue) From c4445f6d448305313ad645297ce2bd03c6220727 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:33:53 +0700 Subject: [PATCH 32/42] test(p8): cover whitespace missing-value sentinel --- tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs index 44e62a074..98807c151 100644 --- a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs +++ b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs @@ -35,13 +35,17 @@ public void NormalizationWithoutOverride_PreservesReadReceiptTime() Assert.Equal(received, envelope.ReceivedAtUtc); } - [Fact] - public void MissingProcessValue_RemainsInvalid_EvenWhenQualitySaysGood() + [Theory] + [InlineData("-")] + [InlineData(" - ")] + [InlineData("")] + [InlineData(" ")] + public void MissingProcessValue_RemainsInvalid_EvenWhenQualitySaysGood(string displayValue) { var read = new Iec61850ReadValue { Value = null, - DisplayValue = "-", + DisplayValue = displayValue, Quality = "Good", DeviceTimestamp = "2026-09-11T01:02:03Z" }; From a4f7f9513db14bd20bd7cd40fdab767e3b397fb7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 10:27:39 +0700 Subject: [PATCH 33/42] fix(p8): reject incomplete source timestamps --- Services/Iec61850TelemetryEnvelope.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Services/Iec61850TelemetryEnvelope.cs b/Services/Iec61850TelemetryEnvelope.cs index 329b124d5..fb26681c2 100644 --- a/Services/Iec61850TelemetryEnvelope.cs +++ b/Services/Iec61850TelemetryEnvelope.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.RegularExpressions; namespace ArIED61850Tester.Services; @@ -24,6 +25,10 @@ public readonly record struct Iec61850TelemetryEnvelope( string SourceReference, string Diagnostic) { + private static readonly Regex CompleteSourceTimestampPattern = new( + @"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|\s?[+-]\d{2}:\d{2})?$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + public bool HasProcessValue => IsProcessValuePresent(Value, DisplayValue); /// @@ -100,10 +105,12 @@ public static Iec61850TelemetryEnvelope Invalid( if (text.Length == 0 || text == "-") return null; - // ARIEC61850's decoded IEC UtcTime display can be zone-less even though IEC UtcTime - // is semantically UTC. AssumeUniversal therefore preserves engine semantics here; - // parsing failure still stays null and is never replaced by ReceivedAtUtc/PC time. - if (!DateTimeOffset.TryParse( + // DateTimeOffset.TryParse accepts partial values such as "10:00:31" and fills the + // missing date from the local PC. That would fabricate source evidence. Accept only + // complete ARIEC/ISO date-time shapes before parsing. A zone-less decoded IEC UtcTime + // is semantically UTC; malformed or incomplete input remains unknown. + if (!CompleteSourceTimestampPattern.IsMatch(text) || + !DateTimeOffset.TryParse( text, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, From f3c025f7cfecae0992e8fe8c1ebe585011d73bc0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 10:27:52 +0700 Subject: [PATCH 34/42] fix(p8): use monotonic allocation timing and honest heap metrics --- Services/RuntimeAllocationSnapshot.cs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/Services/RuntimeAllocationSnapshot.cs b/Services/RuntimeAllocationSnapshot.cs index aa775dab1..195a6cc67 100644 --- a/Services/RuntimeAllocationSnapshot.cs +++ b/Services/RuntimeAllocationSnapshot.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace ArIED61850Tester.Services; /// @@ -6,19 +8,24 @@ namespace ArIED61850Tester.Services; /// public readonly record struct RuntimeAllocationSnapshot( DateTimeOffset CapturedAtUtc, + long MonotonicTimestamp, long TotalAllocatedBytes, - long HeapSizeBytes, - long FragmentedBytes, + long CurrentManagedBytes, + long LastGcHeapSizeBytes, + long LastGcFragmentedBytes, int Gen0Collections, int Gen1Collections, int Gen2Collections) { public static RuntimeAllocationSnapshot Capture() { + var monotonicTimestamp = Stopwatch.GetTimestamp(); var memory = GC.GetGCMemoryInfo(); return new RuntimeAllocationSnapshot( DateTimeOffset.UtcNow, + monotonicTimestamp, GC.GetTotalAllocatedBytes(precise: false), + GC.GetTotalMemory(forceFullCollection: false), memory.HeapSizeBytes, memory.FragmentedBytes, GC.CollectionCount(0), @@ -28,14 +35,17 @@ public static RuntimeAllocationSnapshot Capture() public RuntimeAllocationDelta DeltaFrom(RuntimeAllocationSnapshot earlier) { - var elapsed = CapturedAtUtc >= earlier.CapturedAtUtc - ? CapturedAtUtc - earlier.CapturedAtUtc + var elapsed = MonotonicTimestamp > 0 && + earlier.MonotonicTimestamp > 0 && + MonotonicTimestamp >= earlier.MonotonicTimestamp + ? Stopwatch.GetElapsedTime(earlier.MonotonicTimestamp, MonotonicTimestamp) : TimeSpan.Zero; return new RuntimeAllocationDelta( Math.Max(0, TotalAllocatedBytes - earlier.TotalAllocatedBytes), - HeapSizeBytes - earlier.HeapSizeBytes, - FragmentedBytes - earlier.FragmentedBytes, + CurrentManagedBytes - earlier.CurrentManagedBytes, + LastGcHeapSizeBytes - earlier.LastGcHeapSizeBytes, + LastGcFragmentedBytes - earlier.LastGcFragmentedBytes, Math.Max(0, Gen0Collections - earlier.Gen0Collections), Math.Max(0, Gen1Collections - earlier.Gen1Collections), Math.Max(0, Gen2Collections - earlier.Gen2Collections), @@ -45,8 +55,9 @@ public RuntimeAllocationDelta DeltaFrom(RuntimeAllocationSnapshot earlier) public readonly record struct RuntimeAllocationDelta( long AllocatedBytes, - long HeapSizeDeltaBytes, - long FragmentedBytesDelta, + long CurrentManagedBytesDelta, + long LastGcHeapSizeDeltaBytes, + long LastGcFragmentedBytesDelta, int Gen0Collections, int Gen1Collections, int Gen2Collections, From b36d723ad4e937551714cb60a1c9851df96e76a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 10:28:11 +0700 Subject: [PATCH 35/42] test(p8): reject incomplete relay timestamp evidence --- tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs index 98807c151..3dc10b9e1 100644 --- a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs +++ b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs @@ -80,6 +80,27 @@ public void MalformedRelayTimestamp_RemainsUnknown_InsteadOfUsingPcClock() Assert.Contains("could not be parsed", envelope.Diagnostic, StringComparison.OrdinalIgnoreCase); } + [Theory] + [InlineData("10:00:31")] + [InlineData("2026-09-11")] + [InlineData("2026-09-11T01:02")] + [InlineData("09/11/2026 01:02:03")] + public void IncompleteOrCultureDependentRelayTimestamp_RemainsUnknown(string deviceTimestamp) + { + var read = new Iec61850ReadValue + { + Value = true, + DisplayValue = "True", + Quality = "Good", + DeviceTimestamp = deviceTimestamp + }; + + var envelope = read.ToTelemetryEnvelope(); + + Assert.Null(envelope.SourceTimestampUtc); + Assert.Contains("could not be parsed", envelope.Diagnostic, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void GoodTelemetry_PreservesSourceTimestampSeparatelyFromReceiptTime() { @@ -123,6 +144,22 @@ public void ZoneLessArIecUtcTimeDisplay_IsInterpretedAsUtc_NotLocalPcTime() Assert.Equal(10, envelope.SourceTimestampUtc.Value.Hour); } + [Fact] + public void ExplicitOffsetTimestamp_IsNormalizedToUtc() + { + var read = new Iec61850ReadValue + { + Value = 1, + DisplayValue = "1", + Quality = "Good", + DeviceTimestamp = "2026-09-11T08:02:03+07:00" + }; + + var envelope = read.ToTelemetryEnvelope(); + + Assert.Equal(new DateTimeOffset(2026, 9, 11, 1, 2, 3, TimeSpan.Zero), envelope.SourceTimestampUtc); + } + [Theory] [InlineData("Invalid")] [InlineData("Bad / communication error")] From 0ed5ddfcd3218d4a56639836028bb1e76076d966 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 10:28:25 +0700 Subject: [PATCH 36/42] test(p8): lock monotonic allocation timing and heap semantics --- tests/ARSAS.Tests/P8MemoryPerformanceTests.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs index c934f8252..0d5ca2246 100644 --- a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs +++ b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs @@ -22,11 +22,13 @@ public void AllocationSnapshot_DeltaNeverReportsNegativeAllocatedBytesOrCollecti } [Fact] - public void AllocationSnapshot_ReversedInputClampsElapsedAndCountersSafely() + public void AllocationSnapshot_ReversedMonotonicInputClampsElapsedAndCountersSafely() { var later = new RuntimeAllocationSnapshot( new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero), 2_000, + 2_000, + 1_600, 1_500, 100, 5, @@ -36,6 +38,8 @@ public void AllocationSnapshot_ReversedInputClampsElapsedAndCountersSafely() later.CapturedAtUtc.AddSeconds(-10), 1_000, 1_000, + 1_100, + 1_000, 50, 2, 1, @@ -52,12 +56,18 @@ public void AllocationSnapshot_ReversedInputClampsElapsedAndCountersSafely() } [Fact] - public void AllocationSnapshot_ImplementationNeverForcesCollection() + public void AllocationSnapshot_ImplementationUsesMonotonicRateTiming_AndHonestHeapLabels() { var source = ReadRepoFile("Services/RuntimeAllocationSnapshot.cs"); + Assert.Contains("Stopwatch.GetTimestamp()", source, StringComparison.Ordinal); + Assert.Contains("Stopwatch.GetElapsedTime(", source, StringComparison.Ordinal); + Assert.Contains("GC.GetTotalMemory(forceFullCollection: false)", source, StringComparison.Ordinal); + Assert.Contains("LastGcHeapSizeBytes", source, StringComparison.Ordinal); + Assert.Contains("LastGcFragmentedBytes", source, StringComparison.Ordinal); Assert.Contains("GC.GetGCMemoryInfo()", source, StringComparison.Ordinal); Assert.Contains("GC.GetTotalAllocatedBytes(precise: false)", source, StringComparison.Ordinal); + Assert.DoesNotContain("CapturedAtUtc - earlier.CapturedAtUtc", source, StringComparison.Ordinal); Assert.DoesNotContain("GC.Collect(", source, StringComparison.Ordinal); Assert.DoesNotContain("GC.WaitForPendingFinalizers", source, StringComparison.Ordinal); } From 8ad2babfc1371c4934f3689bbc3366c956506dfe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 10:28:44 +0700 Subject: [PATCH 37/42] docs(p8): close second review findings --- docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md index b04821fdb..1e162f1b8 100644 --- a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md +++ b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md @@ -5,7 +5,7 @@ P8 hardens ARSAS for long-running, multi-IED IEC 61850 work without trading proc ## Non-negotiable invariants 1. **Network/process evidence is lossless.** IEC 61850 Report/GOOSE/SOE processing stays event-by-event. Only the visual latest-value projection may be coalesced. -2. **Missing or unproven data is never promoted to Good evidence.** A missing process value is `Invalid`; unknown/vendor quality remains `Questionable` or `Invalid`, never silently `Good`; an absent relay timestamp stays unknown. `ReceivedAtUtc` is separate local receipt metadata. +2. **Missing or unproven data is never promoted to Good evidence.** A missing process value is `Invalid`; unknown/vendor quality remains `Questionable` or `Invalid`, never silently `Good`; an absent, malformed, or incomplete relay timestamp stays unknown. `ReceivedAtUtc` is separate local receipt metadata. 3. **One IED cannot stall another.** Lifecycle gates, monitor cancellation, reconnect state, client instances, and report recovery are scoped per device. 4. **Native/vendor work does not execute its synchronous prefix on the WPF Dispatcher.** The UI-facing runtime facade offloads IEC 61850 lifecycle operations and provides a pre-emptive stop lane. 5. **Shutdown is bounded.** Native teardown is best effort and may not freeze the application indefinitely. @@ -47,11 +47,22 @@ Main-window timers are stopped during shutdown; the application CTS is cancelled - source/relay timestamp (`SourceTimestampUtc`), - local receipt time (`ReceivedAtUtc`). -A missing process value is `Invalid` even if malformed upstream metadata claims `Good`. Only an explicit IEC quality of `Good` makes `IsValid` true. `Questionable` data remains separately identifiable through `IsUsable` and is never promoted to Good evidence. A malformed source timestamp remains `null`; ARSAS does not replace missing relay evidence with the PC receive clock. ARIEC's zone-less decoded IEC `UtcTime` display is interpreted as UTC by protocol semantics, not as local PC time. `Iec61850ReadValue` exposes this normalized envelope while preserving the existing runtime's report/MMS evidence path. +A missing process value is `Invalid` even if malformed upstream metadata claims `Good`. Only an explicit IEC quality of `Good` makes `IsValid` true. `Questionable` data remains separately identifiable through `IsUsable` and is never promoted to Good evidence. + +Relay timestamp parsing is deliberately strict. ARSAS accepts only complete ARIEC/ISO date-time shapes containing year, month, day, hour, minute, and second. Partial strings such as `10:00:31` are rejected because general date parsers can silently fill the missing date from the local PC. A zone-less decoded IEC `UtcTime` is interpreted as UTC by protocol semantics; explicit offsets are normalized to UTC. A malformed or incomplete source timestamp remains `null` and is never replaced by `ReceivedAtUtc` or local PC time. ### P8.6 — Allocation profiling -`RuntimeAllocationSnapshot` captures total allocated bytes, managed heap size, fragmentation, and generation collection counters without forcing GC. It provides defensive deltas for repeatable field/performance baselines, including safe handling of accidentally reversed snapshots. P8 intentionally avoids hard-coded allocation or timing thresholds in CI because runner scheduling and GC timing would make such tests flaky; thresholds should be established from measured field baselines. +`RuntimeAllocationSnapshot` captures: + +- total allocated bytes, +- a current managed-memory estimate from `GC.GetTotalMemory(false)`, +- last-GC heap size and fragmentation explicitly labeled as last-GC measurements, +- generation collection counters, +- a UTC capture timestamp for diagnostics, +- an independent monotonic `Stopwatch` timestamp for rate calculations. + +Allocation-rate elapsed time is calculated only from the monotonic clock, so NTP correction or a manual Windows clock change cannot corrupt `AllocatedMegabytesPerSecond`. Snapshot capture never forces GC. P8 intentionally avoids hard-coded allocation or timing thresholds in CI because runner scheduling and GC timing would make such tests flaky; thresholds should be established from measured field baselines. ### P8.7 — Pooling decision after profiling audit @@ -66,12 +77,14 @@ If P8.6 profiling later identifies a real hotspot, pooling may be introduced in P8 protects: - null/malformed telemetry normalization, -- no fabricated relay timestamp, +- no fabricated relay timestamp from partial date/time input, - explicit Good versus Questionable/Invalid quality semantics, - production latest-value UI coalescing while SOE remains lossless FIFO, - per-IED operation/reconnect isolation, - existing independent multi-IED FAT state, - FAT row/column recycling virtualization, - bounded cancellation and shutdown/disposal ownership, +- monotonic allocation-rate timing, +- current managed-memory versus last-GC heap semantics, - allocation snapshots that do not force GC, - the rule that ambiguous native/process-bus ownership paths do not receive speculative pooling. From de7e228255b880128faca23f9551e50e9584e1ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 10:34:50 +0700 Subject: [PATCH 38/42] test(p8): make telemetry architecture guard semantic not comment-based --- tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs index a953132d0..3ddece9bf 100644 --- a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs +++ b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs @@ -86,9 +86,11 @@ public void DefensiveTelemetry_DoesNotReplaceMissingRelayTimeWithPcTime() Assert.Contains("DateTimeOffset? SourceTimestampUtc", source, StringComparison.Ordinal); Assert.Contains("DateTimeOffset ReceivedAtUtc", source, StringComparison.Ordinal); - Assert.Contains("source timestamp remains unknown", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("never replaced by ReceivedAtUtc/PC time", source, StringComparison.Ordinal); + Assert.Contains("CompleteSourceTimestampPattern", source, StringComparison.Ordinal); + Assert.Contains("DateTimeStyles.AssumeUniversal", source, StringComparison.Ordinal); + Assert.Contains("return null;", source, StringComparison.Ordinal); Assert.DoesNotContain("SourceTimestampUtc = DateTime", source, StringComparison.Ordinal); + Assert.DoesNotContain("SourceTimestampUtc = ReceivedAtUtc", source, StringComparison.Ordinal); } [Fact] From cadf024f16597da3f59ee0b6fcbe89c0aad273ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:26:46 +0700 Subject: [PATCH 39/42] chore(p8): stage production telemetry patch script --- scripts/apply-p8-production-telemetry-fix.py | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 scripts/apply-p8-production-telemetry-fix.py diff --git a/scripts/apply-p8-production-telemetry-fix.py b/scripts/apply-p8-production-telemetry-fix.py new file mode 100644 index 000000000..545568895 --- /dev/null +++ b/scripts/apply-p8-production-telemetry-fix.py @@ -0,0 +1,49 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +def replace_once(path: Path, old: str, new: str) -> None: + text = path.read_text(encoding='utf-8') + if old not in text: + raise RuntimeError(f'Expected block not found in {path}') + path.write_text(text.replace(old, new, 1), encoding='utf-8') + +native = ROOT / 'Services' / 'NativeIec61850Client.cs' +replace_once( + native, + ''' private static void ApplyDiscoveryReadValue(SignalDefinition signal, object value)\n {\n if (value is Iec61850ReadValue rich)\n {\n signal.Value = Iec61850ValueFormatter.Format(rich.Value ?? rich.ToString(), signal.DataType, signal.Unit);\n signal.Quality = rich.HasQuality ? rich.Quality : "Good";\n signal.DeviceTimestamp = rich.HasDeviceTimestamp ? rich.DeviceTimestamp : "-";\n }\n else\n {\n signal.Value = Iec61850ValueFormatter.Format(value, signal.DataType, signal.Unit);\n signal.Quality = "Good";\n }\n signal.ProbeStatus = "Readable";\n signal.Timestamp = DateTime.Now;\n }\n''', + ''' private static void ApplyDiscoveryReadValue(SignalDefinition signal, object value)\n {\n var receivedAtUtc = value is Iec61850ReadValue rich\n ? rich.ReceivedAtUtc\n : DateTimeOffset.UtcNow;\n var envelope = Iec61850ProductionTelemetryNormalizer.FromReadObject(\n value,\n signal.DataType,\n signal.Unit,\n receivedAtUtc,\n signal.ObjectReference,\n value is Iec61850ReadValue read ? read.ReadReference : signal.ObjectReference);\n\n signal.Value = envelope.HasProcessValue\n ? Iec61850ValueFormatter.Format(envelope.Value ?? envelope.DisplayValue, signal.DataType, signal.Unit)\n : "-";\n signal.Quality = envelope.QualityText;\n signal.DeviceTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(\n envelope,\n value is Iec61850ReadValue sourceRead ? sourceRead.DeviceTimestamp : null);\n signal.ProbeStatus = envelope.HasProcessValue ? "Readable" : "Readable / no process value";\n signal.Timestamp = DateTime.Now;\n }\n''') + +monitor = ROOT / 'Services' / 'Iec61850MonitorRuntime.cs' +replace_once( + monitor, + ''' var reportQuality = update.HasQuality && IsUsefulProcessField(update.Quality)\n ? NormalizeQuality(update.Quality)\n : state.HasValue ? state.Quality : "Pending / q not supplied";\n var reportTimestamp = update.HasTimestamp && IsUsefulProcessField(update.Timestamp)\n ? update.Timestamp\n : state.HasValue ? state.DeviceTimestamp : "-";\n\n ApplyValueUpdate(\n session,\n point,\n display,\n reportQuality,\n reportTimestamp,\n''', + ''' var reportEnvelope = Iec61850ProductionTelemetryNormalizer.FromComponents(\n update.HasValue ? update.Value : null,\n update.HasValue ? display : "-",\n update.HasQuality && IsUsefulProcessField(update.Quality) ? update.Quality : null,\n update.HasTimestamp && IsUsefulProcessField(update.Timestamp) ? update.Timestamp : null,\n new DateTimeOffset(DateTime.SpecifyKind(receivedUtc, DateTimeKind.Utc)),\n update.Reference);\n var reportQuality = reportEnvelope.QualityText;\n var reportTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(\n reportEnvelope,\n update.Timestamp);\n\n ApplyValueUpdate(\n session,\n point,\n display,\n reportQuality,\n reportTimestamp,\n''') +replace_once( + monitor, + ''' trustReportEdge: true,\n hasProcessValue: update.HasValue);''', + ''' trustReportEdge: true,\n hasProcessValue: reportEnvelope.HasProcessValue);''') +replace_once( + monitor, + ''' var rich = resolved.Value as Iec61850ReadValue;\n var raw = Iec61850ReadValue.Unwrap(resolved.Value);\n var display = Iec61850ValueFormatter.Format(raw, point.IecDataType, point.Unit);\n var quality = rich?.HasQuality == true ? rich.Quality : state.Quality;\n var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : state.DeviceTimestamp;\n\n if ((rich?.HasQuality != true || rich?.HasDeviceTimestamp != true) &&\n nowUtc >= session.RecoveryWarmupUntilUtc &&\n nowUtc >= state.NextCompanionPollUtc)\n {\n state.NextCompanionPollUtc = nowUtc.AddMilliseconds(GetCompanionPollIntervalMs(point));\n var companions = await ReadCompanionAttributesAsync(\n session.Client,\n point,\n resolved.EffectiveReference,\n quality,\n deviceTimestamp,\n cancellationToken).ConfigureAwait(false);\n quality = companions.Quality;\n deviceTimestamp = companions.DeviceTimestamp;\n }\n\n var normalizedQuality = NormalizeQuality(quality);\n var normalizedTimestamp = string.IsNullOrWhiteSpace(deviceTimestamp) ? "-" : deviceTimestamp;\n''', + ''' var rich = resolved.Value as Iec61850ReadValue;\n var raw = Iec61850ReadValue.Unwrap(resolved.Value);\n var display = Iec61850ValueFormatter.Format(raw, point.IecDataType, point.Unit);\n // Never carry forward stale Good/q or relay time when the current network read\n // did not actually supply them. Companion reads may enrich this sample, but if\n // they fail the defensive envelope below keeps quality Unknown and source time '-'.\n var quality = rich?.HasQuality == true ? rich.Quality : string.Empty;\n var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : string.Empty;\n\n if ((rich?.HasQuality != true || rich?.HasDeviceTimestamp != true) &&\n nowUtc >= session.RecoveryWarmupUntilUtc &&\n nowUtc >= state.NextCompanionPollUtc)\n {\n state.NextCompanionPollUtc = nowUtc.AddMilliseconds(GetCompanionPollIntervalMs(point));\n var companions = await ReadCompanionAttributesAsync(\n session.Client,\n point,\n resolved.EffectiveReference,\n quality,\n deviceTimestamp,\n cancellationToken).ConfigureAwait(false);\n quality = companions.Quality;\n deviceTimestamp = companions.DeviceTimestamp;\n }\n\n var receivedAtUtc = rich?.ReceivedAtUtc ?? DateTimeOffset.UtcNow;\n var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents(\n raw,\n display,\n quality,\n deviceTimestamp,\n receivedAtUtc,\n point.IecReference,\n resolved.EffectiveReference);\n var normalizedQuality = envelope.QualityText;\n var normalizedTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(\n envelope,\n deviceTimestamp);\n''') +replace_once( + monitor, + ''' DateTime.UtcNow,\n status,\n trustReportEdge: false);''', + ''' DateTime.UtcNow,\n status,\n trustReportEdge: false,\n hasProcessValue: envelope.HasProcessValue);''') + +normalizer = ROOT / 'Services' / 'Iec61850ProductionTelemetryNormalizer.cs' +normalizer.write_text('''namespace ArIED61850Tester.Services;\n\n/// \n/// Single production boundary between decoded IEC 61850 network data and UI/runtime state.\n/// It preserves a valid source timestamp, never fabricates PC time as relay evidence, and\n/// never promotes missing/unknown quality to Good.\n/// \npublic static class Iec61850ProductionTelemetryNormalizer\n{\n public static Iec61850TelemetryEnvelope FromReadObject(\n object? value,\n string dataType,\n string unit,\n DateTimeOffset? receivedAtUtc = null,\n string? sourceReference = null,\n string? readReference = null)\n {\n if (value is Iec61850ReadValue rich)\n return Iec61850TelemetryEnvelope.FromReadValue(rich, receivedAtUtc);\n\n var display = Iec61850ValueFormatter.Format(value, dataType, unit);\n return FromComponents(\n value,\n display,\n quality: null,\n deviceTimestamp: null,\n receivedAtUtc ?? DateTimeOffset.UtcNow,\n sourceReference,\n readReference);\n }\n\n public static Iec61850TelemetryEnvelope FromComponents(\n object? value,\n string? displayValue,\n string? quality,\n string? deviceTimestamp,\n DateTimeOffset receivedAtUtc,\n string? sourceReference = null,\n string? readReference = null)\n => Iec61850TelemetryEnvelope.FromReadValue(new Iec61850ReadValue\n {\n Value = value,\n DisplayValue = displayValue?.Trim() ?? string.Empty,\n Quality = quality?.Trim() ?? string.Empty,\n DeviceTimestamp = deviceTimestamp?.Trim() ?? string.Empty,\n SourceReference = sourceReference?.Trim() ?? string.Empty,\n ReadReference = readReference?.Trim() ?? string.Empty,\n ReceivedAtUtc = receivedAtUtc\n }, receivedAtUtc);\n\n public static string SourceTimestampTextOrUnknown(\n Iec61850TelemetryEnvelope envelope,\n string? originalTimestamp)\n => envelope.SourceTimestampUtc.HasValue\n ? originalTimestamp?.Trim() ?? "-"\n : "-";\n}\n''', encoding='utf-8') + +test = ROOT / 'tests' / 'ARSAS.Tests' / 'P8ProductionTelemetryIntegrationTests.cs' +test.write_text('''using System.Reflection;\nusing ArIED61850Tester.Models;\nusing ArIED61850Tester.Services;\n\nnamespace ARSAS.Tests;\n\npublic sealed class P8ProductionTelemetryIntegrationTests\n{\n [Fact]\n public void ProductionNormalizer_MissingQualityIsNotGood_AndMalformedTimestampStaysUnknown()\n {\n var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents(\n value: true,\n displayValue: "True",\n quality: null,\n deviceTimestamp: "10:00:31",\n receivedAtUtc: new DateTimeOffset(2026, 9, 11, 3, 0, 0, TimeSpan.Zero),\n sourceReference: "LD0/LLN0.Mod.stVal");\n\n Assert.Equal(Iec61850TelemetryQualityState.Questionable, envelope.QualityState);\n Assert.Equal("Unknown", envelope.QualityText);\n Assert.NotEqual(Iec61850TelemetryQualityState.Good, envelope.QualityState);\n Assert.Null(envelope.SourceTimestampUtc);\n Assert.Equal("-", Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(envelope, "10:00:31"));\n }\n\n [Fact]\n public void ProductionNormalizer_ExplicitGoodAndCompleteRelayTimestampPassThrough()\n {\n const string relayTimestamp = "2026-09-11T03:04:05.125Z";\n var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents(\n value: 1,\n displayValue: "1",\n quality: "Good",\n deviceTimestamp: relayTimestamp,\n receivedAtUtc: new DateTimeOffset(2026, 9, 11, 3, 4, 6, TimeSpan.Zero),\n sourceReference: "LD0/LLN0.Mod.stVal");\n\n Assert.True(envelope.IsValid);\n Assert.Equal(Iec61850TelemetryQualityState.Good, envelope.QualityState);\n Assert.Equal("Good", envelope.QualityText);\n Assert.Equal(new DateTimeOffset(2026, 9, 11, 3, 4, 5, 125, TimeSpan.Zero), envelope.SourceTimestampUtc);\n Assert.Equal(relayTimestamp, Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(envelope, relayTimestamp));\n }\n\n [Fact]\n public void DiscoveryProductionPath_DoesNotPromoteMissingQualityToGood()\n {\n var signal = new SignalDefinition\n {\n Name = "Mod",\n ObjectReference = "LD0/LLN0.Mod.stVal",\n DataType = "BOOLEAN"\n };\n var read = new Iec61850ReadValue\n {\n Value = true,\n DisplayValue = "True",\n Quality = string.Empty,\n DeviceTimestamp = "10:00:31",\n SourceReference = signal.ObjectReference,\n ReceivedAtUtc = new DateTimeOffset(2026, 9, 11, 3, 0, 0, TimeSpan.Zero)\n };\n\n var method = typeof(NativeIec61850Client).GetMethod(\n "ApplyDiscoveryReadValue",\n BindingFlags.NonPublic | BindingFlags.Static);\n\n Assert.NotNull(method);\n method!.Invoke(null, [signal, read]);\n\n Assert.Equal("Unknown", signal.Quality);\n Assert.False(signal.Quality.Equals("Good", StringComparison.OrdinalIgnoreCase));\n Assert.Equal("-", signal.DeviceTimestamp);\n Assert.Equal("True", signal.Value);\n }\n\n [Fact]\n public void RuntimeSource_UsesProductionEnvelopeBeforeStateSnapshotAndSoe()\n {\n var source = ReadRepoFile("Services/Iec61850MonitorRuntime.cs");\n\n Assert.Contains("Iec61850ProductionTelemetryNormalizer.FromComponents", source, StringComparison.Ordinal);\n Assert.Contains("hasProcessValue: envelope.HasProcessValue", source, StringComparison.Ordinal);\n Assert.Contains("hasProcessValue: reportEnvelope.HasProcessValue", source, StringComparison.Ordinal);\n Assert.DoesNotContain("var quality = rich?.HasQuality == true ? rich.Quality : state.Quality;", source, StringComparison.Ordinal);\n Assert.DoesNotContain("var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : state.DeviceTimestamp;", source, StringComparison.Ordinal);\n }\n\n private static string ReadRepoFile(string relativePath)\n {\n DirectoryInfo? directory = new(AppContext.BaseDirectory);\n while (directory != null)\n {\n var candidate = Path.Combine(directory.FullName, relativePath);\n if (File.Exists(candidate))\n return File.ReadAllText(candidate).Replace("\\r\\n", "\\n", StringComparison.Ordinal);\n directory = directory.Parent;\n }\n\n throw new FileNotFoundException(relativePath);\n }\n}\n''', encoding='utf-8') + +doc = ROOT / 'docs' / 'P8_RUNTIME_PERFORMANCE_RESILIENCE.md' +doc_text = doc.read_text(encoding='utf-8') +doc_needle = '''Relay timestamp parsing is deliberately strict. ARSAS accepts only complete ARIEC/ISO date-time shapes containing year, month, day, hour, minute, and second. Partial strings such as `10:00:31` are rejected because general date parsers can silently fill the missing date from the local PC. A zone-less decoded IEC `UtcTime` is interpreted as UTC by protocol semantics; explicit offsets are normalized to UTC. A malformed or incomplete source timestamp remains `null` and is never replaced by `ReceivedAtUtc` or local PC time.\n''' +doc_extra = '''\n`Iec61850ProductionTelemetryNormalizer` is wired into the actual production discovery, cyclic MMS validation, and final report-to-runtime projection paths. Normalization therefore occurs before `RuntimePointState`, point snapshots, and SOE metadata are updated. Missing q/t metadata is not inherited from an older sample as if it belonged to the current read; a failed companion read leaves quality `Unknown` and source time `-` rather than carrying forward stale `Good` or stale relay time.\n''' +if doc_needle not in doc_text: + raise RuntimeError('P8 documentation anchor not found') +doc.write_text(doc_text.replace(doc_needle, doc_needle + doc_extra, 1), encoding='utf-8') + +print('P8 production telemetry fix applied successfully.') From d6a984978fa777e662e18447ea822e5372d0724c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:26:57 +0700 Subject: [PATCH 40/42] chore(p8): run production telemetry integration patch --- .../workflows/p8-production-telemetry-fix.yml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/p8-production-telemetry-fix.yml diff --git a/.github/workflows/p8-production-telemetry-fix.yml b/.github/workflows/p8-production-telemetry-fix.yml new file mode 100644 index 000000000..61d6208a0 --- /dev/null +++ b/.github/workflows/p8-production-telemetry-fix.yml @@ -0,0 +1,41 @@ +name: Apply P8 production telemetry fix + +on: + push: + branches: + - feat/p8-runtime-performance-resilience + paths: + - .github/workflows/p8-production-telemetry-fix.yml + workflow_dispatch: + +permissions: + contents: write + +jobs: + apply-fix: + runs-on: windows-2025 + steps: + - name: Checkout P8 branch + uses: actions/checkout@v4 + with: + ref: feat/p8-runtime-performance-resilience + fetch-depth: 0 + + - name: Apply production telemetry fix + shell: pwsh + run: python .\scripts\apply-p8-production-telemetry-fix.py + + - name: Commit production fix and remove temporary patch automation + shell: pwsh + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + Remove-Item .\.github\workflows\p8-production-telemetry-fix.yml -Force + Remove-Item .\scripts\apply-p8-production-telemetry-fix.py -Force + git add -A + git diff --cached --stat + if (git diff --cached --quiet) { + throw "P8 telemetry patch produced no changes." + } + git commit -m "fix(p8): wire defensive telemetry into production runtime" + git push origin HEAD:feat/p8-runtime-performance-resilience From ccec5f008d62e9b03b73f87e008af0767797f2e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:27:14 +0000 Subject: [PATCH 41/42] fix(p8): wire defensive telemetry into production runtime --- .../workflows/p8-production-telemetry-fix.yml | 41 ------- Services/Iec61850MonitorRuntime.cs | 44 ++++++-- .../Iec61850ProductionTelemetryNormalizer.cs | 57 ++++++++++ Services/NativeIec61850Client.cs | 31 ++++-- docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md | 2 + scripts/apply-p8-production-telemetry-fix.py | 49 --------- .../P8ProductionTelemetryIntegrationTests.cs | 103 ++++++++++++++++++ 7 files changed, 213 insertions(+), 114 deletions(-) delete mode 100644 .github/workflows/p8-production-telemetry-fix.yml create mode 100644 Services/Iec61850ProductionTelemetryNormalizer.cs delete mode 100644 scripts/apply-p8-production-telemetry-fix.py create mode 100644 tests/ARSAS.Tests/P8ProductionTelemetryIntegrationTests.cs diff --git a/.github/workflows/p8-production-telemetry-fix.yml b/.github/workflows/p8-production-telemetry-fix.yml deleted file mode 100644 index 61d6208a0..000000000 --- a/.github/workflows/p8-production-telemetry-fix.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Apply P8 production telemetry fix - -on: - push: - branches: - - feat/p8-runtime-performance-resilience - paths: - - .github/workflows/p8-production-telemetry-fix.yml - workflow_dispatch: - -permissions: - contents: write - -jobs: - apply-fix: - runs-on: windows-2025 - steps: - - name: Checkout P8 branch - uses: actions/checkout@v4 - with: - ref: feat/p8-runtime-performance-resilience - fetch-depth: 0 - - - name: Apply production telemetry fix - shell: pwsh - run: python .\scripts\apply-p8-production-telemetry-fix.py - - - name: Commit production fix and remove temporary patch automation - shell: pwsh - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - Remove-Item .\.github\workflows\p8-production-telemetry-fix.yml -Force - Remove-Item .\scripts\apply-p8-production-telemetry-fix.py -Force - git add -A - git diff --cached --stat - if (git diff --cached --quiet) { - throw "P8 telemetry patch produced no changes." - } - git commit -m "fix(p8): wire defensive telemetry into production runtime" - git push origin HEAD:feat/p8-runtime-performance-resilience diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 0b939ec05..76df1c4a3 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1190,12 +1190,17 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT ? BuildPlanAcquisitionLabel(plan, plan.Status.Contains("Dynamic", StringComparison.OrdinalIgnoreCase)) : state.AcquisitionLabel; - var reportQuality = update.HasQuality && IsUsefulProcessField(update.Quality) - ? NormalizeQuality(update.Quality) - : state.HasValue ? state.Quality : "Pending / q not supplied"; - var reportTimestamp = update.HasTimestamp && IsUsefulProcessField(update.Timestamp) - ? update.Timestamp - : state.HasValue ? state.DeviceTimestamp : "-"; + var reportEnvelope = Iec61850ProductionTelemetryNormalizer.FromComponents( + update.HasValue ? update.Value : null, + update.HasValue ? display : "-", + update.HasQuality && IsUsefulProcessField(update.Quality) ? update.Quality : null, + update.HasTimestamp && IsUsefulProcessField(update.Timestamp) ? update.Timestamp : null, + new DateTimeOffset(DateTime.SpecifyKind(receivedUtc, DateTimeKind.Utc)), + update.Reference); + var reportQuality = reportEnvelope.QualityText; + var reportTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown( + reportEnvelope, + update.Timestamp); ApplyValueUpdate( session, @@ -1214,7 +1219,7 @@ private async Task ReceiveReportSlicesAsync(DeviceSession session, CancellationT ? string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report verified" : $"Live / report verified ({update.ProjectionStatus})" : string.IsNullOrWhiteSpace(update.ProjectionStatus) ? "Live / report traffic + MMS verification" : $"Live / report traffic + MMS verification ({update.ProjectionStatus})", trustReportEdge: true, - hasProcessValue: update.HasValue); + hasProcessValue: reportEnvelope.HasProcessValue); } } @@ -1430,8 +1435,11 @@ private async Task PollDuePointsAsync(DeviceSession session, CancellationToken c var rich = resolved.Value as Iec61850ReadValue; var raw = Iec61850ReadValue.Unwrap(resolved.Value); var display = Iec61850ValueFormatter.Format(raw, point.IecDataType, point.Unit); - var quality = rich?.HasQuality == true ? rich.Quality : state.Quality; - var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : state.DeviceTimestamp; + // Never carry forward stale Good/q or relay time when the current network read + // did not actually supply them. Companion reads may enrich this sample, but if + // they fail the defensive envelope below keeps quality Unknown and source time '-'. + var quality = rich?.HasQuality == true ? rich.Quality : string.Empty; + var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : string.Empty; if ((rich?.HasQuality != true || rich?.HasDeviceTimestamp != true) && nowUtc >= session.RecoveryWarmupUntilUtc && @@ -1449,8 +1457,19 @@ private async Task PollDuePointsAsync(DeviceSession session, CancellationToken c deviceTimestamp = companions.DeviceTimestamp; } - var normalizedQuality = NormalizeQuality(quality); - var normalizedTimestamp = string.IsNullOrWhiteSpace(deviceTimestamp) ? "-" : deviceTimestamp; + var receivedAtUtc = rich?.ReceivedAtUtc ?? DateTimeOffset.UtcNow; + var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents( + raw, + display, + quality, + deviceTimestamp, + receivedAtUtc, + point.IecReference, + resolved.EffectiveReference); + var normalizedQuality = envelope.QualityText; + var normalizedTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown( + envelope, + deviceTimestamp); if (reportAssigned && state.AwaitingCommandReportEdge && nowUtc >= state.CommandReportDeadlineUtc && !state.CommandReportMissLogged) { @@ -1514,7 +1533,8 @@ private async Task PollDuePointsAsync(DeviceSession session, CancellationToken c reason, DateTime.UtcNow, status, - trustReportEdge: false); + trustReportEdge: false, + hasProcessValue: envelope.HasProcessValue); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/Services/Iec61850ProductionTelemetryNormalizer.cs b/Services/Iec61850ProductionTelemetryNormalizer.cs new file mode 100644 index 000000000..ec7e5ce72 --- /dev/null +++ b/Services/Iec61850ProductionTelemetryNormalizer.cs @@ -0,0 +1,57 @@ +namespace ArIED61850Tester.Services; + +/// +/// Single production boundary between decoded IEC 61850 network data and UI/runtime state. +/// It preserves a valid source timestamp, never fabricates PC time as relay evidence, and +/// never promotes missing/unknown quality to Good. +/// +public static class Iec61850ProductionTelemetryNormalizer +{ + public static Iec61850TelemetryEnvelope FromReadObject( + object? value, + string dataType, + string unit, + DateTimeOffset? receivedAtUtc = null, + string? sourceReference = null, + string? readReference = null) + { + if (value is Iec61850ReadValue rich) + return Iec61850TelemetryEnvelope.FromReadValue(rich, receivedAtUtc); + + var display = Iec61850ValueFormatter.Format(value, dataType, unit); + return FromComponents( + value, + display, + quality: null, + deviceTimestamp: null, + receivedAtUtc ?? DateTimeOffset.UtcNow, + sourceReference, + readReference); + } + + public static Iec61850TelemetryEnvelope FromComponents( + object? value, + string? displayValue, + string? quality, + string? deviceTimestamp, + DateTimeOffset receivedAtUtc, + string? sourceReference = null, + string? readReference = null) + => Iec61850TelemetryEnvelope.FromReadValue(new Iec61850ReadValue + { + Value = value, + DisplayValue = displayValue?.Trim() ?? string.Empty, + Quality = quality?.Trim() ?? string.Empty, + DeviceTimestamp = deviceTimestamp?.Trim() ?? string.Empty, + SourceReference = sourceReference?.Trim() ?? string.Empty, + ReadReference = readReference?.Trim() ?? string.Empty, + ReceivedAtUtc = receivedAtUtc + }, receivedAtUtc); + + public static string SourceTimestampTextOrUnknown( + Iec61850TelemetryEnvelope envelope, + string? originalTimestamp) + => envelope.SourceTimestampUtc.HasValue + ? originalTimestamp?.Trim() ?? "-" + : "-"; +} diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index f71c41a34..7acc93cf4 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -2356,18 +2356,25 @@ private static string GetEngineeringUnitOwner(string reference) private static void ApplyDiscoveryReadValue(SignalDefinition signal, object value) { - if (value is Iec61850ReadValue rich) - { - signal.Value = Iec61850ValueFormatter.Format(rich.Value ?? rich.ToString(), signal.DataType, signal.Unit); - signal.Quality = rich.HasQuality ? rich.Quality : "Good"; - signal.DeviceTimestamp = rich.HasDeviceTimestamp ? rich.DeviceTimestamp : "-"; - } - else - { - signal.Value = Iec61850ValueFormatter.Format(value, signal.DataType, signal.Unit); - signal.Quality = "Good"; - } - signal.ProbeStatus = "Readable"; + var receivedAtUtc = value is Iec61850ReadValue rich + ? rich.ReceivedAtUtc + : DateTimeOffset.UtcNow; + var envelope = Iec61850ProductionTelemetryNormalizer.FromReadObject( + value, + signal.DataType, + signal.Unit, + receivedAtUtc, + signal.ObjectReference, + value is Iec61850ReadValue read ? read.ReadReference : signal.ObjectReference); + + signal.Value = envelope.HasProcessValue + ? Iec61850ValueFormatter.Format(envelope.Value ?? envelope.DisplayValue, signal.DataType, signal.Unit) + : "-"; + signal.Quality = envelope.QualityText; + signal.DeviceTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown( + envelope, + value is Iec61850ReadValue sourceRead ? sourceRead.DeviceTimestamp : null); + signal.ProbeStatus = envelope.HasProcessValue ? "Readable" : "Readable / no process value"; signal.Timestamp = DateTime.Now; } diff --git a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md index 1e162f1b8..e5ec6adb0 100644 --- a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md +++ b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md @@ -51,6 +51,8 @@ A missing process value is `Invalid` even if malformed upstream metadata claims Relay timestamp parsing is deliberately strict. ARSAS accepts only complete ARIEC/ISO date-time shapes containing year, month, day, hour, minute, and second. Partial strings such as `10:00:31` are rejected because general date parsers can silently fill the missing date from the local PC. A zone-less decoded IEC `UtcTime` is interpreted as UTC by protocol semantics; explicit offsets are normalized to UTC. A malformed or incomplete source timestamp remains `null` and is never replaced by `ReceivedAtUtc` or local PC time. +`Iec61850ProductionTelemetryNormalizer` is wired into the actual production discovery, cyclic MMS validation, and final report-to-runtime projection paths. Normalization therefore occurs before `RuntimePointState`, point snapshots, and SOE metadata are updated. Missing q/t metadata is not inherited from an older sample as if it belonged to the current read; a failed companion read leaves quality `Unknown` and source time `-` rather than carrying forward stale `Good` or stale relay time. + ### P8.6 — Allocation profiling `RuntimeAllocationSnapshot` captures: diff --git a/scripts/apply-p8-production-telemetry-fix.py b/scripts/apply-p8-production-telemetry-fix.py deleted file mode 100644 index 545568895..000000000 --- a/scripts/apply-p8-production-telemetry-fix.py +++ /dev/null @@ -1,49 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - -def replace_once(path: Path, old: str, new: str) -> None: - text = path.read_text(encoding='utf-8') - if old not in text: - raise RuntimeError(f'Expected block not found in {path}') - path.write_text(text.replace(old, new, 1), encoding='utf-8') - -native = ROOT / 'Services' / 'NativeIec61850Client.cs' -replace_once( - native, - ''' private static void ApplyDiscoveryReadValue(SignalDefinition signal, object value)\n {\n if (value is Iec61850ReadValue rich)\n {\n signal.Value = Iec61850ValueFormatter.Format(rich.Value ?? rich.ToString(), signal.DataType, signal.Unit);\n signal.Quality = rich.HasQuality ? rich.Quality : "Good";\n signal.DeviceTimestamp = rich.HasDeviceTimestamp ? rich.DeviceTimestamp : "-";\n }\n else\n {\n signal.Value = Iec61850ValueFormatter.Format(value, signal.DataType, signal.Unit);\n signal.Quality = "Good";\n }\n signal.ProbeStatus = "Readable";\n signal.Timestamp = DateTime.Now;\n }\n''', - ''' private static void ApplyDiscoveryReadValue(SignalDefinition signal, object value)\n {\n var receivedAtUtc = value is Iec61850ReadValue rich\n ? rich.ReceivedAtUtc\n : DateTimeOffset.UtcNow;\n var envelope = Iec61850ProductionTelemetryNormalizer.FromReadObject(\n value,\n signal.DataType,\n signal.Unit,\n receivedAtUtc,\n signal.ObjectReference,\n value is Iec61850ReadValue read ? read.ReadReference : signal.ObjectReference);\n\n signal.Value = envelope.HasProcessValue\n ? Iec61850ValueFormatter.Format(envelope.Value ?? envelope.DisplayValue, signal.DataType, signal.Unit)\n : "-";\n signal.Quality = envelope.QualityText;\n signal.DeviceTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(\n envelope,\n value is Iec61850ReadValue sourceRead ? sourceRead.DeviceTimestamp : null);\n signal.ProbeStatus = envelope.HasProcessValue ? "Readable" : "Readable / no process value";\n signal.Timestamp = DateTime.Now;\n }\n''') - -monitor = ROOT / 'Services' / 'Iec61850MonitorRuntime.cs' -replace_once( - monitor, - ''' var reportQuality = update.HasQuality && IsUsefulProcessField(update.Quality)\n ? NormalizeQuality(update.Quality)\n : state.HasValue ? state.Quality : "Pending / q not supplied";\n var reportTimestamp = update.HasTimestamp && IsUsefulProcessField(update.Timestamp)\n ? update.Timestamp\n : state.HasValue ? state.DeviceTimestamp : "-";\n\n ApplyValueUpdate(\n session,\n point,\n display,\n reportQuality,\n reportTimestamp,\n''', - ''' var reportEnvelope = Iec61850ProductionTelemetryNormalizer.FromComponents(\n update.HasValue ? update.Value : null,\n update.HasValue ? display : "-",\n update.HasQuality && IsUsefulProcessField(update.Quality) ? update.Quality : null,\n update.HasTimestamp && IsUsefulProcessField(update.Timestamp) ? update.Timestamp : null,\n new DateTimeOffset(DateTime.SpecifyKind(receivedUtc, DateTimeKind.Utc)),\n update.Reference);\n var reportQuality = reportEnvelope.QualityText;\n var reportTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(\n reportEnvelope,\n update.Timestamp);\n\n ApplyValueUpdate(\n session,\n point,\n display,\n reportQuality,\n reportTimestamp,\n''') -replace_once( - monitor, - ''' trustReportEdge: true,\n hasProcessValue: update.HasValue);''', - ''' trustReportEdge: true,\n hasProcessValue: reportEnvelope.HasProcessValue);''') -replace_once( - monitor, - ''' var rich = resolved.Value as Iec61850ReadValue;\n var raw = Iec61850ReadValue.Unwrap(resolved.Value);\n var display = Iec61850ValueFormatter.Format(raw, point.IecDataType, point.Unit);\n var quality = rich?.HasQuality == true ? rich.Quality : state.Quality;\n var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : state.DeviceTimestamp;\n\n if ((rich?.HasQuality != true || rich?.HasDeviceTimestamp != true) &&\n nowUtc >= session.RecoveryWarmupUntilUtc &&\n nowUtc >= state.NextCompanionPollUtc)\n {\n state.NextCompanionPollUtc = nowUtc.AddMilliseconds(GetCompanionPollIntervalMs(point));\n var companions = await ReadCompanionAttributesAsync(\n session.Client,\n point,\n resolved.EffectiveReference,\n quality,\n deviceTimestamp,\n cancellationToken).ConfigureAwait(false);\n quality = companions.Quality;\n deviceTimestamp = companions.DeviceTimestamp;\n }\n\n var normalizedQuality = NormalizeQuality(quality);\n var normalizedTimestamp = string.IsNullOrWhiteSpace(deviceTimestamp) ? "-" : deviceTimestamp;\n''', - ''' var rich = resolved.Value as Iec61850ReadValue;\n var raw = Iec61850ReadValue.Unwrap(resolved.Value);\n var display = Iec61850ValueFormatter.Format(raw, point.IecDataType, point.Unit);\n // Never carry forward stale Good/q or relay time when the current network read\n // did not actually supply them. Companion reads may enrich this sample, but if\n // they fail the defensive envelope below keeps quality Unknown and source time '-'.\n var quality = rich?.HasQuality == true ? rich.Quality : string.Empty;\n var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : string.Empty;\n\n if ((rich?.HasQuality != true || rich?.HasDeviceTimestamp != true) &&\n nowUtc >= session.RecoveryWarmupUntilUtc &&\n nowUtc >= state.NextCompanionPollUtc)\n {\n state.NextCompanionPollUtc = nowUtc.AddMilliseconds(GetCompanionPollIntervalMs(point));\n var companions = await ReadCompanionAttributesAsync(\n session.Client,\n point,\n resolved.EffectiveReference,\n quality,\n deviceTimestamp,\n cancellationToken).ConfigureAwait(false);\n quality = companions.Quality;\n deviceTimestamp = companions.DeviceTimestamp;\n }\n\n var receivedAtUtc = rich?.ReceivedAtUtc ?? DateTimeOffset.UtcNow;\n var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents(\n raw,\n display,\n quality,\n deviceTimestamp,\n receivedAtUtc,\n point.IecReference,\n resolved.EffectiveReference);\n var normalizedQuality = envelope.QualityText;\n var normalizedTimestamp = Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(\n envelope,\n deviceTimestamp);\n''') -replace_once( - monitor, - ''' DateTime.UtcNow,\n status,\n trustReportEdge: false);''', - ''' DateTime.UtcNow,\n status,\n trustReportEdge: false,\n hasProcessValue: envelope.HasProcessValue);''') - -normalizer = ROOT / 'Services' / 'Iec61850ProductionTelemetryNormalizer.cs' -normalizer.write_text('''namespace ArIED61850Tester.Services;\n\n/// \n/// Single production boundary between decoded IEC 61850 network data and UI/runtime state.\n/// It preserves a valid source timestamp, never fabricates PC time as relay evidence, and\n/// never promotes missing/unknown quality to Good.\n/// \npublic static class Iec61850ProductionTelemetryNormalizer\n{\n public static Iec61850TelemetryEnvelope FromReadObject(\n object? value,\n string dataType,\n string unit,\n DateTimeOffset? receivedAtUtc = null,\n string? sourceReference = null,\n string? readReference = null)\n {\n if (value is Iec61850ReadValue rich)\n return Iec61850TelemetryEnvelope.FromReadValue(rich, receivedAtUtc);\n\n var display = Iec61850ValueFormatter.Format(value, dataType, unit);\n return FromComponents(\n value,\n display,\n quality: null,\n deviceTimestamp: null,\n receivedAtUtc ?? DateTimeOffset.UtcNow,\n sourceReference,\n readReference);\n }\n\n public static Iec61850TelemetryEnvelope FromComponents(\n object? value,\n string? displayValue,\n string? quality,\n string? deviceTimestamp,\n DateTimeOffset receivedAtUtc,\n string? sourceReference = null,\n string? readReference = null)\n => Iec61850TelemetryEnvelope.FromReadValue(new Iec61850ReadValue\n {\n Value = value,\n DisplayValue = displayValue?.Trim() ?? string.Empty,\n Quality = quality?.Trim() ?? string.Empty,\n DeviceTimestamp = deviceTimestamp?.Trim() ?? string.Empty,\n SourceReference = sourceReference?.Trim() ?? string.Empty,\n ReadReference = readReference?.Trim() ?? string.Empty,\n ReceivedAtUtc = receivedAtUtc\n }, receivedAtUtc);\n\n public static string SourceTimestampTextOrUnknown(\n Iec61850TelemetryEnvelope envelope,\n string? originalTimestamp)\n => envelope.SourceTimestampUtc.HasValue\n ? originalTimestamp?.Trim() ?? "-"\n : "-";\n}\n''', encoding='utf-8') - -test = ROOT / 'tests' / 'ARSAS.Tests' / 'P8ProductionTelemetryIntegrationTests.cs' -test.write_text('''using System.Reflection;\nusing ArIED61850Tester.Models;\nusing ArIED61850Tester.Services;\n\nnamespace ARSAS.Tests;\n\npublic sealed class P8ProductionTelemetryIntegrationTests\n{\n [Fact]\n public void ProductionNormalizer_MissingQualityIsNotGood_AndMalformedTimestampStaysUnknown()\n {\n var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents(\n value: true,\n displayValue: "True",\n quality: null,\n deviceTimestamp: "10:00:31",\n receivedAtUtc: new DateTimeOffset(2026, 9, 11, 3, 0, 0, TimeSpan.Zero),\n sourceReference: "LD0/LLN0.Mod.stVal");\n\n Assert.Equal(Iec61850TelemetryQualityState.Questionable, envelope.QualityState);\n Assert.Equal("Unknown", envelope.QualityText);\n Assert.NotEqual(Iec61850TelemetryQualityState.Good, envelope.QualityState);\n Assert.Null(envelope.SourceTimestampUtc);\n Assert.Equal("-", Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(envelope, "10:00:31"));\n }\n\n [Fact]\n public void ProductionNormalizer_ExplicitGoodAndCompleteRelayTimestampPassThrough()\n {\n const string relayTimestamp = "2026-09-11T03:04:05.125Z";\n var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents(\n value: 1,\n displayValue: "1",\n quality: "Good",\n deviceTimestamp: relayTimestamp,\n receivedAtUtc: new DateTimeOffset(2026, 9, 11, 3, 4, 6, TimeSpan.Zero),\n sourceReference: "LD0/LLN0.Mod.stVal");\n\n Assert.True(envelope.IsValid);\n Assert.Equal(Iec61850TelemetryQualityState.Good, envelope.QualityState);\n Assert.Equal("Good", envelope.QualityText);\n Assert.Equal(new DateTimeOffset(2026, 9, 11, 3, 4, 5, 125, TimeSpan.Zero), envelope.SourceTimestampUtc);\n Assert.Equal(relayTimestamp, Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(envelope, relayTimestamp));\n }\n\n [Fact]\n public void DiscoveryProductionPath_DoesNotPromoteMissingQualityToGood()\n {\n var signal = new SignalDefinition\n {\n Name = "Mod",\n ObjectReference = "LD0/LLN0.Mod.stVal",\n DataType = "BOOLEAN"\n };\n var read = new Iec61850ReadValue\n {\n Value = true,\n DisplayValue = "True",\n Quality = string.Empty,\n DeviceTimestamp = "10:00:31",\n SourceReference = signal.ObjectReference,\n ReceivedAtUtc = new DateTimeOffset(2026, 9, 11, 3, 0, 0, TimeSpan.Zero)\n };\n\n var method = typeof(NativeIec61850Client).GetMethod(\n "ApplyDiscoveryReadValue",\n BindingFlags.NonPublic | BindingFlags.Static);\n\n Assert.NotNull(method);\n method!.Invoke(null, [signal, read]);\n\n Assert.Equal("Unknown", signal.Quality);\n Assert.False(signal.Quality.Equals("Good", StringComparison.OrdinalIgnoreCase));\n Assert.Equal("-", signal.DeviceTimestamp);\n Assert.Equal("True", signal.Value);\n }\n\n [Fact]\n public void RuntimeSource_UsesProductionEnvelopeBeforeStateSnapshotAndSoe()\n {\n var source = ReadRepoFile("Services/Iec61850MonitorRuntime.cs");\n\n Assert.Contains("Iec61850ProductionTelemetryNormalizer.FromComponents", source, StringComparison.Ordinal);\n Assert.Contains("hasProcessValue: envelope.HasProcessValue", source, StringComparison.Ordinal);\n Assert.Contains("hasProcessValue: reportEnvelope.HasProcessValue", source, StringComparison.Ordinal);\n Assert.DoesNotContain("var quality = rich?.HasQuality == true ? rich.Quality : state.Quality;", source, StringComparison.Ordinal);\n Assert.DoesNotContain("var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : state.DeviceTimestamp;", source, StringComparison.Ordinal);\n }\n\n private static string ReadRepoFile(string relativePath)\n {\n DirectoryInfo? directory = new(AppContext.BaseDirectory);\n while (directory != null)\n {\n var candidate = Path.Combine(directory.FullName, relativePath);\n if (File.Exists(candidate))\n return File.ReadAllText(candidate).Replace("\\r\\n", "\\n", StringComparison.Ordinal);\n directory = directory.Parent;\n }\n\n throw new FileNotFoundException(relativePath);\n }\n}\n''', encoding='utf-8') - -doc = ROOT / 'docs' / 'P8_RUNTIME_PERFORMANCE_RESILIENCE.md' -doc_text = doc.read_text(encoding='utf-8') -doc_needle = '''Relay timestamp parsing is deliberately strict. ARSAS accepts only complete ARIEC/ISO date-time shapes containing year, month, day, hour, minute, and second. Partial strings such as `10:00:31` are rejected because general date parsers can silently fill the missing date from the local PC. A zone-less decoded IEC `UtcTime` is interpreted as UTC by protocol semantics; explicit offsets are normalized to UTC. A malformed or incomplete source timestamp remains `null` and is never replaced by `ReceivedAtUtc` or local PC time.\n''' -doc_extra = '''\n`Iec61850ProductionTelemetryNormalizer` is wired into the actual production discovery, cyclic MMS validation, and final report-to-runtime projection paths. Normalization therefore occurs before `RuntimePointState`, point snapshots, and SOE metadata are updated. Missing q/t metadata is not inherited from an older sample as if it belonged to the current read; a failed companion read leaves quality `Unknown` and source time `-` rather than carrying forward stale `Good` or stale relay time.\n''' -if doc_needle not in doc_text: - raise RuntimeError('P8 documentation anchor not found') -doc.write_text(doc_text.replace(doc_needle, doc_needle + doc_extra, 1), encoding='utf-8') - -print('P8 production telemetry fix applied successfully.') diff --git a/tests/ARSAS.Tests/P8ProductionTelemetryIntegrationTests.cs b/tests/ARSAS.Tests/P8ProductionTelemetryIntegrationTests.cs new file mode 100644 index 000000000..2cc8e8a54 --- /dev/null +++ b/tests/ARSAS.Tests/P8ProductionTelemetryIntegrationTests.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class P8ProductionTelemetryIntegrationTests +{ + [Fact] + public void ProductionNormalizer_MissingQualityIsNotGood_AndMalformedTimestampStaysUnknown() + { + var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents( + value: true, + displayValue: "True", + quality: null, + deviceTimestamp: "10:00:31", + receivedAtUtc: new DateTimeOffset(2026, 9, 11, 3, 0, 0, TimeSpan.Zero), + sourceReference: "LD0/LLN0.Mod.stVal"); + + Assert.Equal(Iec61850TelemetryQualityState.Questionable, envelope.QualityState); + Assert.Equal("Unknown", envelope.QualityText); + Assert.NotEqual(Iec61850TelemetryQualityState.Good, envelope.QualityState); + Assert.Null(envelope.SourceTimestampUtc); + Assert.Equal("-", Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(envelope, "10:00:31")); + } + + [Fact] + public void ProductionNormalizer_ExplicitGoodAndCompleteRelayTimestampPassThrough() + { + const string relayTimestamp = "2026-09-11T03:04:05.125Z"; + var envelope = Iec61850ProductionTelemetryNormalizer.FromComponents( + value: 1, + displayValue: "1", + quality: "Good", + deviceTimestamp: relayTimestamp, + receivedAtUtc: new DateTimeOffset(2026, 9, 11, 3, 4, 6, TimeSpan.Zero), + sourceReference: "LD0/LLN0.Mod.stVal"); + + Assert.True(envelope.IsValid); + Assert.Equal(Iec61850TelemetryQualityState.Good, envelope.QualityState); + Assert.Equal("Good", envelope.QualityText); + Assert.Equal(new DateTimeOffset(2026, 9, 11, 3, 4, 5, 125, TimeSpan.Zero), envelope.SourceTimestampUtc); + Assert.Equal(relayTimestamp, Iec61850ProductionTelemetryNormalizer.SourceTimestampTextOrUnknown(envelope, relayTimestamp)); + } + + [Fact] + public void DiscoveryProductionPath_DoesNotPromoteMissingQualityToGood() + { + var signal = new SignalDefinition + { + Name = "Mod", + ObjectReference = "LD0/LLN0.Mod.stVal", + DataType = "BOOLEAN" + }; + var read = new Iec61850ReadValue + { + Value = true, + DisplayValue = "True", + Quality = string.Empty, + DeviceTimestamp = "10:00:31", + SourceReference = signal.ObjectReference, + ReceivedAtUtc = new DateTimeOffset(2026, 9, 11, 3, 0, 0, TimeSpan.Zero) + }; + + var method = typeof(NativeIec61850Client).GetMethod( + "ApplyDiscoveryReadValue", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + method!.Invoke(null, [signal, read]); + + Assert.Equal("Unknown", signal.Quality); + Assert.False(signal.Quality.Equals("Good", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("-", signal.DeviceTimestamp); + Assert.Equal("True", signal.Value); + } + + [Fact] + public void RuntimeSource_UsesProductionEnvelopeBeforeStateSnapshotAndSoe() + { + var source = ReadRepoFile("Services/Iec61850MonitorRuntime.cs"); + + Assert.Contains("Iec61850ProductionTelemetryNormalizer.FromComponents", source, StringComparison.Ordinal); + Assert.Contains("hasProcessValue: envelope.HasProcessValue", source, StringComparison.Ordinal); + Assert.Contains("hasProcessValue: reportEnvelope.HasProcessValue", source, StringComparison.Ordinal); + Assert.DoesNotContain("var quality = rich?.HasQuality == true ? rich.Quality : state.Quality;", source, StringComparison.Ordinal); + Assert.DoesNotContain("var deviceTimestamp = rich?.HasDeviceTimestamp == true ? rich.DeviceTimestamp : state.DeviceTimestamp;", source, StringComparison.Ordinal); + } + + private static string ReadRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return File.ReadAllText(candidate).Replace("\r\n", "\n", StringComparison.Ordinal); + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +} From a3479e95763fa673745aa9cc1fba9f4ea4ba58c0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:29:53 +0700 Subject: [PATCH 42/42] docs(p8): lock final IED field verification gate --- docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md index e5ec6adb0..6ce59f232 100644 --- a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md +++ b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md @@ -90,3 +90,7 @@ P8 protects: - current managed-memory versus last-GC heap semantics, - allocation snapshots that do not force GC, - the rule that ambiguous native/process-bus ownership paths do not receive speculative pooling. + +## Final IED field gate + +The portable build is acceptable for field verification only when the exact PR head has passed the full Windows build/regression suite, SV evidence validation, portable publish, and portable smoke test. Field verification must then confirm that static Report acquisition remains stable, FAT stays responsive, per-IED reconnect remains isolated, and missing quality/source-time metadata is displayed conservatively as `Unknown`/`-` rather than fabricated `Good` or PC time. \ No newline at end of file