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/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() { diff --git a/Services/Iec61850TelemetryEnvelope.cs b/Services/Iec61850TelemetryEnvelope.cs new file mode 100644 index 000000000..fb26681c2 --- /dev/null +++ b/Services/Iec61850TelemetryEnvelope.cs @@ -0,0 +1,167 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +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 quality is preserved conservatively 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) +{ + 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); + + /// + /// 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, + DateTimeOffset? receivedAtUtc = null) + { + // 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( + received, + sourceReference: string.Empty, + diagnostic: "IEC 61850 read returned no value object."); + } + + var display = read.DisplayValue?.Trim() ?? string.Empty; + var hasValue = IsProcessValuePresent(read.Value, 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." + : 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, + 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", + 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; + + // 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, + out var parsed)) + { + return null; + } + + 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) + 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; + } + + return Iec61850TelemetryQualityState.Questionable; + } + + 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; +} 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/Services/RuntimeAllocationSnapshot.cs b/Services/RuntimeAllocationSnapshot.cs new file mode 100644 index 000000000..195a6cc67 --- /dev/null +++ b/Services/RuntimeAllocationSnapshot.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; + +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 MonotonicTimestamp, + long TotalAllocatedBytes, + 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), + GC.CollectionCount(1), + GC.CollectionCount(2)); + } + + public RuntimeAllocationDelta DeltaFrom(RuntimeAllocationSnapshot earlier) + { + 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), + 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), + elapsed); + } +} + +public readonly record struct RuntimeAllocationDelta( + long AllocatedBytes, + long CurrentManagedBytesDelta, + long LastGcHeapSizeDeltaBytes, + long LastGcFragmentedBytesDelta, + int Gen0Collections, + int Gen1Collections, + int Gen2Collections, + TimeSpan Elapsed) +{ + public double AllocatedMegabytes => AllocatedBytes / (1024d * 1024d); + public double AllocatedMegabytesPerSecond => + Elapsed.TotalSeconds <= 0d ? 0d : AllocatedMegabytes / Elapsed.TotalSeconds; +} diff --git a/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md new file mode 100644 index 000000000..6ce59f232 --- /dev/null +++ b/docs/P8_RUNTIME_PERFORMANCE_RESILIENCE.md @@ -0,0 +1,96 @@ +# P8 — Runtime Performance & Resilience + +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 + +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, 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. +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 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. 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 production Engineering workspace already contains the correct split between acquisition and presentation: + +- 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. + +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. 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. P8 locks these ownership boundaries without adding another global lifetime manager. + +### 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 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. + +`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: + +- 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 + +No new production pooling is introduced in P8. + +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. + +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. + +## Regression coverage + +P8 protects: + +- null/malformed telemetry normalization, +- 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. + +## 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 diff --git a/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs new file mode 100644 index 000000000..0d5ca2246 --- /dev/null +++ b/tests/ARSAS.Tests/P8MemoryPerformanceTests.cs @@ -0,0 +1,88 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class P8MemoryPerformanceTests +{ + [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.Elapsed >= TimeSpan.Zero); + Assert.True(delta.AllocatedMegabytesPerSecond >= 0d); + } + + [Fact] + 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, + 3, + 1); + var earlier = new RuntimeAllocationSnapshot( + later.CapturedAtUtc.AddSeconds(-10), + 1_000, + 1_000, + 1_100, + 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_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); + } + + 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); + } +} 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); + } +} diff --git a/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs new file mode 100644 index 000000000..3ddece9bf --- /dev/null +++ b/tests/ARSAS.Tests/P8RuntimeArchitectureRegressionTests.cs @@ -0,0 +1,125 @@ +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); + + Assert.False(File.Exists(Path.Combine(FindRepoRoot(), "Services", "LatestValueUiBatcher.cs"))); + } + + [Fact] + public void LargeProductionGrids_UseRecyclingVirtualization() + { + 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] + 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("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] + 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); + + 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."); + } +} diff --git a/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs new file mode 100644 index 000000000..3dc10b9e1 --- /dev/null +++ b/tests/ARSAS.Tests/P8TelemetryEnvelopeTests.cs @@ -0,0 +1,202 @@ +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.False(envelope.IsUsable); + Assert.Equal(Iec61850TelemetryQualityState.Invalid, envelope.QualityState); + Assert.Null(envelope.SourceTimestampUtc); + 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); + } + + [Theory] + [InlineData("-")] + [InlineData(" - ")] + [InlineData("")] + [InlineData(" ")] + public void MissingProcessValue_RemainsInvalid_EvenWhenQualitySaysGood(string displayValue) + { + var read = new Iec61850ReadValue + { + Value = null, + DisplayValue = displayValue, + Quality = "Good", + DeviceTimestamp = "2026-09-11T01:02:03Z" + }; + + var envelope = read.ToTelemetryEnvelope(); + + Assert.False(envelope.IsValid); + Assert.False(envelope.IsUsable); + Assert.False(envelope.HasProcessValue); + 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.True(envelope.IsUsable); + Assert.Null(envelope.SourceTimestampUtc); + Assert.Equal(received, envelope.ReceivedAtUtc); + 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() + { + 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.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); + 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.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); + } + + [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")] + [InlineData("Failure")] + [InlineData("Reserved")] + 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); + Assert.False(envelope.IsUsable); + } + + [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.False(envelope.IsValid); + Assert.True(envelope.IsUsable); + Assert.NotEqual(Iec61850TelemetryQualityState.Good, envelope.QualityState); + Assert.Contains("not proven Good", envelope.Diagnostic, StringComparison.OrdinalIgnoreCase); + } +}