From d639fdedd60e1d90df0b9746d65f8febc0bdca31 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 18:03:34 +0700 Subject: [PATCH 1/9] P1C: add native WPF COMTRADE phasor visualization --- Controls/ComtradePhasorView.cs | 162 +++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 Controls/ComtradePhasorView.cs diff --git a/Controls/ComtradePhasorView.cs b/Controls/ComtradePhasorView.cs new file mode 100644 index 000000000..91cd2a4da --- /dev/null +++ b/Controls/ComtradePhasorView.cs @@ -0,0 +1,162 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Media; + +namespace ArIED61850Tester.Controls; + +internal sealed record ComtradePhasorVector( + string Label, + string Phase, + string Units, + double MagnitudeRms, + double AngleDegrees); + +public sealed class ComtradePhasorView : FrameworkElement +{ + private IReadOnlyList _vectors = Array.Empty(); + private string _title = "Phasor"; + private string _subtitle = "Select an analog signal"; + + internal void ShowPhasors(string title, string subtitle, IReadOnlyList vectors) + { + _title = title; + _subtitle = subtitle; + _vectors = vectors.Where(v => double.IsFinite(v.MagnitudeRms) && double.IsFinite(v.AngleDegrees)).ToArray(); + InvalidateVisual(); + } + + internal void ShowMessage(string title, string message) + { + _title = title; + _subtitle = message; + _vectors = Array.Empty(); + InvalidateVisual(); + } + + protected override void OnRender(DrawingContext dc) + { + base.OnRender(dc); + var bounds = new Rect(0, 0, Math.Max(0, ActualWidth), Math.Max(0, ActualHeight)); + dc.DrawRectangle(Brushes.White, null, bounds); + if (bounds.Width < 260 || bounds.Height < 220) return; + + var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var body = new Typeface("Segoe UI"); + var semibold = new Typeface("Segoe UI Semibold"); + DrawText(dc, _title, 15, semibold, Color.FromRgb(31, 50, 74), new Point(18, 14), dpi); + DrawText(dc, _subtitle, 10.8, body, Color.FromRgb(103, 120, 141), new Point(18, 39), dpi); + + if (_vectors.Count == 0) + { + DrawText(dc, "No valid phasor at the analysis reference.", 12, body, + Color.FromRgb(126, 139, 156), new Point(24, 82), dpi); + return; + } + + var legendWidth = Math.Clamp(bounds.Width * 0.29, 210, 300); + var plotArea = new Rect(18, 65, Math.Max(120, bounds.Width - legendWidth - 48), Math.Max(120, bounds.Height - 88)); + var radius = Math.Max(45, Math.Min(plotArea.Width, plotArea.Height) * 0.43); + var center = new Point(plotArea.Left + plotArea.Width * 0.5, plotArea.Top + plotArea.Height * 0.5); + DrawPolarGrid(dc, center, radius, dpi, body); + + var maxMagnitude = _vectors.Max(v => Math.Abs(v.MagnitudeRms)); + if (!double.IsFinite(maxMagnitude) || maxMagnitude <= 1e-12) maxMagnitude = 1.0; + foreach (var vector in _vectors) + DrawVector(dc, center, radius, maxMagnitude, vector, dpi, semibold); + + DrawLegend(dc, new Point(bounds.Right - legendWidth - 14, 72), legendWidth, dpi, body, semibold); + } + + private static void DrawPolarGrid(DrawingContext dc, Point center, double radius, double dpi, Typeface body) + { + var minorPen = FrozenPen(Color.FromRgb(229, 234, 241), 1.0); + var axisPen = FrozenPen(Color.FromRgb(183, 195, 209), 1.05); + for (var ring = 1; ring <= 4; ring++) + { + var r = radius * ring / 4.0; + dc.DrawEllipse(null, ring == 4 ? axisPen : minorPen, center, r, r); + DrawText(dc, $"{ring * 25}%", 8.8, body, Color.FromRgb(143, 154, 169), + new Point(center.X + 4, center.Y - r + 2), dpi); + } + + for (var degrees = 0; degrees < 360; degrees += 30) + { + var radians = degrees * Math.PI / 180.0; + var end = new Point(center.X + Math.Cos(radians) * radius, center.Y - Math.Sin(radians) * radius); + dc.DrawLine(degrees % 90 == 0 ? axisPen : minorPen, center, end); + } + + DrawText(dc, "0°", 9.2, body, Color.FromRgb(108, 122, 140), new Point(center.X + radius + 5, center.Y - 7), dpi); + DrawText(dc, "+90°", 9.2, body, Color.FromRgb(108, 122, 140), new Point(center.X - 14, center.Y - radius - 17), dpi); + DrawText(dc, "±180°", 9.2, body, Color.FromRgb(108, 122, 140), new Point(center.X - radius - 42, center.Y - 7), dpi); + DrawText(dc, "−90°", 9.2, body, Color.FromRgb(108, 122, 140), new Point(center.X - 15, center.Y + radius + 5), dpi); + dc.DrawEllipse(new SolidColorBrush(Color.FromRgb(83, 101, 122)), null, center, 2.5, 2.5); + } + + private static void DrawVector(DrawingContext dc, Point center, double radius, double maxMagnitude, + ComtradePhasorVector vector, double dpi, Typeface typeface) + { + var color = PhaseColor(vector.Phase); + var fraction = Math.Clamp(Math.Abs(vector.MagnitudeRms) / maxMagnitude, 0.0, 1.0); + var length = radius * fraction; + var radians = vector.AngleDegrees * Math.PI / 180.0; + var end = new Point(center.X + Math.Cos(radians) * length, center.Y - Math.Sin(radians) * length); + var pen = FrozenPen(color, 2.0); + dc.DrawLine(pen, center, end); + + if (length > 8) + { + var head = 8.0; + var leftAngle = radians + Math.PI * 0.88; + var rightAngle = radians - Math.PI * 0.88; + dc.DrawLine(pen, end, new Point(end.X + Math.Cos(leftAngle) * head, end.Y - Math.Sin(leftAngle) * head)); + dc.DrawLine(pen, end, new Point(end.X + Math.Cos(rightAngle) * head, end.Y - Math.Sin(rightAngle) * head)); + } + + var labelPoint = new Point(end.X + (Math.Cos(radians) >= 0 ? 6 : -30), end.Y - 15); + DrawText(dc, vector.Label, 9.2, typeface, color, labelPoint, dpi); + } + + private void DrawLegend(DrawingContext dc, Point origin, double width, double dpi, Typeface body, Typeface semibold) + { + DrawText(dc, "RMS phasors", 11.5, semibold, Color.FromRgb(49, 70, 94), origin, dpi); + var y = origin.Y + 25; + foreach (var vector in _vectors.Take(10)) + { + var color = PhaseColor(vector.Phase); + dc.DrawEllipse(new SolidColorBrush(color), null, new Point(origin.X + 4, y + 6), 3.5, 3.5); + DrawText(dc, vector.Label, 10.5, semibold, Color.FromRgb(48, 65, 86), new Point(origin.X + 15, y - 1), dpi); + var unit = string.IsNullOrWhiteSpace(vector.Units) ? string.Empty : " " + vector.Units; + DrawText(dc, $"{vector.MagnitudeRms:G6}{unit} ∠ {vector.AngleDegrees:+0.##;-0.##;0}°", + 9.8, body, Color.FromRgb(103, 119, 139), new Point(origin.X + 15, y + 14), dpi); + y += 43; + } + } + + internal static Color PhaseColor(string? phase) + { + var normalized = (phase ?? string.Empty).Trim().ToUpperInvariant(); + return normalized switch + { + "L1" or "A" => Color.FromRgb(211, 47, 47), + "L2" or "B" => Color.FromRgb(214, 167, 0), + "L3" or "C" => Color.FromRgb(25, 118, 210), + "E" or "N" => Color.FromRgb(46, 139, 87), + _ => Color.FromRgb(111, 119, 128) + }; + } + + private static Pen FrozenPen(Color color, double thickness) + { + var brush = new SolidColorBrush(color); brush.Freeze(); + var pen = new Pen(brush, thickness); pen.Freeze(); + return pen; + } + + private static void DrawText(DrawingContext dc, string text, double size, Typeface typeface, Color color, Point point, double dpi) + { + var brush = new SolidColorBrush(color); brush.Freeze(); + dc.DrawText(new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + typeface, size, brush, dpi), point); + } +} From ab0509f14f1ca385f57a42d1fb3632e89dbb9d28 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 18:04:17 +0700 Subject: [PATCH 2/9] P1C: add native WPF COMTRADE harmonic visualization --- Controls/ComtradeHarmonicsView.cs | 202 ++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 Controls/ComtradeHarmonicsView.cs diff --git a/Controls/ComtradeHarmonicsView.cs b/Controls/ComtradeHarmonicsView.cs new file mode 100644 index 000000000..a29dee04b --- /dev/null +++ b/Controls/ComtradeHarmonicsView.cs @@ -0,0 +1,202 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; + +namespace ArIED61850Tester.Controls; + +internal sealed record ComtradeHarmonicDisplayBin( + int Order, + double MagnitudeRms, + double PercentOfFundamental, + double AngleDegrees); + +internal sealed record ComtradeHarmonicDisplaySpectrum( + string SignalName, + string Units, + double FundamentalRms, + double ThdPercent, + int DominantOrder, + double DominantRms, + double DominantPercent, + double EstimatedSampleRateHz, + int MaximumResolvableOrder, + IReadOnlyList Bins); + +public sealed class ComtradeHarmonicsView : FrameworkElement +{ + private ComtradeHarmonicDisplaySpectrum? _spectrum; + private string _title = "Harmonics"; + private string _subtitle = "Select an analog signal"; + private int _selectedOrder = 1; + private Rect _barsRect; + + public ComtradeHarmonicsView() + { + Cursor = Cursors.Arrow; + ToolTip = "Click a harmonic bar to inspect magnitude, percentage and phase angle."; + } + + internal void ShowSpectrum(string title, string subtitle, ComtradeHarmonicDisplaySpectrum spectrum) + { + _title = title; + _subtitle = subtitle; + _spectrum = spectrum; + _selectedOrder = spectrum.Bins.Count > 0 ? Math.Max(1, spectrum.Bins[0].Order) : 1; + InvalidateVisual(); + } + + internal void ShowMessage(string title, string message) + { + _title = title; + _subtitle = message; + _spectrum = null; + InvalidateVisual(); + } + + protected override void OnRender(DrawingContext dc) + { + base.OnRender(dc); + var bounds = new Rect(0, 0, Math.Max(0, ActualWidth), Math.Max(0, ActualHeight)); + dc.DrawRectangle(Brushes.White, null, bounds); + if (bounds.Width < 320 || bounds.Height < 240) return; + + var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + var body = new Typeface("Segoe UI"); + var semibold = new Typeface("Segoe UI Semibold"); + DrawText(dc, _title, 15, semibold, Color.FromRgb(31, 50, 74), new Point(18, 14), dpi); + DrawText(dc, _subtitle, 10.8, body, Color.FromRgb(103, 120, 141), new Point(18, 39), dpi); + + if (_spectrum is null || _spectrum.Bins.Count == 0) + { + DrawText(dc, "No valid harmonic spectrum at the analysis reference.", 12, body, + Color.FromRgb(126, 139, 156), new Point(24, 82), dpi); + return; + } + + DrawSummary(dc, bounds, dpi, body, semibold); + var chartTop = 126.0; + var detailHeight = 58.0; + _barsRect = new Rect(54, chartTop, Math.Max(100, bounds.Width - 78), Math.Max(80, bounds.Height - chartTop - detailHeight - 26)); + DrawGrid(dc, _barsRect, dpi, body); + DrawBars(dc, _barsRect, dpi, body, semibold); + DrawSelectedDetail(dc, new Rect(18, bounds.Bottom - detailHeight, bounds.Width - 36, detailHeight - 8), dpi, body, semibold); + } + + protected override void OnMouseDown(MouseButtonEventArgs e) + { + base.OnMouseDown(e); + if (_spectrum is null || _spectrum.Bins.Count == 0 || !_barsRect.Contains(e.GetPosition(this))) return; + var point = e.GetPosition(this); + var fraction = Math.Clamp((point.X - _barsRect.Left) / _barsRect.Width, 0.0, 0.999999); + var index = Math.Clamp((int)(fraction * _spectrum.Bins.Count), 0, _spectrum.Bins.Count - 1); + _selectedOrder = _spectrum.Bins[index].Order; + InvalidateVisual(); + e.Handled = true; + } + + private void DrawSummary(DrawingContext dc, Rect bounds, double dpi, Typeface body, Typeface semibold) + { + if (_spectrum is null) return; + var units = string.IsNullOrWhiteSpace(_spectrum.Units) ? string.Empty : " " + _spectrum.Units; + var items = new[] + { + ("Fundamental", $"{_spectrum.FundamentalRms:G6}{units}"), + ("THD", $"{_spectrum.ThdPercent:G4}%"), + ("Dominant", _spectrum.DominantOrder > 1 ? $"H{_spectrum.DominantOrder} • {_spectrum.DominantPercent:G4}%" : "—"), + ("Sample rate", _spectrum.EstimatedSampleRateHz > 0 ? $"{_spectrum.EstimatedSampleRateHz:G6} Hz" : "—") + }; + var available = Math.Max(400, bounds.Width - 36); + var cellWidth = available / items.Length; + for (var i = 0; i < items.Length; i++) + { + var x = 18 + i * cellWidth; + DrawText(dc, items[i].Item1, 9.5, body, Color.FromRgb(132, 145, 162), new Point(x, 69), dpi); + DrawText(dc, items[i].Item2, 11.3, semibold, Color.FromRgb(48, 67, 90), new Point(x, 87), dpi); + } + DrawText(dc, $"Nyquist limit: H{_spectrum.MaximumResolvableOrder}", 9.2, body, + Color.FromRgb(139, 150, 164), new Point(18, 108), dpi); + } + + private static void DrawGrid(DrawingContext dc, Rect plot, double dpi, Typeface body) + { + var gridPen = FrozenPen(Color.FromRgb(231, 236, 242), 1); + var borderPen = FrozenPen(Color.FromRgb(190, 200, 213), 1); + for (var i = 0; i <= 4; i++) + { + var y = plot.Bottom - plot.Height * i / 4.0; + dc.DrawLine(gridPen, new Point(plot.Left, y), new Point(plot.Right, y)); + var value = i * 25; + var text = new FormattedText($"{value}%", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, + body, 8.8, new SolidColorBrush(Color.FromRgb(132, 144, 158)), dpi) + { TextAlignment = TextAlignment.Right }; + dc.DrawText(text, new Point(plot.Left - 8, y - 6)); + } + dc.DrawRectangle(null, borderPen, plot); + } + + private void DrawBars(DrawingContext dc, Rect plot, double dpi, Typeface body, Typeface semibold) + { + if (_spectrum is null) return; + var bins = _spectrum.Bins; + var maxPercent = Math.Max(100.0, bins.Max(b => double.IsFinite(b.PercentOfFundamental) ? b.PercentOfFundamental : 0.0)); + var slot = plot.Width / Math.Max(1, bins.Count); + var barWidth = Math.Clamp(slot * 0.62, 3.0, 24.0); + + for (var i = 0; i < bins.Count; i++) + { + var bin = bins[i]; + var percent = double.IsFinite(bin.PercentOfFundamental) ? Math.Max(0, bin.PercentOfFundamental) : 0; + var height = Math.Clamp(percent / maxPercent, 0, 1) * plot.Height; + var x = plot.Left + slot * (i + 0.5) - barWidth * 0.5; + var selected = bin.Order == _selectedOrder; + var color = bin.Order == 1 + ? Color.FromRgb(42, 120, 223) + : selected ? Color.FromRgb(217, 121, 41) : Color.FromRgb(106, 155, 205); + var brush = new SolidColorBrush(color); brush.Freeze(); + dc.DrawRoundedRectangle(brush, null, new Rect(x, plot.Bottom - height, barWidth, height), 2, 2); + + if (selected) + { + var pen = FrozenPen(Color.FromRgb(194, 97, 24), 1.2); + dc.DrawRectangle(null, pen, new Rect(x - 2, plot.Top + 2, barWidth + 4, plot.Height - 4)); + } + + if (bins.Count <= 20 || bin.Order == 1 || bin.Order % 2 == 1) + { + var label = new FormattedText($"H{bin.Order}", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, + selected ? semibold : body, 8.7, new SolidColorBrush(Color.FromRgb(103, 116, 132)), dpi) + { TextAlignment = TextAlignment.Center }; + dc.DrawText(label, new Point(x + barWidth * 0.5, plot.Bottom + 5)); + } + } + } + + private void DrawSelectedDetail(DrawingContext dc, Rect rect, double dpi, Typeface body, Typeface semibold) + { + if (_spectrum is null) return; + var selected = _spectrum.Bins.FirstOrDefault(b => b.Order == _selectedOrder) ?? _spectrum.Bins[0]; + var unit = string.IsNullOrWhiteSpace(_spectrum.Units) ? string.Empty : " " + _spectrum.Units; + var bg = new SolidColorBrush(Color.FromRgb(248, 250, 253)); bg.Freeze(); + var border = FrozenPen(Color.FromRgb(222, 230, 239), 1); + dc.DrawRoundedRectangle(bg, border, rect, 6, 6); + DrawText(dc, $"H{selected.Order}", 11.5, semibold, Color.FromRgb(46, 65, 88), new Point(rect.Left + 12, rect.Top + 8), dpi); + DrawText(dc, $"{selected.MagnitudeRms:G6}{unit} RMS", 10, body, Color.FromRgb(87, 105, 126), new Point(rect.Left + 58, rect.Top + 8), dpi); + DrawText(dc, $"{selected.PercentOfFundamental:G5}% of fundamental", 10, body, Color.FromRgb(87, 105, 126), new Point(rect.Left + 205, rect.Top + 8), dpi); + DrawText(dc, $"∠ {selected.AngleDegrees:+0.##;-0.##;0}°", 10, body, Color.FromRgb(87, 105, 126), new Point(rect.Left + 390, rect.Top + 8), dpi); + } + + private static Pen FrozenPen(Color color, double thickness) + { + var brush = new SolidColorBrush(color); brush.Freeze(); + var pen = new Pen(brush, thickness); pen.Freeze(); + return pen; + } + + private static void DrawText(DrawingContext dc, string text, double size, Typeface typeface, Color color, Point point, double dpi) + { + var brush = new SolidColorBrush(color); brush.Freeze(); + dc.DrawText(new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, + typeface, size, brush, dpi), point); + } +} From 2b196dfbd8afa888a656ebc7130f2e2c53c357d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 18:04:57 +0700 Subject: [PATCH 3/9] P1C: pin ArdIrec native analysis bridge --- engines/ARDIREC.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARDIREC.lock.json b/engines/ARDIREC.lock.json index 22178ad1c..07540730e 100644 --- a/engines/ARDIREC.lock.json +++ b/engines/ARDIREC.lock.json @@ -2,7 +2,7 @@ "schema": 2, "repository": "masarray/ardirec", "ref": "main", - "commit": "d7f8702adce3d3f35ad9d426c65eaf50f741c55c", + "commit": "cfcd65adf853b6707ee54a89dd30328db8827f0b", "bridge": { "abi": 1, "relativeLibrary": "Tools/ArdIrec/ardirec_bridge.dll" @@ -15,5 +15,5 @@ "relativeExecutable": "Tools/ArdIrec/ardirec.exe", "launchArgument": "--arsas-open" }, - "purpose": "Pinned ArdIrec P1 engine for ARSAS COMTRADE integration. The native bridge is the preferred in-process path; the Qt runtime remains a compatibility fallback until WPF analysis parity is complete. Change this commit only through a deliberate engine upgrade." + "purpose": "Pinned ArdIrec P1C engine for ARSAS COMTRADE integration. The native bridge is the preferred in-process path and exposes waveform, digital, phasor and harmonic analysis; the Qt runtime remains a compatibility fallback while WPF analysis parity is completed. Change this commit only through a deliberate engine upgrade." } From fe4fa258fe876fddae6a1b32da87ef93dbbafbf2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 18:06:35 +0700 Subject: [PATCH 4/9] P1C: consume native ArdIrec phasor and harmonic analysis --- Services/ArdIrecNativeBridge.cs | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/Services/ArdIrecNativeBridge.cs b/Services/ArdIrecNativeBridge.cs index a8e311dff..bf024bae5 100644 --- a/Services/ArdIrecNativeBridge.cs +++ b/Services/ArdIrecNativeBridge.cs @@ -39,6 +39,35 @@ internal sealed record ComtradeStatusChannelInfo( string Circuit, int NormalState); +internal sealed record ComtradePhasor( + bool Valid, + double MagnitudeRms, + double AngleDegrees, + double Real, + double Imag, + ulong WindowStartFrame, + ulong WindowEndExclusive); + +internal sealed record ComtradeHarmonicBin( + int Order, + double MagnitudeRms, + double PercentOfFundamental, + double AngleDegrees); + +internal sealed record ComtradeHarmonicSpectrum( + bool Valid, + double DcComponent, + double FundamentalRms, + double ThdPercent, + int DominantOrder, + double DominantRms, + double DominantPercent, + double EstimatedSampleRateHz, + int MaximumResolvableOrder, + ulong WindowStartFrame, + ulong WindowEndExclusive, + IReadOnlyList Bins); + internal static class ArdIrecNativeBridge { internal const uint ExpectedAbiVersion = 1; @@ -211,6 +240,8 @@ internal NativeApi(string libraryPath) CopyAnalog = Export("ardirec_record_copy_analog"); CopyStatus = Export("ardirec_record_copy_status"); CopyRawTimestamps = Export("ardirec_record_copy_raw_timestamps"); + GetPhasor = Export("ardirec_record_get_phasor"); + GetHarmonicSpectrum = Export("ardirec_record_get_harmonic_spectrum"); } internal string LibraryPath { get; } @@ -223,6 +254,8 @@ internal NativeApi(string libraryPath) internal RecordCopyAnalogDelegate CopyAnalog { get; } internal RecordCopyStatusDelegate CopyStatus { get; } internal RecordCopyRawTimestampsDelegate CopyRawTimestamps { get; } + internal RecordGetPhasorDelegate GetPhasor { get; } + internal RecordGetHarmonicSpectrumDelegate GetHarmonicSpectrum { get; } private T Export(string name) where T : Delegate { @@ -247,6 +280,8 @@ public void Dispose() [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordCopyAnalogDelegate(IntPtr handle, uint channel, ulong start, ulong count, [Out] double[] destination); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordCopyStatusDelegate(IntPtr handle, uint channel, ulong start, ulong count, [Out] byte[] destination); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordCopyRawTimestampsDelegate(IntPtr handle, ulong start, ulong count, [Out] uint[] destination); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordGetPhasorDelegate(IntPtr handle, uint channel, ulong referenceFrame, ref NativePhasorInfo info); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordGetHarmonicSpectrumDelegate(IntPtr handle, uint channel, ulong referenceFrame, int maximumOrder, ref NativeHarmonicSpectrumInfo info, IntPtr bins, uint binCapacity); [StructLayout(LayoutKind.Sequential)] internal struct NativeRecordInfo @@ -318,6 +353,44 @@ internal struct NativeStatusChannelInfo Circuit = new byte[128] }; } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativePhasorInfo + { + internal int Valid; + internal double MagnitudeRms; + internal double AngleDegrees; + internal double Real; + internal double Imag; + internal ulong WindowStartFrame; + internal ulong WindowEndExclusive; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativeHarmonicBin + { + internal int Order; + internal double MagnitudeRms; + internal double PercentOfFundamental; + internal double AngleDegrees; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativeHarmonicSpectrumInfo + { + internal int Valid; + internal double DcComponent; + internal double FundamentalRms; + internal double ThdPercent; + internal int DominantOrder; + internal double DominantRms; + internal double DominantPercent; + internal double EstimatedSampleRateHz; + internal int MaximumResolvableOrder; + internal uint BinCount; + internal ulong WindowStartFrame; + internal ulong WindowEndExclusive; + } } internal sealed class ArdIrecNativeRecord : IDisposable @@ -386,6 +459,71 @@ internal uint[] ReadRawTimestamps(ulong startFrame, int frameCount) return values; } + internal ComtradePhasor ReadPhasor(uint channelIndex, ulong referenceFrame) + { + EnsureOpen(); + var native = new ArdIrecNativeBridge.NativePhasorInfo(); + EnsureSuccess(_api.GetPhasor(_handle, channelIndex, referenceFrame, ref native), "calculate phasor"); + return new ComtradePhasor( + native.Valid != 0, + native.MagnitudeRms, + native.AngleDegrees, + native.Real, + native.Imag, + native.WindowStartFrame, + native.WindowEndExclusive); + } + + internal ComtradeHarmonicSpectrum ReadHarmonicSpectrum(uint channelIndex, ulong referenceFrame, int maximumOrder = 15) + { + EnsureOpen(); + if (maximumOrder < 1) throw new ArgumentOutOfRangeException(nameof(maximumOrder)); + + var native = new ArdIrecNativeBridge.NativeHarmonicSpectrumInfo(); + EnsureSuccess(_api.GetHarmonicSpectrum(_handle, channelIndex, referenceFrame, maximumOrder, ref native, IntPtr.Zero, 0), + "query harmonic spectrum"); + if (native.Valid == 0 || native.BinCount == 0) + return MapSpectrum(native, Array.Empty()); + + var nativeSize = Marshal.SizeOf(); + var bytes = checked(nativeSize * checked((int)native.BinCount)); + var buffer = Marshal.AllocHGlobal(bytes); + try + { + EnsureSuccess(_api.GetHarmonicSpectrum(_handle, channelIndex, referenceFrame, maximumOrder, ref native, buffer, native.BinCount), + "read harmonic spectrum"); + var bins = new List(checked((int)native.BinCount)); + for (var i = 0; i < native.BinCount; i++) + { + var pointer = IntPtr.Add(buffer, checked((int)i) * nativeSize); + var bin = Marshal.PtrToStructure(pointer); + bins.Add(new ComtradeHarmonicBin(bin.Order, bin.MagnitudeRms, bin.PercentOfFundamental, bin.AngleDegrees)); + } + return MapSpectrum(native, bins); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static ComtradeHarmonicSpectrum MapSpectrum( + ArdIrecNativeBridge.NativeHarmonicSpectrumInfo native, + IReadOnlyList bins) + => new( + native.Valid != 0, + native.DcComponent, + native.FundamentalRms, + native.ThdPercent, + native.DominantOrder, + native.DominantRms, + native.DominantPercent, + native.EstimatedSampleRateHz, + native.MaximumResolvableOrder, + native.WindowStartFrame, + native.WindowEndExclusive, + bins); + private ComtradeAnalogChannelInfo ReadAnalogChannelInfo(int index) { var native = ArdIrecNativeBridge.NativeAnalogChannelInfo.Create(); From 0ff3c34fb9982231e7279417b6b901ef093e5629 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 9 Sep 2026 18:07:17 +0700 Subject: [PATCH 5/9] P1C: add Waveform Phasor Harmonics workspace modes --- ComtradeWorkspaceWindow.xaml | 79 ++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/ComtradeWorkspaceWindow.xaml b/ComtradeWorkspaceWindow.xaml index 64591a169..52bbc7a3c 100644 --- a/ComtradeWorkspaceWindow.xaml +++ b/ComtradeWorkspaceWindow.xaml @@ -112,40 +112,52 @@ + - + + + + + + + + + +