diff --git a/Controls/ComtradeHarmonicsWorkstationView.cs b/Controls/ComtradeHarmonicsWorkstationView.cs index 73580609a..436fef9a2 100644 --- a/Controls/ComtradeHarmonicsWorkstationView.cs +++ b/Controls/ComtradeHarmonicsWorkstationView.cs @@ -25,9 +25,9 @@ internal sealed record ComtradeHarmonicOverviewSpectrum( /// control only renders compact, aligned small multiples so several checked analog channels can be /// compared at the same H cursor without switching channel-by-channel. /// -/// Render-path rule: spectrum normalization, harmonic lookup, engineering-value formatting and -/// brush creation happen only when ShowSpectra receives a new immutable result. OnRender consumes -/// prepared rows and only performs geometry/text drawing required for the current element size. +/// Render-path rule: native results update exact cached targets at analysis cadence. Static row +/// assets and reusable numeric buffers are rebuilt only on topology changes; composition frames +/// mutate only numeric presentation values and never rebuild brushes, labels or per-bin arrays. /// public sealed class ComtradeHarmonicsWorkstationView : FrameworkElement { @@ -67,9 +67,10 @@ public sealed class ComtradeHarmonicsWorkstationView : FrameworkElement private static readonly string[] OrderLabels = CreateOrderLabels(); private const double PresentationTimeConstantMs = 92.0; - private IReadOnlyList _spectra = Array.Empty(); - private ComtradeHarmonicOverviewSpectrum[] _smoothedSpectra = Array.Empty(); + private const double PresentationAnimationMaximumMs = 300.0; + private bool _presentationRenderingHooked; private long _lastPresentationTimestamp; + private long _presentationAnimationStartedTimestamp; private PreparedSpectrumRow[] _preparedRows = Array.Empty(); private int _maximumDisplayedOrder = -1; private string _title = "Harmonics"; @@ -81,6 +82,7 @@ public ComtradeHarmonicsWorkstationView() { Cursor = Cursors.Arrow; ToolTip = "Click any harmonic order to compare the same order across all visible analog channels."; + Unloaded += (_, _) => StopPresentationAnimation(); } internal void ShowSpectrum(string title, string subtitle, ComtradeHarmonicDisplaySpectrum spectrum) @@ -110,16 +112,26 @@ internal void ShowSpectra( _title = title ?? string.Empty; _subtitle = subtitle ?? string.Empty; var targetSpectra = spectra ?? Array.Empty(); - var now = Stopwatch.GetTimestamp(); - var elapsedMilliseconds = _lastPresentationTimestamp == 0 - ? double.PositiveInfinity - : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds; - _lastPresentationTimestamp = now; - _smoothedSpectra = SmoothSpectra(_smoothedSpectra, targetSpectra, elapsedMilliseconds); - _spectra = _smoothedSpectra; - _maximumDisplayedOrder = ResolveMaximumDisplayedOrder(_spectra); + var targetMaximumOrder = ResolveMaximumDisplayedOrder(targetSpectra); + var topologyMatches = PreparedRowsMatchTopology(_preparedRows, targetSpectra, targetMaximumOrder); + + _maximumDisplayedOrder = targetMaximumOrder; _selectedOrder = Math.Clamp(_selectedOrder, 0, Math.Max(0, _maximumDisplayedOrder)); - _preparedRows = PrepareRows(_spectra, _maximumDisplayedOrder); + + if (!topologyMatches) + { + _preparedRows = PrepareRows(targetSpectra, _maximumDisplayedOrder); + StopPresentationAnimation(); + } + else + { + UpdatePreparedTargets(_preparedRows, targetSpectra, _maximumDisplayedOrder); + if (PreparedRowsDifferFromTarget(_preparedRows)) + StartPresentationAnimation(); + else + StopPresentationAnimation(); + } + _rowTargets.Clear(); InvalidateVisual(); } @@ -128,9 +140,7 @@ internal void ShowMessage(string title, string message) { _title = title ?? string.Empty; _subtitle = message ?? string.Empty; - _spectra = Array.Empty(); - _smoothedSpectra = Array.Empty(); - _lastPresentationTimestamp = 0; + StopPresentationAnimation(); _preparedRows = Array.Empty(); _maximumDisplayedOrder = -1; _rowTargets.Clear(); @@ -217,7 +227,7 @@ private void DrawSpectrumRow( DrawText(dc, prepared.SignalLabel, 9.5, SemiboldTypeface, PrimaryTextBrush, new Point(18, row.Top + 5), dpi, LabelWidth - 22); - DrawText(dc, prepared.ThdLabel, 7.7, BodyTypeface, + DrawText(dc, prepared.TargetThdLabel, 7.7, BodyTypeface, SecondaryTextBrush, new Point(18, row.Top + 22), dpi, LabelWidth - 22); var plot = new Rect( @@ -227,7 +237,7 @@ private void DrawSpectrumRow( Math.Max(38, row.Height - 27)); _rowTargets.Add(new RowHitTarget(plot, maximumOrder)); - DrawMagnitudeGrid(dc, plot, prepared.AxisMaximum, prepared.AxisTopLabel, dpi); + DrawMagnitudeGrid(dc, plot, prepared.AxisMaximum, prepared.TargetAxisTopLabel, dpi); var slot = plot.Width / Math.Max(1, maximumOrder + 1); var barWidth = Math.Clamp(slot * 0.64, 4.0, 38.0); @@ -262,9 +272,9 @@ private void DrawSpectrumRow( if (bin.MagnitudeRms <= 0 && order != 1) continue; var labelY = Math.Max(plot.Top + 1, barRect.Top - 23); - DrawCenteredText(dc, bin.PercentLabel, 7.1, SemiboldTypeface, + DrawCenteredText(dc, prepared.TargetPercentLabels[order], 7.1, SemiboldTypeface, HarmonicLabelBrush, new Point(centerX, labelY), dpi); - DrawCenteredText(dc, bin.MagnitudeLabel, 6.9, BodyTypeface, + DrawCenteredText(dc, prepared.TargetMagnitudeLabels[order], 6.9, BodyTypeface, MagnitudeLabelBrush, new Point(centerX, labelY + 10), dpi); } } @@ -309,77 +319,72 @@ private void DrawFooter(DrawingContext dc, Rect bounds, double dpi) FooterRateBrush, new Point(bounds.Right - 16, y), dpi); } - private static ComtradeHarmonicOverviewSpectrum[] SmoothSpectra( - IReadOnlyList previous, - IReadOnlyList target, - double elapsedMilliseconds) + private void StartPresentationAnimation() { - if (target.Count == 0) - return Array.Empty(); + var now = Stopwatch.GetTimestamp(); + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + _lastPresentationTimestamp = now; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; + } - var topologyMatches = previous.Count == target.Count && previous.Count > 0; - if (topologyMatches) + private void StopPresentationAnimation() + { + if (_presentationRenderingHooked) { - for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++) - { - var before = previous[spectrumIndex]; - var next = target[spectrumIndex]; - if (!string.Equals(before.SignalName, next.SignalName, StringComparison.Ordinal) || - !string.Equals(before.Units, next.Units, StringComparison.Ordinal) || - before.Bins.Count != next.Bins.Count) - { - topologyMatches = false; - break; - } - for (var binIndex = 0; binIndex < next.Bins.Count; binIndex++) - { - if (before.Bins[binIndex].Order != next.Bins[binIndex].Order) - { - topologyMatches = false; - break; - } - } - if (!topologyMatches) break; - } + CompositionTarget.Rendering -= PresentationCompositionFrame; + _presentationRenderingHooked = false; } + _lastPresentationTimestamp = 0; + _presentationAnimationStartedTimestamp = 0; + } + + private void PresentationCompositionFrame(object? sender, EventArgs e) + { + if (!_presentationRenderingHooked) return; + + var now = Stopwatch.GetTimestamp(); + var elapsedMilliseconds = _lastPresentationTimestamp == 0 + ? 16.0 + : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); + _lastPresentationTimestamp = now; - var result = new ComtradeHarmonicOverviewSpectrum[target.Count]; - for (var spectrumIndex = 0; spectrumIndex < target.Count; spectrumIndex++) + AdvancePreparedRows(_preparedRows, elapsedMilliseconds); + + var animationAgeMilliseconds = _presentationAnimationStartedTimestamp == 0 + ? PresentationAnimationMaximumMs + : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; + if (animationAgeMilliseconds >= PresentationAnimationMaximumMs) { - var next = target[spectrumIndex]; - if (!topologyMatches) - { - result[spectrumIndex] = next with { Bins = next.Bins.ToArray() }; - continue; // First sample/channel-set change snaps; never invent a ramp from zero. - } + SnapPreparedRowsToTarget(_preparedRows); + StopPresentationAnimation(); + } - var before = previous[spectrumIndex]; - var bins = new ComtradeHarmonicDisplayBin[next.Bins.Count]; - for (var binIndex = 0; binIndex < bins.Length; binIndex++) - { - var previousBin = before.Bins[binIndex]; - var targetBin = next.Bins[binIndex]; - bins[binIndex] = new ComtradeHarmonicDisplayBin( - targetBin.Order, - PresentationEasingMath.Smooth(previousBin.MagnitudeRms, targetBin.MagnitudeRms, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.Smooth(previousBin.PercentOfFundamental, targetBin.PercentOfFundamental, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.SmoothAngleDegrees(previousBin.AngleDegrees, targetBin.AngleDegrees, elapsedMilliseconds, PresentationTimeConstantMs)); - } + InvalidateVisual(); + } - result[spectrumIndex] = new ComtradeHarmonicOverviewSpectrum( - next.SignalName, - next.Units, - PresentationEasingMath.Smooth(before.DcComponent, next.DcComponent, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.Smooth(before.FundamentalRms, next.FundamentalRms, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.Smooth(before.ThdPercent, next.ThdPercent, elapsedMilliseconds, PresentationTimeConstantMs), - next.DominantOrder, - PresentationEasingMath.Smooth(before.DominantRms, next.DominantRms, elapsedMilliseconds, PresentationTimeConstantMs), - PresentationEasingMath.Smooth(before.DominantPercent, next.DominantPercent, elapsedMilliseconds, PresentationTimeConstantMs), - next.EstimatedSampleRateHz, - next.MaximumResolvableOrder, - bins); + private static bool PreparedRowsMatchTopology( + IReadOnlyList rows, + IReadOnlyList target, + int maximumOrder) + { + if (maximumOrder < 0) + return rows.Count == 0 && target.Count == 0; + if (rows.Count != target.Count) + return false; + + for (var index = 0; index < target.Count; index++) + { + var row = rows[index]; + var spectrum = target[index]; + if (!string.Equals(row.SignalName, spectrum.SignalName, StringComparison.Ordinal) || + !string.Equals(row.Units, spectrum.Units, StringComparison.Ordinal) || + row.Bins.Length != maximumOrder + 1) + return false; } - return result; + + return true; } private static PreparedSpectrumRow[] PrepareRows( @@ -393,89 +398,159 @@ private static PreparedSpectrumRow[] PrepareRows( for (var index = 0; index < spectra.Count; index++) { var spectrum = spectra[index]; - var bins = BuildPlotBins(spectrum, maximumOrder, out var maximumMagnitude); - var axisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); var signalColor = SignalColor(spectrum.SignalName); - var signalBrush = FreezeBrush(signalColor); - var secondarySignalBrush = FreezeBrush(WithAlpha(signalColor, 190)); var unitSuffix = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $"/{spectrum.Units}"; - var axisUnit = string.IsNullOrWhiteSpace(spectrum.Units) ? string.Empty : $" {spectrum.Units}"; - rows[index] = new PreparedSpectrumRow( - bins, - axisMaximum, - signalBrush, - secondarySignalBrush, - $"{spectrum.SignalName}{unitSuffix}", - $"THD {spectrum.ThdPercent:G4}%", - $"{FormatEngineering(axisMaximum)}{axisUnit}", - spectrum.EstimatedSampleRateHz, - spectrum.EstimatedSampleRateHz > 0 ? $"{spectrum.EstimatedSampleRateHz:G6} Hz" : string.Empty); + var row = new PreparedSpectrumRow( + spectrum.SignalName, + spectrum.Units, + new PlotBin[maximumOrder + 1], + new double[maximumOrder + 1], + new double[maximumOrder + 1], + new string[maximumOrder + 1], + new string[maximumOrder + 1], + FreezeBrush(signalColor), + FreezeBrush(WithAlpha(signalColor, 190)), + $"{spectrum.SignalName}{unitSuffix}"); + rows[index] = row; + UpdatePreparedTarget(row, spectrum, maximumOrder); + SnapPreparedRowToTarget(row); } + return rows; } - private static PlotBin[] BuildPlotBins( + private static void UpdatePreparedTargets( + IReadOnlyList rows, + IReadOnlyList target, + int maximumOrder) + { + var count = Math.Min(rows.Count, target.Count); + for (var index = 0; index < count; index++) + UpdatePreparedTarget(rows[index], target[index], maximumOrder); + } + + private static void UpdatePreparedTarget( + PreparedSpectrumRow row, + ComtradeHarmonicOverviewSpectrum target, + int maximumOrder) + { + var targetFundamental = DisplayMagnitude(target.FundamentalRms); + var maximumMagnitude = 0.0; + + for (var order = 0; order <= maximumOrder; order++) + { + ResolveTargetBin(target, order, targetFundamental, out var magnitude, out var angleDegrees); + var percent = order == 1 && targetFundamental > 0.0 + ? 100.0 + : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, targetFundamental); + + row.TargetMagnitudes[order] = magnitude; + row.TargetAngles[order] = angleDegrees; + row.TargetPercentLabels[order] = $"{percent:0.#}%"; + row.TargetMagnitudeLabels[order] = FormatEngineering(magnitude); + maximumMagnitude = Math.Max(maximumMagnitude, magnitude); + } + + row.AxisMaximum = ComtradeHarmonicsOverviewMath.NiceMagnitudeAxisMaximum(maximumMagnitude); + var axisUnit = string.IsNullOrWhiteSpace(target.Units) ? string.Empty : $" {target.Units}"; + row.TargetAxisTopLabel = $"{FormatEngineering(row.AxisMaximum)}{axisUnit}"; + row.TargetThdLabel = $"THD {DisplayScalar(target.ThdPercent):G4}%"; + row.EstimatedSampleRateHz = target.EstimatedSampleRateHz; + row.SampleRateLabel = target.EstimatedSampleRateHz > 0.0 && double.IsFinite(target.EstimatedSampleRateHz) + ? $"{target.EstimatedSampleRateHz:G6} Hz" + : string.Empty; + } + + private static void ResolveTargetBin( ComtradeHarmonicOverviewSpectrum spectrum, - int maximumOrder, - out double maximumMagnitude) - { - var result = new PlotBin[maximumOrder + 1]; - maximumMagnitude = 0.0; - - var dcMagnitude = Math.Abs(double.IsFinite(spectrum.DcComponent) ? spectrum.DcComponent : 0.0); - result[0] = CreatePlotBin( - 0, - dcMagnitude, - ComtradeHarmonicsOverviewMath.PercentOfFundamental(dcMagnitude, spectrum.FundamentalRms), - 0.0); - maximumMagnitude = dcMagnitude; - - // MaximumDisplayedOrder is 10, so an integer bit mask is a cheaper first-bin-wins index - // than allocating GroupBy/Dictionary structures on every redraw. - var populatedMask = 1u; + int order, + double fundamentalRms, + out double magnitude, + out double angleDegrees) + { + if (order == 0) + { + magnitude = Math.Abs(DisplayScalar(spectrum.DcComponent)); + angleDegrees = 0.0; + return; + } + + magnitude = order == 1 ? fundamentalRms : 0.0; + angleDegrees = 0.0; for (var index = 0; index < spectrum.Bins.Count; index++) { var source = spectrum.Bins[index]; - var order = source.Order; - if (order < 1 || order > maximumOrder) - continue; - var bit = 1u << order; - if ((populatedMask & bit) != 0) + if (source.Order != order) continue; - populatedMask |= bit; - - var magnitude = order == 1 && spectrum.FundamentalRms > 0 - ? spectrum.FundamentalRms - : Math.Max(0.0, double.IsFinite(source.MagnitudeRms) ? source.MagnitudeRms : 0.0); - var percent = order == 1 - ? 100.0 - : ComtradeHarmonicsOverviewMath.PercentOfFundamental(magnitude, spectrum.FundamentalRms); - result[order] = CreatePlotBin(order, magnitude, percent, source.AngleDegrees); - maximumMagnitude = Math.Max(maximumMagnitude, magnitude); + if (order != 1) + magnitude = DisplayMagnitude(source.MagnitudeRms); + angleDegrees = double.IsFinite(source.AngleDegrees) + ? PresentationEasingMath.NormalizeAngleDegrees(source.AngleDegrees) + : 0.0; + return; } + } - for (var order = 1; order <= maximumOrder; order++) + private static void AdvancePreparedRows( + IReadOnlyList rows, + double elapsedMilliseconds) + { + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) { - var bit = 1u << order; - if ((populatedMask & bit) != 0) - continue; - var percent = order == 1 && spectrum.FundamentalRms > 0 ? 100.0 : 0.0; - var magnitude = order == 1 && spectrum.FundamentalRms > 0 ? spectrum.FundamentalRms : 0.0; - result[order] = CreatePlotBin(order, magnitude, percent, 0.0); - maximumMagnitude = Math.Max(maximumMagnitude, magnitude); + var row = rows[rowIndex]; + for (var order = 0; order < row.Bins.Length; order++) + { + var before = row.Bins[order]; + row.Bins[order] = new PlotBin( + order, + PresentationEasingMath.Smooth( + before.MagnitudeRms, + row.TargetMagnitudes[order], + elapsedMilliseconds, + PresentationTimeConstantMs), + PresentationEasingMath.SmoothAngleDegrees( + before.AngleDegrees, + row.TargetAngles[order], + elapsedMilliseconds, + PresentationTimeConstantMs)); + } } + } + + private static void SnapPreparedRowsToTarget(IReadOnlyList rows) + { + for (var index = 0; index < rows.Count; index++) + SnapPreparedRowToTarget(rows[index]); + } + + private static void SnapPreparedRowToTarget(PreparedSpectrumRow row) + { + for (var order = 0; order < row.Bins.Length; order++) + row.Bins[order] = new PlotBin(order, row.TargetMagnitudes[order], row.TargetAngles[order]); + } - return result; + private static bool PreparedRowsDifferFromTarget(IReadOnlyList rows) + { + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var row = rows[rowIndex]; + for (var order = 0; order < row.Bins.Length; order++) + { + var bin = row.Bins[order]; + if (!bin.MagnitudeRms.Equals(row.TargetMagnitudes[order]) || + !PresentationEasingMath.NormalizeAngleDegrees(bin.AngleDegrees) + .Equals(PresentationEasingMath.NormalizeAngleDegrees(row.TargetAngles[order]))) + return true; + } + } + return false; } - private static PlotBin CreatePlotBin(int order, double magnitude, double percent, double angleDegrees) - => new( - order, - magnitude, - percent, - angleDegrees, - $"{percent:0.#}%", - FormatEngineering(magnitude)); + private static double DisplayMagnitude(double value) + => double.IsFinite(value) ? Math.Max(0.0, value) : 0.0; + + private static double DisplayScalar(double value) + => double.IsFinite(value) ? value : 0.0; private static int ResolveMaximumDisplayedOrder(IReadOnlyList spectra) { @@ -615,21 +690,50 @@ private static void DrawRightAlignedText( private readonly record struct PlotBin( int Order, double MagnitudeRms, - double PercentOfFundamental, - double AngleDegrees, - string PercentLabel, - string MagnitudeLabel); - - private readonly record struct PreparedSpectrumRow( - PlotBin[] Bins, - double AxisMaximum, - Brush SignalBrush, - Brush SecondarySignalBrush, - string SignalLabel, - string ThdLabel, - string AxisTopLabel, - double EstimatedSampleRateHz, - string SampleRateLabel); + double AngleDegrees); + + private sealed class PreparedSpectrumRow + { + internal PreparedSpectrumRow( + string signalName, + string units, + PlotBin[] bins, + double[] targetMagnitudes, + double[] targetAngles, + string[] targetPercentLabels, + string[] targetMagnitudeLabels, + Brush signalBrush, + Brush secondarySignalBrush, + string signalLabel) + { + SignalName = signalName ?? string.Empty; + Units = units ?? string.Empty; + Bins = bins; + TargetMagnitudes = targetMagnitudes; + TargetAngles = targetAngles; + TargetPercentLabels = targetPercentLabels; + TargetMagnitudeLabels = targetMagnitudeLabels; + SignalBrush = signalBrush; + SecondarySignalBrush = secondarySignalBrush; + SignalLabel = signalLabel; + } + + internal string SignalName { get; } + internal string Units { get; } + internal PlotBin[] Bins { get; } + internal double[] TargetMagnitudes { get; } + internal double[] TargetAngles { get; } + internal string[] TargetPercentLabels { get; } + internal string[] TargetMagnitudeLabels { get; } + internal Brush SignalBrush { get; } + internal Brush SecondarySignalBrush { get; } + internal string SignalLabel { get; } + internal double AxisMaximum { get; set; } = 1.0; + internal string TargetAxisTopLabel { get; set; } = string.Empty; + internal string TargetThdLabel { get; set; } = string.Empty; + internal double EstimatedSampleRateHz { get; set; } + internal string SampleRateLabel { get; set; } = string.Empty; + } private readonly record struct RowHitTarget(Rect Plot, int MaximumOrder); } diff --git a/Controls/ComtradePhasorView.cs b/Controls/ComtradePhasorView.cs index 9564c3081..8208d6ed4 100644 --- a/Controls/ComtradePhasorView.cs +++ b/Controls/ComtradePhasorView.cs @@ -37,50 +37,65 @@ public sealed class ComtradePhasorView : FrameworkElement private PreparedPhasorPanel _voltagePanel = PreparedPhasorPanel.Empty; private PreparedPhasorPanel _currentPanel = PreparedPhasorPanel.Empty; private const double PresentationTimeConstantMs = 78.0; + private const double PresentationAnimationMaximumMs = 260.0; private string _headerLabel = "Fundamental phasors at C1"; private string _referenceDetail = "Select a valid analysis reference"; private string _message = string.Empty; + private ComtradePhasorVector[] _targetVoltageVectors = Array.Empty(); + private ComtradePhasorVector[] _targetCurrentVectors = Array.Empty(); private ComtradePhasorVector[] _smoothedVoltageVectors = Array.Empty(); private ComtradePhasorVector[] _smoothedCurrentVectors = Array.Empty(); + private bool _presentationRenderingHooked; private long _lastPresentationTimestamp; + private long _presentationAnimationStartedTimestamp; - internal void ShowPhasors( - string referenceLabel, - string referenceDetail, - IReadOnlyList voltageVectors, - IReadOnlyList currentVectors) - { - var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; - _headerLabel = $"Fundamental phasors at {resolvedReference}"; - _referenceDetail = referenceDetail ?? string.Empty; - - var now = Stopwatch.GetTimestamp(); - var elapsedMilliseconds = _lastPresentationTimestamp == 0 - ? double.PositiveInfinity - : Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds; - _lastPresentationTimestamp = now; + public ComtradePhasorView() +{ + Unloaded += (_, _) => StopPresentationAnimation(); +} - _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, voltageVectors, elapsedMilliseconds); - _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, currentVectors, elapsedMilliseconds); - _voltagePanel = PreparePanel(_smoothedVoltageVectors); - _currentPanel = PreparePanel(_smoothedCurrentVectors); - _message = string.Empty; - InvalidateVisual(); - } +internal void ShowPhasors( + string referenceLabel, + string referenceDetail, + IReadOnlyList voltageVectors, + IReadOnlyList currentVectors) +{ + var resolvedReference = string.IsNullOrWhiteSpace(referenceLabel) ? "Reference" : referenceLabel; + _headerLabel = $"Fundamental phasors at {resolvedReference}"; + _referenceDetail = referenceDetail ?? string.Empty; + _targetVoltageVectors = CloneVectors(voltageVectors); + _targetCurrentVectors = CloneVectors(currentVectors); + + // First sample/topology changes snap. Normal scrub updates keep the current + // presentation and converge to the newest exact native target on composition frames. + _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, _targetVoltageVectors, 0.0); + _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, _targetCurrentVectors, 0.0); + RefreshPreparedPanels(); + _message = string.Empty; + + if (VectorsDiffer(_smoothedVoltageVectors, _targetVoltageVectors) || + VectorsDiffer(_smoothedCurrentVectors, _targetCurrentVectors)) + StartPresentationAnimation(); + else + StopPresentationAnimation(); + InvalidateVisual(); +} internal void ShowMessage(string title, string message) - { - var resolvedTitle = string.IsNullOrWhiteSpace(title) ? "Phasor" : title; - _headerLabel = $"Fundamental phasors at {resolvedTitle}"; - _referenceDetail = message ?? string.Empty; - _voltagePanel = PreparedPhasorPanel.Empty; - _currentPanel = PreparedPhasorPanel.Empty; - _smoothedVoltageVectors = Array.Empty(); - _smoothedCurrentVectors = Array.Empty(); - _lastPresentationTimestamp = 0; - _message = message ?? string.Empty; - InvalidateVisual(); - } +{ + var resolvedTitle = string.IsNullOrWhiteSpace(title) ? "Phasor" : title; + _headerLabel = $"Fundamental phasors at {resolvedTitle}"; + _referenceDetail = message ?? string.Empty; + StopPresentationAnimation(); + _voltagePanel = PreparedPhasorPanel.Empty; + _currentPanel = PreparedPhasorPanel.Empty; + _targetVoltageVectors = Array.Empty(); + _targetCurrentVectors = Array.Empty(); + _smoothedVoltageVectors = Array.Empty(); + _smoothedCurrentVectors = Array.Empty(); + _message = message ?? string.Empty; + InvalidateVisual(); +} protected override void OnRender(DrawingContext dc) { @@ -129,6 +144,80 @@ protected override void OnRender(DrawingContext dc) } } + private void StartPresentationAnimation() + { + var now = Stopwatch.GetTimestamp(); + _presentationAnimationStartedTimestamp = now; + if (_presentationRenderingHooked) return; + _lastPresentationTimestamp = now; + CompositionTarget.Rendering += PresentationCompositionFrame; + _presentationRenderingHooked = true; + } + +private void StopPresentationAnimation() +{ + if (_presentationRenderingHooked) + { + CompositionTarget.Rendering -= PresentationCompositionFrame; + _presentationRenderingHooked = false; + } + _lastPresentationTimestamp = 0; + _presentationAnimationStartedTimestamp = 0; +} + +private void PresentationCompositionFrame(object? sender, EventArgs e) +{ + if (!_presentationRenderingHooked) return; + var now = Stopwatch.GetTimestamp(); + var dt = _lastPresentationTimestamp == 0 + ? 16.0 + : Math.Clamp(Stopwatch.GetElapsedTime(_lastPresentationTimestamp, now).TotalMilliseconds, 0.0, 50.0); + _lastPresentationTimestamp = now; + _smoothedVoltageVectors = SmoothVectors(_smoothedVoltageVectors, _targetVoltageVectors, dt); + _smoothedCurrentVectors = SmoothVectors(_smoothedCurrentVectors, _targetCurrentVectors, dt); + + var age = _presentationAnimationStartedTimestamp == 0 + ? PresentationAnimationMaximumMs + : Stopwatch.GetElapsedTime(_presentationAnimationStartedTimestamp, now).TotalMilliseconds; + if (age >= PresentationAnimationMaximumMs) + { + // Presentation may ease; the settled engineering display is the exact native target. + _smoothedVoltageVectors = CloneVectors(_targetVoltageVectors); + _smoothedCurrentVectors = CloneVectors(_targetCurrentVectors); + StopPresentationAnimation(); + } + RefreshPreparedPanels(); + InvalidateVisual(); +} + +private void RefreshPreparedPanels() +{ + _voltagePanel = PreparePanel(_smoothedVoltageVectors); + _currentPanel = PreparePanel(_smoothedCurrentVectors); +} + +private static ComtradePhasorVector[] CloneVectors(IReadOnlyList? source) +{ + if (source is null || source.Count == 0) return Array.Empty(); + var result = new ComtradePhasorVector[source.Count]; + for (var i = 0; i < source.Count; i++) result[i] = source[i]; + return result; +} + +private static bool VectorsDiffer(IReadOnlyList left, IReadOnlyList right) +{ + if (left.Count != right.Count) return true; + for (var i = 0; i < left.Count; i++) + { + var a = left[i]; var b = right[i]; + if (!string.Equals(a.Label, b.Label, StringComparison.Ordinal) || + !string.Equals(a.Phase, b.Phase, StringComparison.Ordinal) || + !string.Equals(a.Units, b.Units, StringComparison.Ordinal) || + !a.MagnitudeRms.Equals(b.MagnitudeRms) || !a.AngleDegrees.Equals(b.AngleDegrees)) return true; + } + return false; +} + private static ComtradePhasorVector[] SmoothVectors( IReadOnlyList previous, IReadOnlyList? target, diff --git a/MainWindow.RcbExport.cs b/MainWindow.RcbExport.cs index 44c95f25d..7d7c89f64 100644 --- a/MainWindow.RcbExport.cs +++ b/MainWindow.RcbExport.cs @@ -82,18 +82,30 @@ private static IReadOnlyList BuildRcbExportRows( if (sourceInventory == null) return liveRows; - // Never let an older/source SCL hide RCBs that the connected IED actually - // exposes. Source-backed rows are preferred for exact export identity, then - // unmatched live-discovery rows are appended as first-class export choices. + // Present concrete live instances when the IED exposes them, but never append the + // corresponding logical SCL ReportControl as a duplicate row. For example, a source + // `Buffer` with RptEnabled max=2 may appear online as Buffer01 + Buffer02. The source + // logical identity remains export authority; the concrete instance names are UX/runtime evidence. var rows = BuildSourceBackedRcbRows(device, sourceInventory, availability).ToList(); var seen = rows .Select(row => NormalizeRcbReference(row.Reference)) .Where(reference => !string.IsNullOrWhiteSpace(reference)) .ToHashSet(StringComparer.OrdinalIgnoreCase); + var sourceBackedSelectionKeys = rows + .Where(row => row.IsSourceBacked && !string.IsNullOrWhiteSpace(row.SourceSelectionKey)) + .Select(row => row.SourceSelectionKey) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var logicalSourceReferences = sourceInventory.ReportControls + .Where(descriptor => sourceBackedSelectionKeys.Contains(descriptor.SelectionKey)) + .Select(descriptor => NormalizeRcbReference(descriptor.DisplayReference)) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var liveRow in liveRows) { var key = NormalizeRcbReference(liveRow.Reference); + if (logicalSourceReferences.Contains(key)) + continue; if (!seen.Add(key)) continue; rows.Add(liveRow); @@ -361,6 +373,7 @@ private async Task ExportLegacySasRcbAsync( SchemaProfile = schema, SelectedReportControl = new SclReportControlSelection(row.SourceSelectionKey, row.ExportName), RemoveUnreferencedDataSets = false, + PreserveSourceReportControlIdentity = true, ToolId = "ARIEC61850" }), cancellationToken); @@ -459,4 +472,4 @@ private static string LastReferenceSegment(string? reference) private static string NormalizeRcbReference(string? reference) => (reference ?? string.Empty).Trim().Replace('$', '.').ToLowerInvariant(); -} +} \ No newline at end of file diff --git a/Models/RcbExportModels.cs b/Models/RcbExportModels.cs index 2d4417a28..4acd26de0 100644 --- a/Models/RcbExportModels.cs +++ b/Models/RcbExportModels.cs @@ -8,6 +8,9 @@ namespace ArIED61850Tester.Models; public sealed class RcbExportRow : ObservableObject { + private static readonly Brush ReadyIndicatorBrush = BrushFrom(22, 163, 74); + private static readonly Brush OccupiedIndicatorBrush = BrushFrom(234, 179, 8); + private bool _isSelected; private int _memberCount; private MmsRcbOperationalAvailability _availability = MmsRcbOperationalAvailability.Unknown; @@ -51,6 +54,7 @@ public MmsRcbOperationalAvailability Availability if (!Set(ref _availability, value)) return; Raise(nameof(IsSelectable)); Raise(nameof(RequiresConfirmation)); + Raise(nameof(IsClientOccupied)); Raise(nameof(StatusGlyph)); Raise(nameof(StatusBrush)); } @@ -61,30 +65,16 @@ public MmsRcbOperationalAvailability Availability public string Reason { get => _reason; set => Set(ref _reason, value?.Trim() ?? string.Empty); } public string Owner { get => _owner; set => Set(ref _owner, value?.Trim() ?? string.Empty); } - // Availability/ownership is evidence for the operator, not an export lock. - // Every discovered RCB remains selectable so the exported engineering model - // can truthfully represent what the IED exposes, including InUse/NoDataSet. + // Availability/ownership remains engineering evidence and still drives confirmation. + // The table intentionally presents only a calm client-occupancy indicator: yellow means + // an active client owns/uses the RCB; every other state is green and details stay in tooltip/evidence. public bool IsSelectable => true; - + public bool IsClientOccupied => Availability is MmsRcbOperationalAvailability.InUse or MmsRcbOperationalAvailability.UsedByCaller; public bool RequiresConfirmation => HasEvidenceConflict || Availability is not MmsRcbOperationalAvailability.Available; public string MemberCountText => MemberCount > 0 ? $"{MemberCount:N0} FCDA" : "0 FCDA"; - public string StatusGlyph => Availability switch - { - MmsRcbOperationalAvailability.Available => "✅", - MmsRcbOperationalAvailability.UsedByCaller => "●", - MmsRcbOperationalAvailability.Unknown => "⚠", - _ => "❌" - }; - public Brush StatusBrush => HasEvidenceConflict - ? BrushFrom(201, 42, 50) - : Availability switch - { - MmsRcbOperationalAvailability.Available => BrushFrom(22, 163, 74), - MmsRcbOperationalAvailability.UsedByCaller => BrushFrom(37, 99, 235), - MmsRcbOperationalAvailability.Unknown => BrushFrom(202, 138, 4), - _ => BrushFrom(201, 42, 50) - }; + public string StatusGlyph => "■"; + public Brush StatusBrush => IsClientOccupied ? OccupiedIndicatorBrush : ReadyIndicatorBrush; public string SelectionIdentity => string.IsNullOrWhiteSpace(Reference) ? Name : Reference; public static string ToStatusText(MmsRcbOperationalAvailability availability) diff --git a/RcbExportFilterWindow.xaml b/RcbExportFilterWindow.xaml index 64c9c95bf..3c2bff68e 100644 --- a/RcbExportFilterWindow.xaml +++ b/RcbExportFilterWindow.xaml @@ -183,15 +183,13 @@ - + - - - - + + + diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index bc0491d2e..e4e2a9548 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,13 +2,13 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "0023ef9a4373855497464ed3979e359c4041c95d", - "sourcePullRequest": 132, - "purpose": "Temporary ARSAS 1.6.36 combined-workstream qualification pin for the exact green ARIEC convergence head. ARIEC .NET CI #603 passed on this SHA, including provenance/source/license verification, restore, build, tests, and diagnostics. The convergence preserves the trusted-SCL golden-wire contracts used by ARSAS: SCL-authoritative DataSet/RCB identity, LDevice ldName and ReportControl indexed semantics, quoted Edition-1/vendor OSI-AP-Title compatibility, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, receiver-before-write report registration, URCB Resv -> RptEna, BRCB direct RptEna with ResvTms retry-only, two whole-RCB verification reads, one-shot GI after routing is registered, GI fail-closed cleanup, no cyclic process polling, no network DataSet-directory browse, and no dynamic DataSet mutation on the trusted-SCL path. The same convergence also locks buffered BRCB latest-state semantics through the canonical runtime value plane and preserves SCL RptEnabled@max only as diagnostics metadata; it is never authority to synthesize concrete runtime RCB names.", + "commit": "3afc924c97627fe86adbe784c905e2f35dff0b1a", + "sourcePullRequest": 133, + "purpose": "Pins merged ARIEC61850 PR #133 on top of the exact PR #132 golden-wire tree. ARIEC .NET CI #605 passed source/provenance verification, restore, build, and tests. This adds only an opt-in source-backed Legacy SAS export boundary: ARSAS may display concrete live runtime RCB slots such as Buffer01/Buffer02 while CID/IID export retains the logical source ReportControl identity and RptEnabled indexing metadata. The option is default-off, so existing exact-runtime live-model export remains unchanged. All trusted-SCL acquisition, reporting and control contracts from PR #132 remain unchanged: SCL-authoritative DataSet/RCB identity, LDevice ldName and ReportControl indexed semantics, quoted Edition-1/vendor OSI-AP-Title compatibility, Domain/VMD reconciliation, bounded sequential initial FC-root Reads, receiver-before-write report registration, URCB Resv -> RptEna, BRCB direct RptEna with ResvTms retry-only, two whole-RCB verification reads, one-shot GI after routing is registered, GI fail-closed cleanup, no cyclic process polling, no network DataSet-directory browse, and no dynamic DataSet mutation on the trusted-SCL path. SCL RptEnabled@max remains declarative design metadata and is never authority to synthesize concrete runtime RCB names.", "previousTrialPin": { - "commit": "d50e5bcb9fd428fe3d80ac72f8d4015a575cfda5", - "sourcePullRequest": 125, - "purpose": "Previous ARSAS 1.6.36 trusted-SCL golden-wire trial pin retained for explicit ancestry." + "commit": "0023ef9a4373855497464ed3979e359c4041c95d", + "sourcePullRequest": 132, + "purpose": "Previous ARSAS 1.6.36 combined golden-wire convergence pin retained for explicit ancestry." }, "fieldProvenBaseline": { "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9", diff --git a/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs new file mode 100644 index 000000000..83e3e9eea --- /dev/null +++ b/tests/ARSAS.Tests/ComtradePresentationAnimationRegressionTests.cs @@ -0,0 +1,48 @@ +namespace ARSAS.Tests; + +public sealed class ComtradePresentationAnimationRegressionTests +{ + [Fact] + public void PresentationViews_PreserveFrameClock_AndReuseHarmonicBuffers() + { + var phasor = File.ReadAllText(FindRepoFile("Controls/ComtradePhasorView.cs")); + var harmonic = File.ReadAllText(FindRepoFile("Controls/ComtradeHarmonicsWorkstationView.cs")); + + // Retargeting restarts the settle deadline without resetting an already-running frame clock. + Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", phasor, StringComparison.Ordinal); + Assert.Contains("_presentationAnimationStartedTimestamp = now;", phasor, StringComparison.Ordinal); + Assert.Contains("if (_presentationRenderingHooked) return;", phasor, StringComparison.Ordinal); + + // Harmonic composition frames mutate reusable numeric buffers; static assets stay cached. + Assert.Contains("CompositionTarget.Rendering += PresentationCompositionFrame", harmonic, StringComparison.Ordinal); + Assert.Contains("AdvancePreparedRows(_preparedRows, elapsedMilliseconds)", harmonic, StringComparison.Ordinal); + Assert.Contains("TargetMagnitudes", harmonic, StringComparison.Ordinal); + Assert.Contains("TargetMagnitudeLabels", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("RefreshPreparedSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("SmoothSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("_smoothedSpectra", harmonic, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", phasor, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", harmonic, StringComparison.Ordinal); + } + + [Fact] + public void NativeScrub_RemainsLatestWinsSingleWorker() + { + var source = File.ReadAllText(FindRepoFile("ComtradeWorkspaceWindow.P1D4LiveScrub.cs")); + Assert.Contains("if (_p1d4ScrubWorkerRunning || !_p1d4ScrubDirty)", source, StringComparison.Ordinal); + Assert.Contains("_p1d4ScrubWorkerRunning = true", source, StringComparison.Ordinal); + Assert.Contains("if (_p1d4ScrubDirty && _analysisMode != AnalysisMode.Waveform)", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/RcbIedScoutParityRegressionTests.cs b/tests/ARSAS.Tests/RcbIedScoutParityRegressionTests.cs new file mode 100644 index 000000000..11e70c409 --- /dev/null +++ b/tests/ARSAS.Tests/RcbIedScoutParityRegressionTests.cs @@ -0,0 +1,51 @@ +namespace ARSAS.Tests; + +public sealed class RcbIedScoutParityRegressionTests +{ + [Fact] + public void SourceBackedRuntimeInstances_Suppress_Logical_Scl_Duplicate() + { + var source = ReadRepoFile("MainWindow.RcbExport.cs"); + + Assert.Contains("logicalSourceReferences", source, StringComparison.Ordinal); + Assert.Contains("logicalSourceReferences.Contains(key)", source, StringComparison.Ordinal); + Assert.Contains("sourceBackedSelectionKeys", source, StringComparison.Ordinal); + } + + [Fact] + public void SourceBackedExport_Preserves_Canonical_Scl_ReportControl_Identity() + { + var source = ReadRepoFile("MainWindow.RcbExport.cs"); + + Assert.Contains("PreserveSourceReportControlIdentity = true", source, StringComparison.Ordinal); + Assert.Contains("new SclReportControlSelection(row.SourceSelectionKey, row.ExportName)", source, StringComparison.Ordinal); + } + + [Fact] + public void RcbStatus_Is_SquareOnly_YellowForClientUse_AndGreenOtherwise() + { + var model = ReadRepoFile("Models/RcbExportModels.cs"); + var xaml = ReadRepoFile("RcbExportFilterWindow.xaml"); + + Assert.Contains("Availability is MmsRcbOperationalAvailability.InUse or MmsRcbOperationalAvailability.UsedByCaller", model, StringComparison.Ordinal); + Assert.Contains("IsClientOccupied ? OccupiedIndicatorBrush : ReadyIndicatorBrush", model, StringComparison.Ordinal); + Assert.Contains("BrushFrom(234, 179, 8)", model, StringComparison.Ordinal); + Assert.Contains("BrushFrom(22, 163, 74)", model, StringComparison.Ordinal); + Assert.Contains("