diff --git a/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs b/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs index 7a08dfff6..20b293a42 100644 --- a/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs +++ b/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs @@ -243,11 +243,27 @@ protected override void OnRender(DrawingContext dc) var showVoltage = !string.Equals(ShowMode, "Current", StringComparison.OrdinalIgnoreCase); var showCurrent = !string.Equals(ShowMode, "Voltage", StringComparison.OrdinalIgnoreCase); + // Legacy DigSub groups are named after Ch1–4 and Ch5–8, not 9-2LE + // semantics. In the explicit 9-2LE profile, Ch5–8 is Ua/Ub/Uc/Un + // and MUST occupy the top pane; Ch1–4 (Ia/Ib/Ic/In) is below it. + // RAW preserves its original unverified group A/B order. + var nineTwoLe = snapshot.CurrentSeries.FirstOrDefault()?.Unit == "V" && + snapshot.VoltageSeries.FirstOrDefault()?.Unit == "A"; var visibleLanes = new List(2); - if (showVoltage) - visibleLanes.Add(new LaneDefinition(snapshot.VoltageSeries.FirstOrDefault()?.Unit == "A" ? "CURRENT · Ch1–4" : "RAW Ch1–4", snapshot.VoltageSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.VoltageSeries)); - if (showCurrent) - visibleLanes.Add(new LaneDefinition(snapshot.CurrentSeries.FirstOrDefault()?.Unit == "V" ? "VOLTAGE · Ch5–8" : "RAW Ch5–8", snapshot.CurrentSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.CurrentSeries)); + if (nineTwoLe) + { + if (showCurrent) + visibleLanes.Add(new LaneDefinition("VOLTAGE · Ch5–8", "V", snapshot.CurrentSeries)); + if (showVoltage) + visibleLanes.Add(new LaneDefinition("CURRENT · Ch1–4", "A", snapshot.VoltageSeries)); + } + else + { + if (showVoltage) + visibleLanes.Add(new LaneDefinition("RAW Ch1–4", "counts", snapshot.VoltageSeries)); + if (showCurrent) + visibleLanes.Add(new LaneDefinition("RAW Ch5–8", "counts", snapshot.CurrentSeries)); + } if (visibleLanes.Count == 0) { diff --git a/Views/SmvInlineViewer.xaml b/Views/SmvInlineViewer.xaml index 3c725b2ad..3be20f852 100644 --- a/Views/SmvInlineViewer.xaml +++ b/Views/SmvInlineViewer.xaml @@ -406,9 +406,11 @@ - - + + + + @@ -521,62 +523,46 @@ - - - - - - - - - - - - + + + + + + + + + + + + - + - + - + - + - - - - - - - - - - - - - - - - + _visibleVectors = []; private string? _selectedKey; private string _captureStatus = "Select an Ethernet adapter and press Start. No IED or SCL is required."; @@ -58,6 +63,12 @@ public partial class SmvInlineViewer : UserControl, INotifyPropertyChanged public string PowerStatus => _powerTotal is null ? "Power unavailable · requires clean 9-2LE U/I sample pairing" : "P true · Q₁ fundamental · S true RMS · PF true (operator-selected U/I)"; + public string PowerStatusShort => _powerTotal is null + ? "9-2LE required" : "True P · Q₁ fundamental"; + public string PowerDetails => _powerTotal is null + ? "No power calculation for unverified RAW channels or incomplete snapshots." + : PowerStatus + "\n" + string.Join("\n", PowerPhases.Select(phase => + $"{phase.Name}: P {phase.PText} Q₁ {phase.QText} S {phase.SText} PF {phase.PfText}")); public string SyncLabel => SmvSyncPresentation.Label(_visibleSnapshot); public string SyncDetail => SmvSyncPresentation.Detail(_visibleSnapshot); public Brush SyncBackground => SmvSyncPresentation.Background(_visibleSnapshot); @@ -69,9 +80,9 @@ public partial class SmvInlineViewer : UserControl, INotifyPropertyChanged public ICollectionView CurrentMeasurements { get; } public string VoltageGroupHeading => ActiveUnits == SmvDisplayUnits.NineTwoLe - ? "Voltage · Ch5–8 (V)" : "Voltage · Ch1–4 (raw)*"; + ? "Voltage · Ch5–8 (V)" : "RAW Group A · Ch1–4 (counts)"; public string CurrentGroupHeading => ActiveUnits == SmvDisplayUnits.NineTwoLe - ? "Current · Ch1–4 (A)" : "Current · Ch5–8 (raw)*"; + ? "Current · Ch1–4 (A)" : "RAW Group B · Ch5–8 (counts)"; // The original DigSub WPF controls repaint immutable models, not packet work. public WaveformSnapshot? CurrentWaveform { get; private set; } @@ -106,7 +117,7 @@ public string TimeAxisCaption public string UnitStatus => RequestedUnits switch { - SmvDisplayUnits.RawCounts => "RAW counts · *Voltage/Current grouping is unverified (no SCL channel mapping)", + SmvDisplayUnits.RawCounts => "RAW counts · Voltage/Current grouping is unverified; 9-2LE profile: Ch1–4 ×0.001 A, Ch5–8 ×0.01 V (operator-selected)", _ when ActiveUnits == SmvDisplayUnits.NineTwoLe => "9-2LE user profile · 1 mA / 10 mV per count · verify publisher DataSet", _ => "9-2LE selected · need eight clean channels; displaying RAW counts" @@ -378,38 +389,83 @@ private void ApplyUpdate(SmvLiveUpdate update, int generation) // no mixing old results after stream selection or captured-source changes. if (update.SelectedKey != selectedKey || update.SelectedSnapshot is null) { - ClearPresentation(summary.Diagnostic); + // A buffer in its initial warm-up window must not repeatedly blank + // every pane 5 times/second. Stream changes clear only once in the + // SelectionChanged handler. Hold the last coherent state otherwise. + if (_visibleSnapshot is null && Measurements.Count == 0) + WaveformStatus = summary.Diagnostic; return; } - var snapshot = update.SelectedSnapshot; - _visibleSnapshot = snapshot; - IsLiveSnapshot = true; - Notify(nameof(IsLiveSnapshot)); - UpdateUnitPresentation(redrawWaveform: true); - BeginAnalysis(snapshot, selectedKey, generation); + BeginAnalysis(update.SelectedSnapshot, selectedKey, generation); + } + + private void BeginAnalysis(SmvSnapshotResult snapshot, string? selectedKey, int generation) + { + // Latest-value backpressure: retain at most one waiting coherent window + // while the prior one is processed. Never accumulate Task.Run jobs. + _pendingAnalysis = new PendingAnalysis(snapshot, selectedKey, generation); + if (_analysisRunning) + return; + _analysisRunning = true; + _ = ProcessAnalysisQueueAsync(); } - private async void BeginAnalysis(SmvSnapshotResult snapshot, string? selectedKey, int generation) + private async Task ProcessAnalysisQueueAsync() { - var analysis = Interlocked.Increment(ref _analysisGeneration); try { - var vectors = await Task.Run(() => SmvRawVectorMath.Analyze(snapshot)); - if (generation != _generation || analysis != _analysisGeneration || - !ReferenceEquals(_visibleSnapshot, snapshot) || !IsLoaded || - selectedKey != Volatile.Read(ref _selectedKey)) - return; - _visibleVectors = SmvDigSubPresentationAdapter.RelativeMeasurements(vectors); - UpdateUnitPresentation(redrawWaveform: false); + while (_pendingAnalysis is { } pending) + { + _pendingAnalysis = null; + var snapshot = pending.Snapshot; + var selectedKey = pending.SelectedKey; + var generation = pending.Generation; + var analysis = Interlocked.Increment(ref _analysisGeneration); + var profile = RequestedUnits; + try + { + // One bounded immutable publication: DSP and waveform are + // calculated on the same selected snapshot off the UI thread. + var rendered = await Task.Run(() => + { + var vectors = SmvRawVectorMath.Analyze(snapshot); + var waveform = SmvDigSubPresentationAdapter.BuildWaveform(snapshot, profile); + return (vectors, waveform); + }); + if (generation != _generation || analysis != _analysisGeneration || + !IsLoaded || !_capturing || + selectedKey != Volatile.Read(ref _selectedKey)) + continue; + + _visibleSnapshot = snapshot; + _visibleVectors = SmvDigSubPresentationAdapter.RelativeMeasurements(rendered.vectors); + CurrentWaveform = rendered.waveform; + Notify(nameof(CurrentWaveform)); + IsLiveSnapshot = true; + Notify(nameof(IsLiveSnapshot)); + UpdateUnitPresentation(redrawWaveform: profile != RequestedUnits); + if (profile == RequestedUnits) + WaveformStatus = + (snapshot.TimebaseReason.StartsWith("ASSUMED", StringComparison.OrdinalIgnoreCase) + ? "ASSUMED timebase · rate/PTP unverified · " : string.Empty) + + $"Received {snapshot.CapturedSamples:N0} coherent samples • {snapshot.SamplesPerCycle} samples/cycle • " + + (ActiveUnits == SmvDisplayUnits.NineTwoLe + ? "9-2LE operator-selected engineering display" + : "raw INT32 counts"); + } + catch (Exception) + { + if (generation != _generation || analysis != _analysisGeneration || + selectedKey != Volatile.Read(ref _selectedKey)) + continue; + WaveformStatus = "New SV analysis unavailable; displaying last coherent snapshot."; + } + } } - catch (Exception) + finally { - if (generation != _generation || analysis != _analysisGeneration) - return; - _visibleVectors = []; - UpdateUnitPresentation(redrawWaveform: false); - WaveformStatus = "Raw phasor calculation unavailable; no substitute values shown."; + _analysisRunning = false; } } @@ -434,6 +490,8 @@ private void UpdateStreamDetail(SmvLiveStreamSummary? summary) private void ClearPresentation(string message) { + _pendingAnalysis = null; + Interlocked.Increment(ref _analysisGeneration); _visibleSnapshot = null; _visibleVectors = []; Measurements.Clear(); @@ -481,20 +539,45 @@ private void UpdateUnitPresentation(bool redrawWaveform) // The immutable wire/DSP vectors remain raw. Scaling is presentation-only. // WPF collections remain bounded to at most the 8 displayed channels. - Measurements.Clear(); - // The phase reference is selected at PRESENTATION time; no received - // sample, total RMS or fundamental magnitude is reinterpreted. + // Preserve the same bounded DataGrid collection/view and replace only + // changed rows; Clear/Add resets WPF row containers and visibly flickers. + // The phase reference is selected at PRESENTATION time; no wire value + // or DFT result is overwritten. var presentationVectors = SmvDigSubPresentationAdapter.RelativeMeasurements(_visibleVectors, profile); + var rowIndex = 0; foreach (var raw in presentationVectors) - Measurements.Add(SmvEngineeringDisplay.Present(raw, profile)); + { + var next = SmvEngineeringDisplay.Present(raw, profile); + if (rowIndex < Measurements.Count) + { + if (!Equals(Measurements[rowIndex], next)) + Measurements[rowIndex] = next; + } + else + Measurements.Add(next); + rowIndex++; + } + while (Measurements.Count > rowIndex) + Measurements.RemoveAt(Measurements.Count - 1); // A single two-cycle immutable snapshot is the only source of power // calculations; never pair samples across different publishers/frames. var power = SmvPowerMeasurements.Analyze(_visibleSnapshot, _visibleVectors, profile); - PowerPhases.Clear(); + var phaseCount = 0; if (power is { } actual) foreach (var phase in actual.Phases) - PowerPhases.Add(phase); + { + if (phaseCount < PowerPhases.Count) + { + if (!Equals(PowerPhases[phaseCount], phase)) + PowerPhases[phaseCount] = phase; + } + else + PowerPhases.Add(phase); + phaseCount++; + } + while (PowerPhases.Count > phaseCount) + PowerPhases.RemoveAt(PowerPhases.Count - 1); _powerTotal = power?.Total; NotifyPowerStatus(); UpdateDigSubPhasors(); @@ -516,6 +599,8 @@ private void NotifyPowerStatus() Notify(nameof(PowerS)); Notify(nameof(PowerPf)); Notify(nameof(PowerStatus)); + Notify(nameof(PowerStatusShort)); + Notify(nameof(PowerDetails)); } private void WaveformOptionsChanged(object sender, SelectionChangedEventArgs e) diff --git a/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs b/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs index f2dba562b..4573c2b3a 100644 --- a/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs +++ b/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs @@ -55,7 +55,8 @@ public void GroupingKeepsOriginalEightRawValuesAndHonorsExplicit9_2LeOrder() Assert.Contains("new ListCollectionView(Measurements)", code); Assert.Contains("SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", code); Assert.Contains("!SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", code); - Assert.Contains("Measurements.Add(SmvEngineeringDisplay.Present(raw, profile))", code); + Assert.Contains("var next = SmvEngineeringDisplay.Present(raw, profile);", code); + Assert.Contains("Measurements[rowIndex] = next;", code); Assert.Contains("Notify(nameof(VoltageGroupHeading))", code); Assert.Contains("Notify(nameof(CurrentGroupHeading))", code); Assert.Contains("grouping is unverified", code); diff --git a/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs b/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs index a8706c10f..647e68ea8 100644 --- a/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs +++ b/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs @@ -21,7 +21,9 @@ public void MainSmvTab_HostsFullViewerWithoutIEDSelectionGate() Assert.Contains("new SmvSnapshotCaptureService().ListAdapters()", code); Assert.Contains("Volatile.Read(ref _selectedKey)", code); Assert.Contains("SmvDigSubPresentationAdapter.BuildWaveform(snapshot, profile)", code); - Assert.Contains("SmvDigSubPresentationAdapter.RelativeMeasurements(vectors)", code); + Assert.Contains("SmvDigSubPresentationAdapter.RelativeMeasurements(rendered.vectors)", code); + Assert.Contains("var vectors = SmvRawVectorMath.Analyze(snapshot);", code); + Assert.Contains("CurrentWaveform = rendered.waveform;", code); Assert.DoesNotContain("private void DrawWaveform()", code); Assert.DoesNotContain("private void DrawPhasors()", code); Assert.Contains("MainTabs.SelectedIndex = EngineeringNavigationRoute.SmvWorkspaceIndex", quick); diff --git a/tests/ARSAS.Tests/SmvP3FatUxRegressionTests.cs b/tests/ARSAS.Tests/SmvP3FatUxRegressionTests.cs new file mode 100644 index 000000000..e62019bfe --- /dev/null +++ b/tests/ARSAS.Tests/SmvP3FatUxRegressionTests.cs @@ -0,0 +1,102 @@ +using System.IO; +using System.Linq; +using System.Xml.Linq; +using ArIED61850Tester.Views; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +/// +/// Real Windows FAT reports: power overlapped with the channel tables, current +/// sat above the Ua phase reference, and SV switching blanked the whole UI. +/// These contracts are source/layout and electrical display safeguards. +/// +public sealed class SmvP3FatUxRegressionTests +{ + [Fact] + public void MeasurementsPanel_HasSevenDefinedRows_PowerAndFooterCannotShareOneSlot() + { + var xaml = XDocument.Parse(Read("Views/SmvInlineViewer.xaml")); + var heading = xaml.Descendants().Single(x => + x.Name.LocalName == "TextBlock" && + (string?)x.Attribute("Text") == "Measurements"); + var panel = heading.Parent!.Parent!; + var definitions = panel.Elements().Single(x => + x.Name.LocalName == "Grid.RowDefinitions"); + Assert.Equal(7, definitions.Elements().Count()); + var power = panel.Descendants().Single(x => + x.Name.LocalName == "Border" && + (string?)x.Attribute("ToolTip") == "{Binding PowerDetails}"); + Assert.Equal("4", (string?)power.Attribute("Grid.Row")); + var footer = panel.Descendants().Single(x => + x.Name.LocalName == "TextBlock" && + (string?)x.Attribute("Text") == "{Binding UnitStatus}"); + Assert.Equal("6", (string?)footer.Attribute("Grid.Row")); + Assert.DoesNotContain("IsExpanded=\"False\"", power.ToString()); + Assert.Contains("Text=\"{Binding PowerStatusShort}\"", power.ToString()); + Assert.Equal(2, xaml.Descendants().Count(x => x.Name.LocalName == "DataGrid")); + } + + [Fact] + public void TrueVoltageIsFirstPlotInMappedProfile_AndRawOriginalOrderIsUnchanged() + { + var wave = Read("Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs"); + var mapped = wave.IndexOf("if (nineTwoLe)", StringComparison.Ordinal); + Assert.True(mapped >= 0); + var voltage = wave.IndexOf("visibleLanes.Add(new LaneDefinition(\"VOLTAGE · Ch5–8\"", mapped, StringComparison.Ordinal); + var current = wave.IndexOf("visibleLanes.Add(new LaneDefinition(\"CURRENT · Ch1–4\"", mapped, StringComparison.Ordinal); + Assert.True(voltage > mapped && current > voltage); + var raw = wave.IndexOf("visibleLanes.Add(new LaneDefinition(\"RAW Ch1–4\"", current, StringComparison.Ordinal); + Assert.True(raw > current); + } + + [Fact] + public void LiveUi_StagesOneCoherentSnapshotOffThread_AndDoesNotClearEightRowsEachRefresh() + { + var code = Read("Views/SmvInlineViewer.xaml.cs"); + Assert.Contains("var vectors = SmvRawVectorMath.Analyze(snapshot);", code); + Assert.Contains("var waveform = SmvDigSubPresentationAdapter.BuildWaveform(snapshot, profile);", code); + Assert.Contains("if (generation != _generation || analysis != _analysisGeneration", code); + Assert.Contains("CurrentWaveform = rendered.waveform;", code); + Assert.Contains("if (_analysisRunning)", code); + Assert.Contains("_pendingAnalysis = new PendingAnalysis(snapshot, selectedKey, generation);", code); + Assert.Contains("while (_pendingAnalysis is { } pending)", code); + Assert.Contains("if (generation != _generation || analysis != _analysisGeneration", code); + Assert.Contains("Measurements[rowIndex] = next;", code); + Assert.Contains("if (!Equals(Measurements[rowIndex], next))", code); + Assert.Contains("selectedKey != Volatile.Read(ref _selectedKey)", code); + var presentation = code[code.IndexOf("private void UpdateUnitPresentation(", StringComparison.Ordinal)..]; + Assert.DoesNotContain("Measurements.Clear();", presentation); + } + + [Fact] + public void RawAndNineTwoLe_AreSameWireMagnitudesButExplicitlyDifferentUnitsAndGroups() + { + var i = new SmvRawVectorMeasurement(1, "Ch1", 1001.931, 1001.931, 15, true); + var u = new SmvRawVectorMeasurement(5, "Ch5", 10000.039, 10000.039, 0, true); + var iRaw = SmvEngineeringDisplay.Present(i, SmvDisplayUnits.RawCounts); + var uRaw = SmvEngineeringDisplay.Present(u, SmvDisplayUnits.RawCounts); + var current = SmvEngineeringDisplay.Present(i, SmvDisplayUnits.NineTwoLe); + var voltage = SmvEngineeringDisplay.Present(u, SmvDisplayUnits.NineTwoLe); + Assert.Equal("counts", iRaw.Unit); + Assert.Equal("counts", uRaw.Unit); + Assert.Equal(1.001931, current.Rms, 6); + Assert.Equal(100.00039, voltage.Rms, 6); + Assert.Equal("A", current.Unit); + Assert.Equal("V", voltage.Unit); + var view = Read("Views/SmvInlineViewer.xaml.cs"); + Assert.Contains("RAW Group A · Ch1–4 (counts)", view); + Assert.Contains("RAW Group B · Ch5–8 (counts)", view); + Assert.Contains("Voltage/Current grouping is unverified", view); + } + + private static string Read(string relativePath) + { + for (DirectoryInfo? d = new(AppContext.BaseDirectory); d is not null; d = d.Parent) + { + var file = Path.Combine(d.FullName, relativePath); + if (File.Exists(file)) return File.ReadAllText(file); + } + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/SmvP3PresentationSourceRegressionTests.cs b/tests/ARSAS.Tests/SmvP3PresentationSourceRegressionTests.cs index 1a9b265d2..fee7deb50 100644 --- a/tests/ARSAS.Tests/SmvP3PresentationSourceRegressionTests.cs +++ b/tests/ARSAS.Tests/SmvP3PresentationSourceRegressionTests.cs @@ -15,8 +15,9 @@ public void P3ReadoutsAreVisibleAndProfileUnverifiedPowerIsNotInvented() Assert.Contains("Text=\"{Binding PowerQ}\"", xaml); Assert.Contains("Text=\"{Binding PowerS}\"", xaml); Assert.Contains("Text=\"{Binding PowerPf}\"", xaml); - Assert.Contains("ItemsSource=\"{Binding PowerPhases}\"", xaml); - Assert.Contains("IsExpanded=\"False\"", xaml); + Assert.Contains("ToolTip=\"{Binding PowerDetails}\"", xaml); + Assert.Contains("Grid.Row=\"6\" Text=\"{Binding UnitStatus}\"", xaml); + Assert.DoesNotContain("Header=\"Per-phase P / Q₁ / S / PF\"", xaml); var power = Source("Views/SmvPowerMeasurements.cs"); Assert.Contains("SmvEngineeringDisplay.Effective(snapshot, selected) != SmvDisplayUnits.NineTwoLe", power); Assert.Contains("!snapshot.IsCleanProof", power);