diff --git a/src/Gemstone.PhasorProtocols.sln.DotSettings b/src/Gemstone.PhasorProtocols.sln.DotSettings
index 3f3b82e9b2..a8df163179 100644
--- a/src/Gemstone.PhasorProtocols.sln.DotSettings
+++ b/src/Gemstone.PhasorProtocols.sln.DotSettings
@@ -6,4 +6,7 @@
True
True
True
+ True
+ True
+ True
True
\ No newline at end of file
diff --git a/src/Gemstone.PhasorProtocols/SelCWS/Common.cs b/src/Gemstone.PhasorProtocols/SelCWS/Common.cs
index 5f833ff0fc..033c0d2f7d 100644
--- a/src/Gemstone.PhasorProtocols/SelCWS/Common.cs
+++ b/src/Gemstone.PhasorProtocols/SelCWS/Common.cs
@@ -23,6 +23,7 @@
// ReSharper disable InconsistentNaming
using System;
+using System.ComponentModel;
using Gemstone.Numeric.EE;
namespace Gemstone.PhasorProtocols.SelCWS;
@@ -67,15 +68,33 @@ public enum PhaseChannel
///
IA = 3,
///
- /// Phase B current (IB).
+ /// Phase B voltage (VB).
///
IB = 4,
///
- /// Phase C current (IC).
+ /// Phase C voltage (VC).
///
IC = 5,
}
+///
+/// Phase estimation algorithm used to derive synchrophasor, frequency and ROCOF components from
+/// SEL CWS point-on-wave data.
+///
+public enum PhaseEstimationAlgorithm
+{
+ ///
+ /// Rolling sliding DFT estimator with optional EMA smoothing (see ).
+ ///
+ [Description("Rolling sliding DFT estimator with optional EMA smoothing")]
+ SlidingDft,
+ ///
+ /// IEEE C37.118-2018 Annex D filter-based estimator (see ).
+ ///
+ [Description("IEEE C37.118-2018 Annex D filter-based estimator")]
+ IEEEC37_118
+}
+
///
/// IEEE C37.118-2018 Annex D filter class for phasor estimation.
///
diff --git a/src/Gemstone.PhasorProtocols/SelCWS/ConfigurationCell.cs b/src/Gemstone.PhasorProtocols/SelCWS/ConfigurationCell.cs
index 805e8badd4..ee2d5f06c2 100644
--- a/src/Gemstone.PhasorProtocols/SelCWS/ConfigurationCell.cs
+++ b/src/Gemstone.PhasorProtocols/SelCWS/ConfigurationCell.cs
@@ -59,10 +59,10 @@ internal ConfigurationCell(ConfigurationFrame parent, LineFrequency nominalFrequ
});
}
+ PhasorDefinitionCollection phasorDefinitions = PhasorDefinitions;
+
for (int i = 0; i < Common.MaximumPhasorValues; i++)
- {
- PhasorDefinitions.Add(new PhasorDefinition(this, analogNames[i], i < 3 ? PhasorType.Voltage : PhasorType.Current));
- }
+ phasorDefinitions.Add(new PhasorDefinition(this, analogNames[i], i < 3 ? PhasorType.Voltage : PhasorType.Current));
}
///
diff --git a/src/Gemstone.PhasorProtocols/SelCWS/ConnectionParameters.cs b/src/Gemstone.PhasorProtocols/SelCWS/ConnectionParameters.cs
index 628d5229c9..33ed4b6908 100644
--- a/src/Gemstone.PhasorProtocols/SelCWS/ConnectionParameters.cs
+++ b/src/Gemstone.PhasorProtocols/SelCWS/ConnectionParameters.cs
@@ -1,4 +1,4 @@
-//******************************************************************************************************
+//******************************************************************************************************
// ConnectionParameters.cs - Gbtc
//
// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
@@ -29,7 +29,8 @@
using System.ComponentModel;
using System.Runtime.Serialization;
using Gemstone.Numeric.EE;
-using static Gemstone.PhasorProtocols.SelCWS.RollingPhaseEstimator;
+using static Gemstone.PhasorProtocols.SelCWS.SlidingDftPhaseEstimator;
+using static Gemstone.PhasorProtocols.SelCWS.IEEEC37_118PhaseEstimator;
namespace Gemstone.PhasorProtocols.SelCWS;
@@ -58,6 +59,11 @@ public class ConnectionParameters : ConnectionParametersBase
///
public const bool DefaultRepeatLastCalculatedValueWhenDownSampling = true;
+ ///
+ /// Default value for .
+ ///
+ public const PhaseEstimationAlgorithm DefaultAlgorithm = PhaseEstimationAlgorithm.SlidingDft;
+
#endregion
#region [ Constructors ]
@@ -71,6 +77,19 @@ public ConnectionParameters()
NominalFrequency = Common.DefaultNominalFrequency;
CalculationFrameRate = Common.DefaultFramePerSecond;
RepeatLastCalculatedValueWhenDownSampling = DefaultRepeatLastCalculatedValueWhenDownSampling;
+ Algorithm = DefaultAlgorithm;
+ ReferenceChannel = DefaultReferenceChannel;
+ TargetCycles = DefaultTargetCycles;
+ EnableIntervalAveraging = DefaultEnableIntervalAveraging;
+ EnablePublishEMA = DefaultEnablePublishEMA;
+ PublishAnglesTauSeconds = DefaultPublishAnglesTauSeconds;
+ PublishMagnitudesTauSeconds = DefaultPublishMagnitudesTauSeconds;
+ PublishFrequencyTauSeconds = DefaultPublishFrequencyTauSeconds;
+ PublishRocofTauSeconds = DefaultPublishRocofTauSeconds;
+ SampleFrequencyTauSeconds = DefaultSampleFrequencyTauSeconds;
+ SampleRocofTauSeconds = DefaultSampleRocofTauSeconds;
+ RecalculationCycles = DefaultRecalculationCycles;
+ MaxGapFillSamples = DefaultMaxGapFillSamples;
FilterClass = DefaultFilterClass;
}
@@ -86,6 +105,19 @@ protected ConnectionParameters(SerializationInfo info, StreamingContext context)
NominalFrequency = info.GetOrDefault("nominalFrequency", Common.DefaultNominalFrequency);
CalculationFrameRate = info.GetOrDefault("calculationFrameRate", Common.DefaultFramePerSecond);
RepeatLastCalculatedValueWhenDownSampling = info.GetOrDefault("repeatLastCalculatedValueWhenDownSampling", DefaultRepeatLastCalculatedValueWhenDownSampling);
+ Algorithm = info.GetOrDefault("algorithm", DefaultAlgorithm);
+ ReferenceChannel = info.GetOrDefault("referenceChannel", DefaultReferenceChannel);
+ TargetCycles = info.GetOrDefault("targetCycles", DefaultTargetCycles);
+ EnableIntervalAveraging = info.GetOrDefault("enableIntervalAveraging", DefaultEnableIntervalAveraging);
+ EnablePublishEMA = info.GetOrDefault("enablePublishEMA", DefaultEnablePublishEMA);
+ PublishAnglesTauSeconds = info.GetOrDefault("publishAnglesTauSeconds", DefaultPublishAnglesTauSeconds);
+ PublishMagnitudesTauSeconds = info.GetOrDefault("publishMagnitudesTauSeconds", DefaultPublishMagnitudesTauSeconds);
+ PublishFrequencyTauSeconds = info.GetOrDefault("publishFrequencyTauSeconds", DefaultPublishFrequencyTauSeconds);
+ PublishRocofTauSeconds = info.GetOrDefault("publishRocofTauSeconds", DefaultPublishRocofTauSeconds);
+ SampleFrequencyTauSeconds = info.GetOrDefault("sampleFrequencyTauSeconds", DefaultSampleFrequencyTauSeconds);
+ SampleRocofTauSeconds = info.GetOrDefault("sampleRocofTauSeconds", DefaultSampleRocofTauSeconds);
+ RecalculationCycles = info.GetOrDefault("recalculationCycles", DefaultRecalculationCycles);
+ MaxGapFillSamples = info.GetOrDefault("maxGapFillSamples", DefaultMaxGapFillSamples);
FilterClass = info.GetOrDefault("filterClass", DefaultFilterClass);
}
@@ -97,7 +129,7 @@ protected ConnectionParameters(SerializationInfo info, StreamingContext context)
/// Gets or sets flag that determines if current and voltage phase estimates, frequency and dF/dt should be
/// calculated for PoW data.
///
- [Category("Phase Estimation Parameters")]
+ [Category("General Phase Estimation Settings")]
[Description("Determines if current and voltage phase estimates, frequency and dF/dt should be calculated for PoW data.")]
[DefaultValue(DefaultCalculatePhaseEstimates)]
public bool CalculatePhaseEstimates { get; set; }
@@ -105,7 +137,7 @@ protected ConnectionParameters(SerializationInfo info, StreamingContext context)
///
/// Gets or sets the nominal of this SEL CWS device.
///
- [Category("Phase Estimation Parameters")]
+ [Category("General Phase Estimation Settings")]
[Description("Configured nominal frequency for SEL CWS device.")]
[DefaultValue(typeof(LineFrequency), "Hz60")]
public LineFrequency NominalFrequency { get; set; }
@@ -113,7 +145,7 @@ protected ConnectionParameters(SerializationInfo info, StreamingContext context)
///
/// Gets or sets the configured frame rate for phase estimate calculations.
///
- [Category("Phase Estimation Parameters")]
+ [Category("General Phase Estimation Settings")]
[Description("Configured frame rate for phase estimate calculations.")]
[DefaultValue(Common.DefaultFramePerSecond)]
public ushort CalculationFrameRate { get; set; }
@@ -123,7 +155,7 @@ protected ConnectionParameters(SerializationInfo info, StreamingContext context)
/// when is less than SEL CWS frame rate (commonly 3000Hz);
/// otherwise will be used.
///
- [Category("Phase Estimation Parameters")]
+ [Category("General Phase Estimation Settings")]
[Description(
"Gets or sets flag that determines if last value should be repeated when down-sampling, i.e.," +
"when 'CalculationFrameRate' is less than SEL CWS frame rate (commonly 3000Hz);" +
@@ -132,11 +164,158 @@ protected ConnectionParameters(SerializationInfo info, StreamingContext context)
[DefaultValue(DefaultRepeatLastCalculatedValueWhenDownSampling)]
public bool RepeatLastCalculatedValueWhenDownSampling { get; set; }
+ ///
+ /// Gets or sets the used to derive synchrophasor, frequency
+ /// and ROCOF components from SEL CWS point-on-wave data.
+ ///
+ ///
+ /// uses a rolling sliding DFT estimator with optional
+ /// EMA smoothing; uses the IEEE C37.118-2018 Annex D
+ /// filter-based estimator. Algorithm-specific options appear in the matching property category.
+ ///
+ [Category("General Phase Estimation Settings")]
+ [Description("Phase estimation algorithm: SlidingDft (rolling sliding DFT with optional EMA smoothing) or IEEEC37_118 (IEEE C37.118-2018 Annex D filter-based). Algorithm-specific options appear in the matching category.")]
+ [DefaultValue(DefaultAlgorithm)]
+ public PhaseEstimationAlgorithm Algorithm { get; set; }
+
+ ///
+ /// Gets or sets the reference channel for frequency tracking.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("Reference channel for frequency tracking.")]
+ [DefaultValue(typeof(PhaseChannel), "VA")]
+ public PhaseChannel ReferenceChannel { get; set; }
+
+ ///
+ /// Gets or sets the number of nominal cycles contained in the sliding DFT analysis window.
+ ///
+ ///
+ /// Larger values generally reduce noise/jitter (more averaging) but increase latency and reduce step response.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("Number of nominal cycles contained in the sliding DFT analysis window. Larger values generally reduce noise/jitter (more averaging) but increase latency and reduce step response.")]
+ [DefaultValue(DefaultTargetCycles)]
+ public int TargetCycles { get; set; }
+
+ ///
+ /// Gets or sets a flag that determines if interval averaging (boxcar averaging) is enabled across each publish interval when down-sampling.
+ ///
+ ///
+ /// Down-sampling without an anti-alias / low-pass step will preserve high-rate jitter and can alias higher-frequency
+ /// content into the published stream. Interval averaging acts as a simple, cheap low-pass filter that reduces
+ /// jitter and improves published stability.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("Enables interval averaging (boxcar averaging) across each publish interval when down-sampling. Down-sampling without an anti-alias / low-pass step will preserve high-rate jitter and can alias higher-frequency content into the published stream. Interval averaging acts as a simple, cheap low-pass filter that reduces jitter and improves published stability.")]
+ [DefaultValue(DefaultEnableIntervalAveraging)]
+ public bool EnableIntervalAveraging { get; set; }
+
+ ///
+ /// Gets or sets a flag that determines if an additional exponential moving average (EMA) is applied to the published stream (after interval averaging).
+ ///
+ ///
+ /// Interval averaging removes high-rate noise; publish-EMA further reduces remaining jitter and produces a "calm"
+ /// display or control signal. This is usually the most intuitive "knob" for operators/consumers because it acts on
+ /// the actual output cadence.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("Enables an additional exponential moving average (EMA) applied to the published stream (after interval averaging). Interval averaging removes high-rate noise; publish-EMA further reduces remaining jitter and produces a calm display or control signal.")]
+ [DefaultValue(DefaultEnablePublishEMA)]
+ public bool EnablePublishEMA { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for published phase angles.
+ ///
+ ///
+ /// Angles are circular quantities; this implementation performs wrap-safe smoothing by operating on unit vectors
+ /// (cos/sin) rather than naïvely averaging radians. This avoids discontinuities at ±π.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("EMA time constant τ (seconds) for published phase angles. Angles are circular quantities; this implementation performs wrap-safe smoothing by operating on unit vectors (cos/sin) rather than naïvely averaging radians.")]
+ [DefaultValue(DefaultPublishAnglesTauSeconds)]
+ public double PublishAnglesTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for published RMS magnitudes.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("EMA time constant τ (seconds) for published RMS magnitudes.")]
+ [DefaultValue(DefaultPublishMagnitudesTauSeconds)]
+ public double PublishMagnitudesTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for published frequency.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("EMA time constant τ (seconds) for published frequency.")]
+ [DefaultValue(DefaultPublishFrequencyTauSeconds)]
+ public double PublishFrequencyTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for published ROCOF (dF/dt).
+ ///
+ ///
+ /// ROCOF is effectively a derivative signal and is typically much noisier than frequency; it generally benefits from
+ /// heavier smoothing (larger τ) than frequency.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("EMA time constant τ (seconds) for published ROCOF (dF/dt). ROCOF is effectively a derivative signal and is typically much noisier than frequency; it generally benefits from heavier smoothing (larger τ) than frequency.")]
+ [DefaultValue(DefaultPublishRocofTauSeconds)]
+ public double PublishRocofTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for the internal per-sample frequency smoothing that occurs inside the estimator before any down-sampling/publish filtering.
+ ///
+ ///
+ /// When interval averaging + publish EMA are enabled, this can be relatively light. If you disable publish smoothing,
+ /// you may want to increase this τ.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("EMA time constant τ (seconds) for the internal per-sample frequency smoothing that occurs inside the estimator before any down-sampling/publish filtering. When interval averaging + publish EMA are enabled, this can be relatively light.")]
+ [DefaultValue(DefaultSampleFrequencyTauSeconds)]
+ public double SampleFrequencyTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for the internal per-sample ROCOF smoothing (computed from the internally smoothed frequency).
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("EMA time constant τ (seconds) for the internal per-sample ROCOF smoothing (computed from the internally smoothed frequency).")]
+ [DefaultValue(DefaultSampleRocofTauSeconds)]
+ public double SampleRocofTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the number of nominal cycles between full DFT recalculations for numerical stability.
+ ///
+ ///
+ /// Sliding DFT updates are O(1) per sample but can accumulate numerical drift; periodic full recomputation
+ /// re-anchors the phasor sums.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("Number of nominal cycles between full DFT recalculations for numerical stability. Sliding DFT updates are O(1) per sample but can accumulate numerical drift; periodic full recomputation re-anchors the phasor sums.")]
+ [DefaultValue(DefaultRecalculationCycles)]
+ public int RecalculationCycles { get; set; }
+
+ ///
+ /// Gets or sets the maximum gap (in input samples) filled by phase-continued synthesis before resynchronizing.
+ ///
+ ///
+ /// When dropped samples are inferred from the input timestamp cadence, gaps up to this size are coasted
+ /// (the last phasors are continued at the tracked frequency); larger gaps drop and refill the analysis window.
+ /// A negative value means "auto" (one full analysis window); 0 resynchronizes on any gap.
+ ///
+ [Category("Sliding DFT Estimation Algorithm Settings")]
+ [Description("Maximum gap (in input samples) filled by phase-continued synthesis before resynchronizing. Negative means auto (one full analysis window); 0 resynchronizes on any gap.")]
+ [DefaultValue(DefaultMaxGapFillSamples)]
+ public int MaxGapFillSamples { get; set; }
+
///
/// Gets or sets the IEEE C37.118 filter class: P (Protection, fast response) or M (Measurement, better out-of-band rejection).
///
- [Category("Phase Estimation Parameters")]
- [Description("IEEE C37.118 filter class: P (Protection, fast response) or M (Measurement, better out-of-band rejection).")]
+ ///
+ /// Only applies when is .
+ ///
+ [Category("IEEE C37.118-2018 Annex D Estimation Algorithm Settings")]
+ [Description("IEEE C37.118 filter class: P (Protection, fast response) or M (Measurement, better out-of-band rejection). Applies only when the IEEE C37.118 algorithm is selected.")]
[DefaultValue(DefaultFilterClass)]
public FilterClass FilterClass { get; set; }
@@ -156,6 +335,19 @@ public override void GetObjectData(SerializationInfo info, StreamingContext cont
info.AddValue("nominalFrequency", NominalFrequency, typeof(LineFrequency));
info.AddValue("calculationFrameRate", CalculationFrameRate);
info.AddValue("repeatLastCalculatedValueWhenDownSampling", RepeatLastCalculatedValueWhenDownSampling);
+ info.AddValue("algorithm", Algorithm, typeof(PhaseEstimationAlgorithm));
+ info.AddValue("referenceChannel", ReferenceChannel, typeof(PhaseChannel));
+ info.AddValue("targetCycles", TargetCycles);
+ info.AddValue("enableIntervalAveraging", EnableIntervalAveraging);
+ info.AddValue("enablePublishEMA", EnablePublishEMA);
+ info.AddValue("publishAnglesTauSeconds", PublishAnglesTauSeconds);
+ info.AddValue("publishMagnitudesTauSeconds", PublishMagnitudesTauSeconds);
+ info.AddValue("publishFrequencyTauSeconds", PublishFrequencyTauSeconds);
+ info.AddValue("publishRocofTauSeconds", PublishRocofTauSeconds);
+ info.AddValue("sampleFrequencyTauSeconds", SampleFrequencyTauSeconds);
+ info.AddValue("sampleRocofTauSeconds", SampleRocofTauSeconds);
+ info.AddValue("recalculationCycles", RecalculationCycles);
+ info.AddValue("maxGapFillSamples", MaxGapFillSamples);
info.AddValue("filterClass", FilterClass, typeof(FilterClass));
}
diff --git a/src/Gemstone.PhasorProtocols/SelCWS/FrameParser.cs b/src/Gemstone.PhasorProtocols/SelCWS/FrameParser.cs
index 3c542da85e..456fb87c98 100644
--- a/src/Gemstone.PhasorProtocols/SelCWS/FrameParser.cs
+++ b/src/Gemstone.PhasorProtocols/SelCWS/FrameParser.cs
@@ -31,7 +31,8 @@
using Gemstone.Numeric.EE;
using static Gemstone.PhasorProtocols.SelCWS.Common;
using static Gemstone.PhasorProtocols.SelCWS.ConnectionParameters;
-using static Gemstone.PhasorProtocols.SelCWS.RollingPhaseEstimator;
+using static Gemstone.PhasorProtocols.SelCWS.SlidingDftPhaseEstimator;
+using static Gemstone.PhasorProtocols.SelCWS.IEEEC37_118PhaseEstimator;
namespace Gemstone.PhasorProtocols.SelCWS;
@@ -69,7 +70,7 @@ public class FrameParser : FrameParserBase
// Fields
private ConfigurationFrame? m_configurationFrame;
private DataFrame? m_initialDataFrame;
- private RollingPhaseEstimator? m_phaseEstimator;
+ private IPhaseEstimator? m_phaseEstimator;
private long[]? m_nanosecondPacketFrameOffsets;
private DataCell? m_lastCell;
@@ -89,6 +90,19 @@ public FrameParser(CheckSumValidationFrameTypes checkSumValidationFrameTypes = C
NominalFrequency = DefaultNominalFrequency;
CalculationFrameRate = DefaultFramePerSecond;
RepeatLastCalculatedValueWhenDownSampling = DefaultRepeatLastCalculatedValueWhenDownSampling;
+ Algorithm = DefaultAlgorithm;
+ ReferenceChannel = DefaultReferenceChannel;
+ TargetCycles = DefaultTargetCycles;
+ EnableIntervalAveraging = DefaultEnableIntervalAveraging;
+ EnablePublishEMA = DefaultEnablePublishEMA;
+ PublishAnglesTauSeconds = DefaultPublishAnglesTauSeconds;
+ PublishMagnitudesTauSeconds = DefaultPublishMagnitudesTauSeconds;
+ PublishFrequencyTauSeconds = DefaultPublishFrequencyTauSeconds;
+ PublishRocofTauSeconds = DefaultPublishRocofTauSeconds;
+ SampleFrequencyTauSeconds = DefaultSampleFrequencyTauSeconds;
+ SampleRocofTauSeconds = DefaultSampleRocofTauSeconds;
+ RecalculationCycles = DefaultRecalculationCycles;
+ MaxGapFillSamples = DefaultMaxGapFillSamples;
FilterClass = DefaultFilterClass;
}
@@ -147,9 +161,115 @@ public override IConfigurationFrame ConfigurationFrame
///
public bool RepeatLastCalculatedValueWhenDownSampling { get; set; }
+ ///
+ /// Gets or sets the used to derive synchrophasor, frequency
+ /// and ROCOF components from SEL CWS point-on-wave data.
+ ///
+ public PhaseEstimationAlgorithm Algorithm { get; set; }
+
+ ///
+ /// Gets or sets the reference channel for frequency tracking.
+ ///
+ ///
+ /// Only applies when is .
+ ///
+ public PhaseChannel ReferenceChannel { get; set; }
+
+ ///
+ /// Gets or sets the number of nominal cycles contained in the sliding DFT analysis window.
+ ///
+ ///
+ /// Larger values generally reduce noise/jitter (more averaging) but increase latency and reduce step response.
+ ///
+ public int TargetCycles { get; set; }
+
+ ///
+ /// Gets or sets a flag that determines if interval averaging (boxcar averaging) is enabled across each publish interval when down-sampling.
+ ///
+ ///
+ /// Down-sampling without an anti-alias / low-pass step will preserve high-rate jitter and can alias higher-frequency
+ /// content into the published stream. Interval averaging acts as a simple, cheap low-pass filter that reduces
+ /// jitter and improves published stability.
+ ///
+ public bool EnableIntervalAveraging { get; set; }
+
+ ///
+ /// Gets or sets a flag that determines if an additional exponential moving average (EMA) is applied to the published stream (after interval averaging).
+ ///
+ ///
+ /// Interval averaging removes high-rate noise; publish-EMA further reduces remaining jitter and produces a "calm"
+ /// display or control signal. This is usually the most intuitive "knob" for operators/consumers because it acts on
+ /// the actual output cadence.
+ ///
+ public bool EnablePublishEMA { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for published phase angles.
+ ///
+ ///
+ /// Angles are circular quantities; this implementation performs wrap-safe smoothing by operating on unit vectors
+ /// (cos/sin) rather than naïvely averaging radians. This avoids discontinuities at ±π.
+ ///
+ public double PublishAnglesTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for published RMS magnitudes.
+ ///
+ public double PublishMagnitudesTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for published frequency.
+ ///
+ public double PublishFrequencyTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for published ROCOF (dF/dt).
+ ///
+ ///
+ /// ROCOF is effectively a derivative signal and is typically much noisier than frequency; it generally benefits from
+ /// heavier smoothing (larger τ) than frequency.
+ ///
+ public double PublishRocofTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for the internal per-sample frequency smoothing that occurs inside the estimator before any down-sampling/publish filtering.
+ ///
+ ///
+ /// When interval averaging + publish EMA are enabled, this can be relatively light. If you disable publish smoothing,
+ /// you may want to increase this τ.
+ ///
+ public double SampleFrequencyTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the EMA time constant τ (seconds) for the internal per-sample ROCOF smoothing (computed from the internally smoothed frequency).
+ ///
+ public double SampleRocofTauSeconds { get; set; }
+
+ ///
+ /// Gets or sets the number of nominal cycles between full DFT recalculations for numerical stability.
+ ///
+ ///
+ /// Sliding DFT updates are O(1) per sample but can accumulate numerical drift; periodic full recomputation
+ /// re-anchors the phasor sums.
+ ///
+ public int RecalculationCycles { get; set; }
+
+ ///
+ /// Gets or sets the maximum gap (in input samples) filled by phase-continued synthesis before resynchronizing.
+ ///
+ ///
+ /// Dropped samples (e.g., lost UDP packets) are inferred from the input timestamp cadence. Gaps up to this size
+ /// are coasted by continuing the last phasors at the tracked frequency; larger gaps drop and refill the analysis
+ /// window. A negative value means "auto" (one full analysis window); 0 resynchronizes on any gap.
+ ///
+ public int MaxGapFillSamples { get; set; }
+
///
/// Gets or sets the IEEE C37.118 filter class: P (Protection, fast response) or M (Measurement, better out-of-band rejection).
///
+ ///
+ /// Only applies when is .
+ ///
public FilterClass FilterClass { get; set; }
#endregion
@@ -282,9 +402,12 @@ protected override int ParseFrame(byte[] buffer, int offset, int length)
// Ensure nanosecond frame distribution is initialized
m_nanosecondPacketFrameOffsets ??= CalculateNanosecondPacketFrameOffsets(FramesPerPacket);
- // Move offset past initial data frame which includes 64-bit nanosecond timestamp
- offset += 32;
- length -= 32;
+ // Move offset past the entire initial data frame to reach the second sample. The initial
+ // frame spans the 16-byte common header + 8-byte nanosecond timestamp + first 24-byte sample
+ // (6 analogs x 4 bytes) = 48 bytes. (Previously skipped only 32, omitting the common header,
+ // which misaligned samples 1-49 of every packet by 16 bytes / 4 analog channels.)
+ offset += 48;
+ length -= 48;
// In the case of data frames in CWS, the source buffer has 49 more frames to parse after the first
for (int i = 1; i < FramesPerPacket; i++)
@@ -334,15 +457,11 @@ private void ApplyEstimatedPhases(DataFrame dataFrame)
double ib = cell.AnalogValues[(int)PhaseChannel.IB].Value;
double ic = cell.AnalogValues[(int)PhaseChannel.IC].Value;
- // Ensure phase estimator is created
- m_phaseEstimator ??= new RollingPhaseEstimator(
- DefaultFramePerSecond,
- CalculationFrameRate,
- NominalFrequency,
- FilterClass);
+ // Ensure phase estimator is created for the selected algorithm
+ m_phaseEstimator ??= CreatePhaseEstimator();
// Calculate next phase estimation
- bool calculated = m_phaseEstimator.Step(ia, ib, ic, va, vb, vc, timestamp, processPhaseEstimate);
+ bool calculated = m_phaseEstimator.Step(va, vb, vc, ia, ib, ic, timestamp, processPhaseEstimate);
if (!RepeatLastCalculatedValueWhenDownSampling)
return;
@@ -383,6 +502,36 @@ void processPhaseEstimate(in PhaseEstimate estimate)
}
}
+ // Creates the phase estimator for the currently selected algorithm. Both estimators consume
+ // sample-groups in VA, VB, VC, IA, IB, IC order and publish a common PhaseEstimate.
+ private IPhaseEstimator CreatePhaseEstimator()
+ {
+ return Algorithm switch
+ {
+ PhaseEstimationAlgorithm.IEEEC37_118 => new IEEEC37_118PhaseEstimator(
+ DefaultFramePerSecond,
+ CalculationFrameRate,
+ NominalFrequency,
+ FilterClass),
+ _ => new SlidingDftPhaseEstimator(
+ DefaultFramePerSecond,
+ CalculationFrameRate,
+ NominalFrequency,
+ ReferenceChannel,
+ TargetCycles,
+ EnableIntervalAveraging,
+ EnablePublishEMA,
+ PublishAnglesTauSeconds,
+ PublishMagnitudesTauSeconds,
+ PublishFrequencyTauSeconds,
+ PublishRocofTauSeconds,
+ SampleFrequencyTauSeconds,
+ SampleRocofTauSeconds,
+ RecalculationCycles,
+ MaxGapFillSamples)
+ };
+ }
+
///
protected override void OnParsingException(Exception ex)
{
@@ -462,7 +611,23 @@ public override IConnectionParameters ConnectionParameters
NominalFrequency = parameters.NominalFrequency;
CalculationFrameRate = parameters.CalculationFrameRate;
RepeatLastCalculatedValueWhenDownSampling = parameters.RepeatLastCalculatedValueWhenDownSampling;
+ Algorithm = parameters.Algorithm;
+ ReferenceChannel = parameters.ReferenceChannel;
+ TargetCycles = parameters.TargetCycles;
+ EnableIntervalAveraging = parameters.EnableIntervalAveraging;
+ EnablePublishEMA = parameters.EnablePublishEMA;
+ PublishAnglesTauSeconds = parameters.PublishAnglesTauSeconds;
+ PublishMagnitudesTauSeconds = parameters.PublishMagnitudesTauSeconds;
+ PublishFrequencyTauSeconds = parameters.PublishFrequencyTauSeconds;
+ PublishRocofTauSeconds = parameters.PublishRocofTauSeconds;
+ SampleFrequencyTauSeconds = parameters.SampleFrequencyTauSeconds;
+ SampleRocofTauSeconds = parameters.SampleRocofTauSeconds;
+ RecalculationCycles = parameters.RecalculationCycles;
+ MaxGapFillSamples = parameters.MaxGapFillSamples;
FilterClass = parameters.FilterClass;
+
+ // Force the estimator to be rebuilt so any change of algorithm or its options takes effect
+ m_phaseEstimator = null;
}
}
diff --git a/src/Gemstone.PhasorProtocols/SelCWS/RollingPhaseEstimator.cs b/src/Gemstone.PhasorProtocols/SelCWS/IEEEC37_118PhaseEstimator.cs
similarity index 90%
rename from src/Gemstone.PhasorProtocols/SelCWS/RollingPhaseEstimator.cs
rename to src/Gemstone.PhasorProtocols/SelCWS/IEEEC37_118PhaseEstimator.cs
index 39beaf3ed8..92d1a7d0f5 100644
--- a/src/Gemstone.PhasorProtocols/SelCWS/RollingPhaseEstimator.cs
+++ b/src/Gemstone.PhasorProtocols/SelCWS/IEEEC37_118PhaseEstimator.cs
@@ -1,5 +1,5 @@
//******************************************************************************************************
-// RollingPhaseEstimator.cs - Gbtc
+// IEEEC37_118PhaseEstimator.cs - Gbtc
//
// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
//
@@ -16,13 +16,13 @@
//
// Code Modification History:
// ----------------------------------------------------------------------------------------------------
-// 11/04/2025 - Ritchie Carroll
-// Generated original version of source code in collaboration with ChatGPT.
-//
// 03/19/2026 - Ritchie Carroll
-// Replaced sliding DFT algorithm with IEEE C37.118-2018 Annex D filter-based phasor estimation.
-// Added P-class and M-class filter support, positive-sequence computation, and IEEE-standard
-// frequency/ROCOF estimation.
+// Generated original version of source code in collaboration with ChatGPT implementing the
+// IEEE C37.118-2018 Annex D filter-based phasor estimation algorithm.
+// 05/23/2026 - Ritchie Carroll
+// Standardized on the VA, VB, VC, IA, IB, IC channel order used by the sliding DFT estimator
+// (which also corrects the voltage positive-sequence channel indexing for frequency/ROCOF
+// estimation) as part of the selectable SEL CWS phase-estimation algorithm work.
//
//******************************************************************************************************
// ReSharper disable IdentifierTypo
@@ -38,52 +38,6 @@
namespace Gemstone.PhasorProtocols.SelCWS;
-///
-/// Represents the output of the phase estimation algorithm.
-///
-public readonly ref struct PhaseEstimate
-{
- ///
- /// Gets frequency estimate in hertz.
- ///
- public required double Frequency { get; init; }
-
- ///
- /// Gets rate of change of frequency (ROCOF) in Hz/s.
- ///
- public required double dFdt { get; init; }
-
- ///
- /// Gets angles in radians, length 6: IA, IB, IC, VA, VB, VC.
- ///
- ///
- ///
- /// The data this span references is owned by the instance,
- /// it is only valid during the scope of the delegate call.
- /// If you need to retain the data, make a copy.
- ///
- ///
- public required ReadOnlySpan Angles { get; init; }
-
- ///
- /// Gets RMS magnitudes, length 6: IA, IB, IC, VA, VB, VC.
- ///
- ///
- ///
- /// The data this span references is owned by the instance,
- /// it is only valid during the scope of the delegate call.
- /// If you need to retain the data, make a copy.
- ///
- ///
- public required ReadOnlySpan Magnitudes { get; init; }
-}
-
-///
-/// Delegate for handling a .
-///
-/// Phase estimate.
-public delegate void PhaseEstimateHandler(in PhaseEstimate estimate);
-
///
/// Represents a rolling six-phase estimator using the IEEE C37.118-2018 Annex D filter-based
/// phasor estimation algorithm with P-class and M-class support.
@@ -108,17 +62,17 @@ public readonly ref struct PhaseEstimate
/// to compensate for spectral leakage when the signal frequency deviates from nominal.
///
///
-/// Outputs: 6 phasors (IA, IB, IC, VA, VB, VC) plus frequency and ROCOF estimates.
+/// Outputs: 6 phasors (VA, VB, VC, IA, IB, IC) plus frequency and ROCOF estimates.
/// Internally computes voltage positive-sequence for IEEE Annex D.4 frequency/ROCOF estimation.
///
///
-public sealed class RollingPhaseEstimator
+public sealed class IEEEC37_118PhaseEstimator : IPhaseEstimator
{
#region [ Members ]
// Constants
- // Number of input channels (IA, IB, IC, VA, VB, VC).
+ // Number of input channels (VA, VB, VC, IA, IB, IC).
private const int NumInputChannels = 6;
private const double TwoPI = 2.0 * Math.PI;
@@ -172,7 +126,7 @@ public sealed class RollingPhaseEstimator
#region [ Constructors ]
///
- /// Initializes a new instance of the class implementing
+ /// Initializes a new instance of the class implementing
/// the IEEE C37.118-2018 Annex D phasor estimation algorithm.
///
///
@@ -198,16 +152,21 @@ public sealed class RollingPhaseEstimator
/// Thrown if the and combination
/// is not supported for M-class filters per IEEE C37.118-2018 Table D.1.
///
- public RollingPhaseEstimator(
+ public IEEEC37_118PhaseEstimator(
double sampleRateHz,
double outputRateHz,
LineFrequency nominalFrequency,
FilterClass filterClass = DefaultFilterClass)
{
// Validate rates
- ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRateHz);
- ArgumentOutOfRangeException.ThrowIfNegativeOrZero(outputRateHz);
- ArgumentOutOfRangeException.ThrowIfGreaterThan(outputRateHz, sampleRateHz);
+ if (sampleRateHz <= 0)
+ throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Input sample rate must be positive.");
+
+ if (outputRateHz <= 0)
+ throw new ArgumentOutOfRangeException(nameof(outputRateHz), "Output rate must be positive.");
+
+ if (outputRateHz > sampleRateHz)
+ throw new ArgumentOutOfRangeException(nameof(outputRateHz), "Output rate must be <= input sample rate.");
double f0 = (double)nominalFrequency;
double fs = sampleRateHz;
@@ -306,14 +265,14 @@ public RollingPhaseEstimator(
#region [ Methods ]
///
- /// Push one interleaved sample-group (IA, IB, IC, VA, VB, VC) with its epoch nanoseconds.
+ /// Push one interleaved sample-group (VA, VB, VC, IA, IB, IC) with its epoch nanoseconds.
///
- /// Current sample for phase A current.
- /// Current sample for phase B current.
- /// Current sample for phase C current.
/// Current sample for phase A voltage.
/// Current sample for phase B voltage.
/// Current sample for phase C voltage.
+ /// Current sample for phase A current.
+ /// Current sample for phase B current.
+ /// Current sample for phase C current.
/// Timestamp in nanoseconds since epoch.
/// Handler for phase estimate result.
///
@@ -323,22 +282,22 @@ public RollingPhaseEstimator(
///
/// The data referenced in the span-based properties of the
/// parameter provided to the delegate is owned by the
- /// instance, it is only valid during the scope of the
+ /// instance, it is only valid during the scope of the
/// delegate call. If you need to retain the data, make a copy.
///
public bool Step(
- double ia,
- double ib,
- double ic,
double va,
double vb,
double vc,
+ double ia,
+ double ib,
+ double ic,
long epochNanoseconds,
PhaseEstimateHandler phaseEstimateHandler)
{
ArgumentNullException.ThrowIfNull(phaseEstimateHandler);
- Span samples = [ia, ib, ic, va, vb, vc]; // Implicit stackalloc
+ Span samples = [ va, vb, vc, ia, ib, ic ];
// Push new samples into circular buffers
for (int ch = 0; ch < NumInputChannels; ch++)
@@ -416,8 +375,8 @@ public void Reset()
m_lastUnwrappedPhase = 0.0D;
// Reset output buffers
- Array.Clear(m_publishAngles);
- Array.Clear(m_publishMagnitudes);
+ Array.Clear(m_publishAngles, 0, NumInputChannels);
+ Array.Clear(m_publishMagnitudes, 0, NumInputChannels);
}
///
@@ -512,15 +471,15 @@ private void ComputeVoltagePositiveSequence()
// Reconstruct derotated complex phasors from published magnitudes and angles
// (which have the nominal frequency rotation already removed in ExtractMagnitudesAndAngles).
// This matches the Python __estimate output: XM*cos(XA) + j*XM*sin(XA)
- double vaAngle = (double)m_publishAngles[VA];
+ double vaAngle = m_publishAngles[VA];
double vaReal = m_publishMagnitudes[VA] * Math.Cos(vaAngle);
double vaImag = m_publishMagnitudes[VA] * Math.Sin(vaAngle);
- double vbAngle = (double)m_publishAngles[VB];
+ double vbAngle = m_publishAngles[VB];
double vbReal = m_publishMagnitudes[VB] * Math.Cos(vbAngle);
double vbImag = m_publishMagnitudes[VB] * Math.Sin(vbAngle);
- double vcAngle = (double)m_publishAngles[VC];
+ double vcAngle = m_publishAngles[VC];
double vcReal = m_publishMagnitudes[VC] * Math.Cos(vcAngle);
double vcImag = m_publishMagnitudes[VC] * Math.Sin(vcAngle);
diff --git a/src/Gemstone.PhasorProtocols/SelCWS/IPhaseEstimator.cs b/src/Gemstone.PhasorProtocols/SelCWS/IPhaseEstimator.cs
new file mode 100644
index 0000000000..1e79ec9b1a
--- /dev/null
+++ b/src/Gemstone.PhasorProtocols/SelCWS/IPhaseEstimator.cs
@@ -0,0 +1,67 @@
+//******************************************************************************************************
+// IPhaseEstimator.cs - Gbtc
+//
+// Copyright © 2026, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 05/23/2026 - Ritchie Carroll
+// Generated original version of source code.
+//
+//******************************************************************************************************
+// ReSharper disable IdentifierTypo
+// ReSharper disable InconsistentNaming
+
+namespace Gemstone.PhasorProtocols.SelCWS;
+
+///
+/// Defines a SEL CWS six-phase estimator that consumes point-on-wave sample groups and produces
+/// results.
+///
+///
+/// Implementations represent interchangeable phasor estimation algorithms (see
+/// ) selected via the SEL CWS connection parameters.
+///
+public interface IPhaseEstimator
+{
+ ///
+ /// Pushes one interleaved sample-group (VA, VB, VC, IA, IB, IC) with its epoch nanoseconds.
+ ///
+ /// Current sample for phase A voltage.
+ /// Current sample for phase B voltage.
+ /// Current sample for phase C voltage.
+ /// Current sample for phase A current.
+ /// Current sample for phase B current.
+ /// Current sample for phase C current.
+ /// Timestamp in nanoseconds since epoch.
+ /// Handler for phase estimate result.
+ ///
+ /// true when an estimate is available, and it is time to publish at the target output rate;
+ /// otherwise, false.
+ ///
+ bool Step(
+ double va,
+ double vb,
+ double vc,
+ double ia,
+ double ib,
+ double ic,
+ long epochNanoseconds,
+ PhaseEstimateHandler phaseEstimateHandler);
+
+ ///
+ /// Resets the estimator to its initial state.
+ ///
+ void Reset();
+}
diff --git a/src/Gemstone.PhasorProtocols/SelCWS/PhaseEstimate.cs b/src/Gemstone.PhasorProtocols/SelCWS/PhaseEstimate.cs
new file mode 100644
index 0000000000..8c40ecc07b
--- /dev/null
+++ b/src/Gemstone.PhasorProtocols/SelCWS/PhaseEstimate.cs
@@ -0,0 +1,79 @@
+//******************************************************************************************************
+// PhaseEstimate.cs - Gbtc
+//
+// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 11/04/2025 - Ritchie Carroll
+// Generated original version of source code in collaboration with ChatGPT.
+// 05/23/2026 - Ritchie Carroll
+// Extracted shared phase-estimate result type and handler so multiple phase-estimation
+// algorithms (sliding DFT and IEEE C37.118-2018 Annex D) can produce a common output.
+//
+//******************************************************************************************************
+// ReSharper disable IdentifierTypo
+// ReSharper disable CommentTypo
+// ReSharper disable InconsistentNaming
+
+using System;
+using Gemstone.Units;
+
+namespace Gemstone.PhasorProtocols.SelCWS;
+
+///
+/// Represents the output of a SEL CWS phase estimation algorithm.
+///
+///
+/// This is the common result type produced by every implementation,
+/// regardless of the underlying estimation algorithm.
+///
+public readonly ref struct PhaseEstimate
+{
+ ///
+ /// Gets frequency estimate in hertz.
+ ///
+ public required double Frequency { get; init; }
+
+ ///
+ /// Gets rate of change of frequency (ROCOF) in Hz/s.
+ ///
+ public required double dFdt { get; init; }
+
+ ///
+ /// Gets angles in radians, length 6: VA, VB, VC, IA, IB, IC.
+ ///
+ ///
+ /// The data this span references is owned by the phase estimator instance,
+ /// it is only valid during the scope of the delegate call.
+ /// If you need to retain the data, make a copy.
+ ///
+ public required ReadOnlySpan Angles { get; init; }
+
+ ///
+ /// Gets RMS magnitudes, length 6: VA, VB, VC, IA, IB, IC.
+ ///
+ ///
+ /// The data this span references is owned by the phase estimator instance,
+ /// it is only valid during the scope of the delegate call.
+ /// If you need to retain the data, make a copy.
+ ///
+ public required ReadOnlySpan Magnitudes { get; init; }
+}
+
+///
+/// Delegate for handling a .
+///
+/// Phase estimate.
+public delegate void PhaseEstimateHandler(in PhaseEstimate estimate);
diff --git a/src/Gemstone.PhasorProtocols/SelCWS/RollingPhaseEstimationTest.cs b/src/Gemstone.PhasorProtocols/SelCWS/RollingPhaseEstimationTest.cs
deleted file mode 100644
index 7a4b5fb951..0000000000
--- a/src/Gemstone.PhasorProtocols/SelCWS/RollingPhaseEstimationTest.cs
+++ /dev/null
@@ -1,382 +0,0 @@
-// ReSharper disable UnusedParameter.Local
-// ReSharper disable InconsistentNaming
-// ReSharper disable PossiblyImpureMethodCallOnReadonlyVariable
-
-using System;
-using Gemstone.Numeric.EE;
-
-namespace Gemstone.PhasorProtocols.SelCWS;
-
-///
-/// Test program demonstrating the RollingPhaseEstimator with IEEE C37.118-2018 Annex D algorithm.
-///
-internal class Program
-{
- private static void Main(string[] args)
- {
- System.Console.WriteLine("RollingPhaseEstimator Test Program (IEEE C37.118-2018 Annex D)");
- System.Console.WriteLine("==============================================================\n");
-
- // Test 1: Basic operation at nominal frequency (P-class)
- TestNominalFrequency();
-
- // Test 2: Frequency deviation detection
- TestFrequencyDeviation();
-
- // Test 3: Magnitude measurement
- TestMagnitudeMeasurement();
-
- // Test 4: Phase angle relationships
- TestPhaseAngles();
-
- // Test 5: P-class vs M-class comparison
- TestPClassVsMClass();
-
- // Test 6: Decimation count verification
- TestDecimationCount();
-
- System.Console.WriteLine("\nAll tests completed.");
- }
-
- private static void TestNominalFrequency()
- {
- System.Console.WriteLine("Test 1: Nominal Frequency Operation (60 Hz, P-class)");
- System.Console.WriteLine("-----------------------------------------------------");
-
- const double sampleRate = 960.0;
- const double outputRate = 60.0;
- const double nominalFreq = 60.0;
- const double amplitude = 100.0;
-
- RollingPhaseEstimator estimator = new(sampleRate, outputRate, LineFrequency.Hz60, FilterClass.P);
-
- // Generate samples for 50 cycles worth of data
- int totalSamples = (int)(sampleRate / nominalFreq * 50);
- long samplePeriodNs = (long)(1e9 / sampleRate);
- double omega = 2.0 * Math.PI * nominalFreq;
- long epochNs = 0;
-
- double lastFrequency = 0;
- double lastRocof = 0;
- double lastVAMagnitude = 0;
- bool gotEstimate = false;
- int estimateCount = 0;
-
- for (int i = 0; i < totalSamples; i++)
- {
- double t = i / sampleRate;
-
- // Generate balanced three-phase signals (120° apart)
- double ia = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t);
- double ib = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t - 2.0 * Math.PI / 3.0);
- double ic = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t + 2.0 * Math.PI / 3.0);
- double va = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t);
- double vb = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t - 2.0 * Math.PI / 3.0);
- double vc = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t + 2.0 * Math.PI / 3.0);
-
- if (estimator.Step(ia, ib, ic, va, vb, vc, epochNs, (in PhaseEstimate estimate) =>
- {
- lastFrequency = estimate.Frequency;
- lastRocof = estimate.dFdt;
- lastVAMagnitude = estimate.Magnitudes[(int)PhaseChannel.VA];
- gotEstimate = true;
- estimateCount++;
- }))
- {
- // Step returned true, meaning an estimate was produced
- }
-
- epochNs += samplePeriodNs;
- }
-
- if (gotEstimate)
- {
- double expectedMag = amplitude / Math.Sqrt(2);
- System.Console.WriteLine($" Filter order: {estimator.FilterOrder}");
- System.Console.WriteLine($" Total estimates: {estimateCount}");
- System.Console.WriteLine($" Measured Frequency: {lastFrequency:F4} Hz (expected: {nominalFreq} Hz)");
- System.Console.WriteLine($" ROCOF: {lastRocof:F4} Hz/s (expected: ~0)");
- System.Console.WriteLine($" VA Magnitude: {lastVAMagnitude:F4} (expected: ~{expectedMag:F4} RMS)");
- System.Console.WriteLine($" Frequency error: {Math.Abs(lastFrequency - nominalFreq):F6} Hz");
- }
- System.Console.WriteLine();
- }
-
- private static void TestFrequencyDeviation()
- {
- System.Console.WriteLine("Test 2: Frequency Deviation Detection (61 Hz vs 60 Hz nominal)");
- System.Console.WriteLine("--------------------------------------------------------------");
-
- const double sampleRate = 960.0;
- const double outputRate = 60.0;
- const double actualFreq = 61.0;
- const double amplitude = 100.0;
-
- RollingPhaseEstimator estimator = new(sampleRate, outputRate, LineFrequency.Hz60, FilterClass.P);
-
- int totalSamples = (int)(sampleRate / 60.0 * 30); // 30 nominal cycles for convergence
- long samplePeriodNs = (long)(1e9 / sampleRate);
- long epochNs = 0;
-
- double lastFrequency = 0;
- bool gotEstimate = false;
-
- for (int i = 0; i < totalSamples; i++)
- {
- double t = i / sampleRate;
-
- double ia = amplitude * Math.Cos(2.0 * Math.PI * actualFreq * t);
- double ib = amplitude * Math.Cos(2.0 * Math.PI * actualFreq * t - 2.0 * Math.PI / 3.0);
- double ic = amplitude * Math.Cos(2.0 * Math.PI * actualFreq * t + 2.0 * Math.PI / 3.0);
- double va = amplitude * Math.Cos(2.0 * Math.PI * actualFreq * t);
- double vb = amplitude * Math.Cos(2.0 * Math.PI * actualFreq * t - 2.0 * Math.PI / 3.0);
- double vc = amplitude * Math.Cos(2.0 * Math.PI * actualFreq * t + 2.0 * Math.PI / 3.0);
-
- estimator.Step(ia, ib, ic, va, vb, vc, epochNs, (in PhaseEstimate estimate) =>
- {
- lastFrequency = estimate.Frequency;
- gotEstimate = true;
- });
-
- epochNs += samplePeriodNs;
- }
-
- if (gotEstimate)
- {
- System.Console.WriteLine($" Measured Frequency: {lastFrequency:F4} Hz (actual: {actualFreq} Hz)");
- System.Console.WriteLine($" Frequency error: {Math.Abs(lastFrequency - actualFreq):F4} Hz");
- }
-
- System.Console.WriteLine();
- }
-
- private static void TestMagnitudeMeasurement()
- {
- System.Console.WriteLine("Test 3: Magnitude Measurement (various amplitudes)");
- System.Console.WriteLine("--------------------------------------------------");
-
- const double sampleRate = 960.0;
- const double outputRate = 60.0;
- const double nominalFreq = 60.0;
-
- RollingPhaseEstimator estimator = new(sampleRate, outputRate, LineFrequency.Hz60, FilterClass.P);
-
- // Different amplitudes for each channel
- double[] peakAmplitudes = [100.0, 150.0, 200.0, 120.0, 180.0, 90.0];
- double[] expectedRms = new double[6];
-
- for (int i = 0; i < 6; i++)
- expectedRms[i] = peakAmplitudes[i] / Math.Sqrt(2);
-
- int totalSamples = (int)(sampleRate / nominalFreq * 20); // 20 cycles
- long samplePeriodNs = (long)(1e9 / sampleRate);
-
- long epochNs = 0;
-
- double[] lastMagnitudes = new double[6];
- bool gotEstimate = false;
-
- for (int i = 0; i < totalSamples; i++)
- {
- double t = i / sampleRate;
-
- double ia = peakAmplitudes[0] * Math.Cos(2.0 * Math.PI * nominalFreq * t);
- double ib = peakAmplitudes[1] * Math.Cos(2.0 * Math.PI * nominalFreq * t - 2.0 * Math.PI / 3.0);
- double ic = peakAmplitudes[2] * Math.Cos(2.0 * Math.PI * nominalFreq * t + 2.0 * Math.PI / 3.0);
- double va = peakAmplitudes[3] * Math.Cos(2.0 * Math.PI * nominalFreq * t);
- double vb = peakAmplitudes[4] * Math.Cos(2.0 * Math.PI * nominalFreq * t - 2.0 * Math.PI / 3.0);
- double vc = peakAmplitudes[5] * Math.Cos(2.0 * Math.PI * nominalFreq * t + 2.0 * Math.PI / 3.0);
-
- estimator.Step(ia, ib, ic, va, vb, vc, epochNs, (in PhaseEstimate estimate) =>
- {
- estimate.Magnitudes.CopyTo(lastMagnitudes);
- gotEstimate = true;
- });
-
- epochNs += samplePeriodNs;
- }
-
- if (gotEstimate)
- {
- string[] names = ["IA", "IB", "IC", "VA", "VB", "VC"];
- for (int i = 0; i < 6; i++)
- {
- double error = Math.Abs(lastMagnitudes[i] - expectedRms[i]);
- double errorPct = error / expectedRms[i] * 100;
- System.Console.WriteLine($" {names[i]}: Measured={lastMagnitudes[i]:F4}, Expected={expectedRms[i]:F4}, Error={errorPct:F2}%");
- }
- }
- System.Console.WriteLine();
- }
-
- private static void TestPhaseAngles()
- {
- System.Console.WriteLine("Test 4: Phase Angle Relationships");
- System.Console.WriteLine("----------------------------------");
-
- const double sampleRate = 960.0;
- const double outputRate = 60.0;
- const double nominalFreq = 60.0;
- const double amplitude = 100.0;
-
- RollingPhaseEstimator estimator = new(sampleRate, outputRate, LineFrequency.Hz60, FilterClass.P);
-
- // Currents lead voltages by 30 degrees (capacitive load)
- const double CurrentLeadRad = 30.0 * Math.PI / 180.0;
- int totalSamples = (int)(sampleRate / nominalFreq * 20);
- long samplePeriodNs = (long)(1e9 / sampleRate);
- long epochNs = 0;
-
- double[] lastAngles = new double[6];
- bool gotEstimate = false;
-
- for (int i = 0; i < totalSamples; i++)
- {
- double t = i / sampleRate;
-
- // Currents with 30° lead
- double ia = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t + CurrentLeadRad);
- double ib = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t - 2.0 * Math.PI / 3.0 + CurrentLeadRad);
- double ic = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t + 2.0 * Math.PI / 3.0 + CurrentLeadRad);
-
- // Voltages (reference)
- double va = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t);
- double vb = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t - 2.0 * Math.PI / 3.0);
- double vc = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t + 2.0 * Math.PI / 3.0);
-
- estimator.Step(ia, ib, ic, va, vb, vc, epochNs, (in PhaseEstimate estimate) =>
- {
- for (int j = 0; j < 6; j++)
- lastAngles[j] = estimate.Angles[j].ToDegrees();
- gotEstimate = true;
- });
-
- epochNs += samplePeriodNs;
- }
-
- if (gotEstimate)
- {
- string[] names = ["IA", "IB", "IC", "VA", "VB", "VC"];
-
- System.Console.WriteLine(" Angles:");
-
- for (int i = 0; i < 6; i++)
- System.Console.WriteLine($" {names[i]}: {lastAngles[i]:F2}°");
-
- System.Console.WriteLine($"\n IA-VA angle difference: {WrapAngleDeg(lastAngles[(int)PhaseChannel.IA] - lastAngles[(int)PhaseChannel.VA]):F2}° (expected: 30°)");
- }
-
- System.Console.WriteLine();
- }
-
- private static void TestPClassVsMClass()
- {
- System.Console.WriteLine("Test 5: P-class vs M-class Comparison");
- System.Console.WriteLine("--------------------------------------");
-
- const double sampleRate = 960.0;
- const double outputRate = 60.0;
- const double nominalFreq = 60.0;
- const double amplitude = 100.0;
-
- RollingPhaseEstimator pEstimator = new(sampleRate, outputRate, LineFrequency.Hz60, FilterClass.P);
- RollingPhaseEstimator mEstimator = new(sampleRate, outputRate, LineFrequency.Hz60, FilterClass.M);
-
- System.Console.WriteLine($" P-class filter order: {pEstimator.FilterOrder}");
- System.Console.WriteLine($" M-class filter order: {mEstimator.FilterOrder}");
-
- int totalSamples = (int)(sampleRate / nominalFreq * 50);
- long samplePeriodNs = (long)(1e9 / sampleRate);
- long epochNs = 0;
-
- double pFreq = 0, mFreq = 0;
- double pMag = 0, mMag = 0;
-
- for (int i = 0; i < totalSamples; i++)
- {
- double t = i / sampleRate;
-
- double va = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t);
- double vb = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t - 2.0 * Math.PI / 3.0);
- double vc = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t + 2.0 * Math.PI / 3.0);
- double ia = va, ib = vb, ic = vc;
-
- pEstimator.Step(ia, ib, ic, va, vb, vc, epochNs, (in PhaseEstimate estimate) =>
- {
- pFreq = estimate.Frequency;
- pMag = estimate.Magnitudes[(int)PhaseChannel.VA];
- });
-
- mEstimator.Step(ia, ib, ic, va, vb, vc, epochNs, (in PhaseEstimate estimate) =>
- {
- mFreq = estimate.Frequency;
- mMag = estimate.Magnitudes[(int)PhaseChannel.VA];
- });
-
- epochNs += samplePeriodNs;
- }
-
- double expectedRms = amplitude / Math.Sqrt(2);
- System.Console.WriteLine($" P-class: Freq={pFreq:F4} Hz, VA Mag={pMag:F4} (expected RMS: {expectedRms:F4})");
- System.Console.WriteLine($" M-class: Freq={mFreq:F4} Hz, VA Mag={mMag:F4} (expected RMS: {expectedRms:F4})");
-
- System.Console.WriteLine();
- }
-
- private static void TestDecimationCount()
- {
- System.Console.WriteLine("Test 6: Decimation Count Verification");
- System.Console.WriteLine("--------------------------------------");
-
- const double sampleRate = 960.0;
- const double outputRate = 60.0;
- const double nominalFreq = 60.0;
- const double amplitude = 100.0;
- const double testDurationSeconds = 1.0; // exactly 1 second
-
- RollingPhaseEstimator estimator = new(sampleRate, outputRate, LineFrequency.Hz60, FilterClass.P);
-
- int totalSamples = (int)(sampleRate * testDurationSeconds);
- long samplePeriodNs = (long)(1e9 / sampleRate);
- long epochNs = 0;
- int estimateCount = 0;
-
- for (int i = 0; i < totalSamples; i++)
- {
- double t = i / sampleRate;
-
- double va = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t);
- double vb = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t - 2.0 * Math.PI / 3.0);
- double vc = amplitude * Math.Cos(2.0 * Math.PI * nominalFreq * t + 2.0 * Math.PI / 3.0);
-
- estimator.Step(va, vb, vc, va, vb, vc, epochNs, (in PhaseEstimate estimate) =>
- {
- estimateCount++;
- });
-
- epochNs += samplePeriodNs;
- }
-
- // Expected: total outputs = (totalSamples - WindowSamples) / decimationStep + some
- // At 960 Hz with filter order ~28, about 960-29=931 valid samples, 931/16=58 ish
- // Exact count depends on filter order
- int samplesPerReport = (int)(sampleRate / outputRate);
- System.Console.WriteLine($" Total samples: {totalSamples}");
- System.Console.WriteLine($" Samples per report: {samplesPerReport}");
- System.Console.WriteLine($" Filter startup samples: {estimator.WindowSamples}");
- System.Console.WriteLine($" Total estimates produced: {estimateCount}");
- System.Console.WriteLine($" Expected approx: {(totalSamples - estimator.WindowSamples) / samplesPerReport}");
-
- System.Console.WriteLine();
- }
-
- private static double WrapAngleDeg(double degrees)
- {
- double result = (degrees + 180.0) % 360.0;
-
- if (result < 0)
- result += 360.0;
-
- return result - 180.0;
- }
-}
diff --git a/src/Gemstone.PhasorProtocols/SelCWS/SlidingDftPhaseEstimator.cs b/src/Gemstone.PhasorProtocols/SelCWS/SlidingDftPhaseEstimator.cs
new file mode 100644
index 0000000000..07e85d9f3f
--- /dev/null
+++ b/src/Gemstone.PhasorProtocols/SelCWS/SlidingDftPhaseEstimator.cs
@@ -0,0 +1,1195 @@
+//******************************************************************************************************
+// SlidingDftPhaseEstimator.cs - Gbtc
+//
+// Copyright © 2025, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 11/04/2025 - Ritchie Carroll
+// Generated original version of source code in collaboration with ChatGPT/Claude.
+//
+//******************************************************************************************************
+// ReSharper disable IdentifierTypo
+// ReSharper disable CommentTypo
+// ReSharper disable GrammarMistakeInComment
+// ReSharper disable InconsistentNaming
+
+using System;
+using System.Runtime.CompilerServices;
+using Gemstone.Numeric.EE;
+using Gemstone.Numeric.UnitExtensions;
+using Gemstone.Units;
+
+namespace Gemstone.PhasorProtocols.SelCWS;
+
+///
+/// Represents a rolling six-phase estimator using a sliding DFT algorithm with optional smoothing.
+///
+///
+///
+/// This class implements a real-time phasor measurement algorithm suitable for power system
+/// applications. It uses a Sliding Discrete Fourier Transform (SDFT) approach to efficiently
+/// compute phasors at each sample with O(1) complexity.
+///
+///
+/// Frequency estimation is performed by tracking the rate of change of phase angle from the
+/// reference channel (VA by default). The frequency and ROCOF outputs are smoothed using
+/// exponential moving average filters.
+///
+///
+/// The algorithm is designed for nominal 50Hz or 60Hz systems sampled at 3000Hz, but can be
+/// configured for other sample rates.
+///
+///
+public sealed class SlidingDftPhaseEstimator : IPhaseEstimator
+{
+ #region [ Members ]
+
+ // Nested Types
+ private struct SamplePhaseEstimate
+ {
+ public double Frequency;
+ public double dFdt;
+ public Angle[] Angles;
+ public double[] Magnitudes;
+ }
+
+ // Constants
+ private const int NumChannels = 6;
+ private const double TwoPI = 2.0D * Math.PI;
+ private const double Sqrt2 = 1.4142135623730951D;
+ private const long NanosecondsPerSecond = 1_000_000_000L;
+
+ ///
+ /// Default value for reference channel.
+ ///
+ public const PhaseChannel DefaultReferenceChannel = PhaseChannel.VA;
+
+ ///
+ /// Default value for number of target cycles.
+ ///
+ public const int DefaultTargetCycles = 2;
+
+ ///
+ /// Default value for determining if interval averaging is enabled.
+ ///
+ public const bool DefaultEnableIntervalAveraging = true;
+
+ ///
+ /// Default value for determining if EMA publishing is enabled.
+ ///
+ public const bool DefaultEnablePublishEMA = true;
+
+ ///
+ /// Default value for EMA time constant τ (seconds) published angles.
+ ///
+ public const double DefaultPublishAnglesTauSeconds = 0.35D;
+
+ ///
+ /// Default value for EMA time constant τ (seconds) published RMS magnitudes.
+ ///
+ public const double DefaultPublishMagnitudesTauSeconds = 0.50D;
+
+ ///
+ /// Default value for EMA time constant τ (seconds) published frequency.
+ ///
+ public const double DefaultPublishFrequencyTauSeconds = 0.75D;
+
+ ///
+ /// Default value for EMA time constant τ (seconds) published ROCOF.
+ ///
+ public const double DefaultPublishRocofTauSeconds = 1.50D;
+
+ ///
+ /// Default value for EMA time constant τ (seconds) published for the internal per-sample frequency smoothing.
+ ///
+ public const double DefaultSampleFrequencyTauSeconds = 0.15D;
+
+ ///
+ /// Default value for EMA time constant τ (seconds) published for the internal per-sample ROCOF smoothing.
+ ///
+ public const double DefaultSampleRocofTauSeconds = 0.30D;
+
+ ///
+ /// Default value for number of recalculation cycles.
+ ///
+ public const int DefaultRecalculationCycles = 10;
+
+ ///
+ /// Default maximum gap (in samples) filled by phase-continued synthesis before resynchronizing.
+ /// A negative value means "auto" (one full analysis window).
+ ///
+ public const int DefaultMaxGapFillSamples = -1;
+
+ // Fields
+ private readonly double m_samplePeriodSeconds;
+ private readonly int m_recalculationInterval;
+ private readonly int m_initialSamplesUntilRecalc; // countdown to the first recalc after the window fills
+ private int m_samplesUntilRecalc; // samples remaining until the next full DFT recalc
+
+ // Output decimation / publish gating
+ private readonly long m_publishPeriodNs; // 0 means "publish every sample"
+ private long m_nextPublishEpochNs; // first publish scheduled when window becomes ready
+ private bool m_publishScheduleInitialized; // guards first-time publish-schedule initialization
+
+ // If enabled, we boxcar-average (anti-alias) across each publish interval.
+ private readonly bool m_enableIntervalAveraging;
+ private double m_lastFrequency;
+ private double m_lastRocof;
+ private bool m_hasLastSampleEstimate;
+
+ // Optional EMA applied to the *published* stream (after interval averaging).
+ private readonly bool m_enablePublishEMA;
+ private readonly double m_publishAnglesAlpha;
+ private readonly double m_publishMagnitudesAlpha;
+ private readonly double m_publishFrequencyAlpha;
+ private readonly double m_publishRocofAlpha;
+
+ // Precomputed (1 - α) complements for the published-stream EMA
+ private readonly double m_oneMinusPublishAnglesAlpha;
+ private readonly double m_oneMinusPublishMagnitudesAlpha;
+ private readonly double m_oneMinusPublishFrequencyAlpha;
+ private readonly double m_oneMinusPublishRocofAlpha;
+
+ // Circular sample buffers for each channel
+ private readonly double[][] m_sampleBuffers;
+ private int m_bufferWriteIndex;
+
+ // Reusable per-sample working buffers (avoids allocating Angle[] / double[] every sample)
+ private readonly Angle[] m_workingAngles = new Angle[NumChannels];
+ private readonly double[] m_workingMagnitudes = new double[NumChannels];
+
+ // Dedicated storage for "sample-and-hold" mode (only used when interval averaging is disabled)
+ private readonly Angle[] m_lastAngles = new Angle[NumChannels];
+ private readonly double[] m_lastMagnitudes = new double[NumChannels];
+
+ // Publish output buffers (single set for all modes suffices)
+ private readonly Angle[] m_publishAngles = new Angle[NumChannels];
+ private readonly double[] m_publishMagnitudes = new double[NumChannels];
+
+ // DFT twiddle factor for sliding update at bin k: e^(j*2π*k/N), where k = targetCycles
+ private readonly double m_twiddleReal; // cos(...)
+ private readonly double m_twiddleImag; // sin(...)
+
+ // Accumulated DFT phasors (real and imaginary parts) for each channel
+ private readonly double[] m_phasorReal;
+ private readonly double[] m_phasorImag;
+
+ // Precomputed cosine/sine tables for full DFT recalculation
+ private readonly double[] m_cosTable;
+ private readonly double[] m_sinTable;
+
+ // Precomputed RMS magnitude scale factor: sqrt(2) / N
+ private readonly double m_magnitudeScale;
+
+ // Precomputed frequency-tracking constants (derived from nominal frequency)
+ private readonly double m_minFrequency; // NominalFrequencyHz * 0.9 (clamp lower bound)
+ private readonly double m_maxFrequency; // NominalFrequencyHz * 1.1 (clamp upper bound)
+
+ // Previous phase angle for frequency calculation (from reference channel)
+ private double m_prevPhaseAngle;
+ private bool m_hasPrevPhase;
+
+ // Frequency smoothing (exponential moving average) on per-sample instantaneous frequency
+ private readonly double m_frequencyAlpha;
+ private readonly double m_oneMinusFrequencyAlpha; // precomputed 1 - α
+ private double m_smoothedFrequency;
+ private bool m_frequencyInitialized;
+
+ // ROCOF smoothing on per-sample instantaneous ROCOF
+ private readonly double m_rocofAlpha;
+ private readonly double m_oneMinusRocofAlpha; // precomputed 1 - α
+ private double m_prevSmoothedFrequency;
+ private double m_smoothedRocof;
+ private bool m_rocofInitialized;
+
+ // Timing
+ private long m_prevEpochNs;
+
+ // Gap detection / fill (missing-data handling)
+ private readonly double m_samplePeriodNs; // expected nanoseconds between consecutive input samples
+ private readonly int m_maxGapFillSamples; // max gap (samples) coasted before a resynchronization
+ private long m_prevInputEpochNs; // epoch of the previous Step call (tracked every sample)
+
+ // Interval accumulators (for down-sampling / anti-aliasing)
+ private int m_intervalCount;
+ private readonly double[] m_intervalAngleCosSum = new double[NumChannels];
+ private readonly double[] m_intervalAngleSinSum = new double[NumChannels];
+ private readonly double[] m_intervalMagnitudeSum = new double[NumChannels];
+ private double m_intervalFreqSum;
+ private double m_intervalRocofSum;
+
+ // Published EMA state (wrap-safe for angles)
+ private bool m_publishEMAInitialized;
+ private readonly double[] m_pubAngleCos = new double[NumChannels];
+ private readonly double[] m_pubAngleSin = new double[NumChannels];
+ private readonly double[] m_pubMagnitude = new double[NumChannels];
+ private double m_pubFrequency;
+ private double m_pubRocof;
+
+ #endregion
+
+ #region [ Constructors ]
+
+ ///
+ /// Initializes a new instance of the class with independent
+ /// input sample rate and output publish rate, plus smoothing expressed as time constants (τ).
+ ///
+ ///
+ /// Input sample rate in Hz (PoW samples per second). Must be > 0.
+ /// This is the rate at which is called with new samples.
+ ///
+ ///
+ /// Output publish rate in Hz (estimates per second). Must be > 0 and <= .
+ /// Common synchrophasor-friendly values: 30, 60 or 120Hz. Defaults chosen for "calm" 60Hz publish rates;
+ /// τ-to-α mapping adapts automatically to other publish rates.
+ ///
+ /// Behavioral note:
+ /// may return false even after the estimator is ready, if the current sample does not
+ /// land on a publish boundary (i.e., it is not yet time to produce the next output).
+ ///
+ ///
+ /// Nominal line frequency (typically 50Hz or 60Hz). This determines the nominal-cycle length used
+ /// for the DFT window and the expected reference-phase progression.
+ ///
+ ///
+ /// Reference channel for frequency tracking.
+ /// Default: .
+ ///
+ ///
+ /// Number of nominal cycles contained in the sliding DFT analysis window (default: 2).
+ /// Larger values generally reduce noise/jitter (more averaging) but increase latency and reduce step response.
+ ///
+ ///
+ /// Enables interval averaging (boxcar averaging) across each publish interval when down-sampling
+ /// ( < ).
+ ///
+ /// Why this exists:
+ /// Down-sampling without an anti-alias / low-pass step will preserve high-rate jitter and can alias higher-frequency
+ /// content into the published stream. Interval averaging acts as a simple, cheap low-pass filter that reduces
+ /// jitter and improves published stability.
+ ///
+ /// Consequences:
+ /// - Improves calmness and reduces aliasing artifacts.
+ /// - Adds a small amount of additional latency (effectively ~½ publish interval group delay).
+ /// Recommended: true (default).
+ ///
+ ///
+ /// Enables an additional exponential moving average (EMA) applied to the published stream (after interval averaging).
+ ///
+ /// Why this exists:
+ /// Interval averaging removes high-rate noise; publish-EMA further reduces remaining jitter and produces a "calm"
+ /// display or control signal. This is usually the most intuitive "knob" for operators/consumers because it acts on
+ /// the actual output cadence.
+ ///
+ /// Consequences:
+ /// - Larger time constants (τ) produce smoother outputs but slower response to real changes.
+ /// - Smaller τ tracks faster but appears noisier.
+ /// Default: true.
+ ///
+ ///
+ /// EMA time constant τ (seconds) for published phase angles. Default: 0.35s.
+ ///
+ /// Important:
+ /// Angles are circular quantities; this implementation performs wrap-safe smoothing by operating on unit vectors
+ /// (cos/sin) rather than naïvely averaging radians. This avoids discontinuities at ±π.
+ ///
+ ///
+ /// EMA time constant τ (seconds) for published RMS magnitudes. Default: 0.50s.
+ ///
+ ///
+ /// EMA time constant τ (seconds) for published frequency. Default: 0.75s.
+ ///
+ ///
+ /// EMA time constant τ (seconds) for published ROCOF (dF/dt). Default: 1.50s.
+ ///
+ /// Note:
+ /// ROCOF is effectively a derivative signal and is typically much noisier than frequency; it generally benefits from
+ /// heavier smoothing (larger τ) than frequency.
+ ///
+ ///
+ /// EMA time constant τ (seconds) for the internal per-sample frequency smoothing that occurs inside the estimator
+ /// before any down-sampling/publish filtering. Default: 0.15s.
+ ///
+ /// Guidance:
+ /// When interval averaging + publish EMA are enabled, this can be relatively light. If you disable publish smoothing,
+ /// you may want to increase this τ.
+ ///
+ ///
+ /// EMA time constant τ (seconds) for the internal per-sample ROCOF smoothing (computed from the internally smoothed
+ /// frequency). Default: 0.30s.
+ ///
+ ///
+ /// Number of nominal cycles between full DFT recalculations for numerical stability (default: 10).
+ /// Sliding DFT updates are O(1) per sample but can accumulate numerical drift; periodic full recomputation
+ /// re-anchors the phasor sums.
+ ///
+ ///
+ /// Maximum gap (in input samples) that will be filled by phase-continued synthesis (coasting the last
+ /// phasors at the tracked frequency) when the input timestamp cadence indicates dropped samples. Gaps
+ /// larger than this trigger a resynchronization (the analysis window is dropped and refilled).
+ /// A negative value (default) means "auto" = one full analysis window; 0 disables coasting
+ /// (any gap resynchronizes). Must not exceed twice .
+ ///
+ ///
+ /// Thrown if any rate is non-positive, output rate exceeds input rate, targetCycles < 1, or any τ is negative.
+ ///
+ ///
+ ///
+ /// Time constant (τ) vs "alpha" (α):
+ /// Internally, EMAs use α constrained from 0 to 1. This API exposes τ because it is easier to reason about.
+ /// For update period Δt (seconds), α is derived as:
+ ///
+ /// α = 1 - exp(-Δt / τ)
+ ///
+ /// Interpretation: after a step change, an EMA will move about 63% toward the new value after ~τ seconds.
+ ///
+ ///
+ /// Two-stage smoothing (when outputRate < inputRate):
+ ///
+ /// -
+ /// Interval averaging (boxcar) across the publish interval (anti-alias / jitter reduction).
+ ///
+ /// -
+ /// Publish EMA applied to the down-sampled stream (additional "calmness").
+ ///
+ ///
+ /// This combination yields stable outputs at 60Hz without producing thousands of nearly-identical jittery estimates per second.
+ ///
+ ///
+ /// Knob-tweaking guidance:
+ ///
+ /// -
+ /// Increase to reduce noise/jitter, at the cost of more latency.
+ ///
+ /// -
+ /// Increase publish τ values to make outputs calmer, at the cost of slower response.
+ ///
+ /// -
+ /// If you disable interval averaging, consider increasing publish τ values to compensate.
+ ///
+ /// -
+ /// Keep ROCOF τ larger than frequency τ; ROCOF is inherently noisier.
+ ///
+ ///
+ ///
+ ///
+ public SlidingDftPhaseEstimator(
+ double sampleRateHz,
+ double outputRateHz,
+ LineFrequency nominalFrequency,
+ PhaseChannel referenceChannel = DefaultReferenceChannel,
+ int targetCycles = DefaultTargetCycles,
+ bool enableIntervalAveraging = DefaultEnableIntervalAveraging,
+ bool enablePublishEMA = DefaultEnablePublishEMA,
+ double publishAnglesTauSeconds = DefaultPublishAnglesTauSeconds,
+ double publishMagnitudesTauSeconds = DefaultPublishMagnitudesTauSeconds,
+ double publishFrequencyTauSeconds = DefaultPublishFrequencyTauSeconds,
+ double publishRocofTauSeconds = DefaultPublishRocofTauSeconds,
+ double sampleFrequencyTauSeconds = DefaultSampleFrequencyTauSeconds,
+ double sampleRocofTauSeconds = DefaultSampleRocofTauSeconds,
+ int recalculationCycles = DefaultRecalculationCycles,
+ int maxGapFillSamples = DefaultMaxGapFillSamples)
+ {
+ // Validate rates / window
+ if (sampleRateHz <= 0)
+ throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Input sample rate must be positive.");
+
+ if (outputRateHz <= 0)
+ throw new ArgumentOutOfRangeException(nameof(outputRateHz), "Output rate must be positive.");
+
+ if (outputRateHz > sampleRateHz)
+ throw new ArgumentOutOfRangeException(nameof(outputRateHz), "Output rate must be <= input sample rate.");
+
+ if (targetCycles < 1)
+ throw new ArgumentOutOfRangeException(nameof(targetCycles), "Target cycles must be at least 1.");
+
+ if (recalculationCycles < 1)
+ throw new ArgumentOutOfRangeException(nameof(recalculationCycles), "Recalculation cycles must be at least 1.");
+
+ // Validate taus (τ >= 0). τ == 0 means "no smoothing" (alpha = 1).
+ if (publishAnglesTauSeconds < 0.0D)
+ throw new ArgumentOutOfRangeException(nameof(publishAnglesTauSeconds), "Tau must be >= 0.");
+
+ if (publishMagnitudesTauSeconds < 0.0D)
+ throw new ArgumentOutOfRangeException(nameof(publishMagnitudesTauSeconds), "Tau must be >= 0.");
+
+ if (publishFrequencyTauSeconds < 0.0D)
+ throw new ArgumentOutOfRangeException(nameof(publishFrequencyTauSeconds), "Tau must be >= 0.");
+
+ if (publishRocofTauSeconds < 0.0D)
+ throw new ArgumentOutOfRangeException(nameof(publishRocofTauSeconds), "Tau must be >= 0.");
+
+ if (sampleFrequencyTauSeconds < 0.0D)
+ throw new ArgumentOutOfRangeException(nameof(sampleFrequencyTauSeconds), "Tau must be >= 0.");
+
+ if (sampleRocofTauSeconds < 0.0D)
+ throw new ArgumentOutOfRangeException(nameof(sampleRocofTauSeconds), "Tau must be >= 0.");
+
+ // Store configuration
+ SampleRateHz = sampleRateHz;
+ OutputRateHz = outputRateHz;
+ NominalFrequencyHz = (double)nominalFrequency;
+ ReferenceChannel = referenceChannel;
+
+ // Precompute nominal-derived frequency-tracking constants
+ m_minFrequency = NominalFrequencyHz * 0.9D;
+ m_maxFrequency = NominalFrequencyHz * 1.1D;
+
+ m_samplePeriodSeconds = 1.0D / sampleRateHz;
+
+ m_publishPeriodNs = outputRateHz >= sampleRateHz ?
+ 0L : (long)Math.Round(NanosecondsPerSecond / outputRateHz);
+
+ m_enableIntervalAveraging = enableIntervalAveraging && m_publishPeriodNs != 0L;
+ m_enablePublishEMA = enablePublishEMA;
+
+ // Convert τ -> α using the appropriate Δt for each EMA
+ double publishDt = 1.0D / outputRateHz; // published stream cadence
+ double sampleDt = 1.0D / sampleRateHz; // per-sample cadence
+
+ m_publishAnglesAlpha = AlphaFromTau(publishDt, publishAnglesTauSeconds);
+ m_publishMagnitudesAlpha = AlphaFromTau(publishDt, publishMagnitudesTauSeconds);
+ m_publishFrequencyAlpha = AlphaFromTau(publishDt, publishFrequencyTauSeconds);
+ m_publishRocofAlpha = AlphaFromTau(publishDt, publishRocofTauSeconds);
+
+ m_frequencyAlpha = AlphaFromTau(sampleDt, sampleFrequencyTauSeconds);
+ m_rocofAlpha = AlphaFromTau(sampleDt, sampleRocofTauSeconds);
+
+ // Precompute (1 - α) complements used in the EMA hot paths
+ m_oneMinusPublishAnglesAlpha = 1.0D - m_publishAnglesAlpha;
+ m_oneMinusPublishMagnitudesAlpha = 1.0D - m_publishMagnitudesAlpha;
+ m_oneMinusPublishFrequencyAlpha = 1.0D - m_publishFrequencyAlpha;
+ m_oneMinusPublishRocofAlpha = 1.0D - m_publishRocofAlpha;
+ m_oneMinusFrequencyAlpha = 1.0D - m_frequencyAlpha;
+ m_oneMinusRocofAlpha = 1.0D - m_rocofAlpha;
+
+ int samplesPerNominalCycle = (int)Math.Round(sampleRateHz / NominalFrequencyHz);
+ WindowSamples = samplesPerNominalCycle * targetCycles;
+ m_recalculationInterval = samplesPerNominalCycle * recalculationCycles;
+
+ // Align the first recalc countdown so recalcs land on the same absolute cadence a modulo would
+ m_initialSamplesUntilRecalc = m_recalculationInterval - WindowSamples % m_recalculationInterval;
+
+ // Gap-fill bound: validate for reasonability against the window, then resolve "auto" (negative) to one window
+ if (maxGapFillSamples > 2 * WindowSamples)
+ throw new ArgumentOutOfRangeException(nameof(maxGapFillSamples), "Max gap fill samples should not exceed twice the window size.");
+
+ m_maxGapFillSamples = maxGapFillSamples < 0 ? WindowSamples : maxGapFillSamples;
+ m_samplePeriodNs = NanosecondsPerSecond / sampleRateHz;
+
+ // Precompute RMS magnitude scale factor from DFT magnitude.
+ // For a pure sinusoid: |X| = N * A / 2 (A = peak amplitude); RMS = A / sqrt(2), so RMS = sqrt(2) * |X| / N.
+ m_magnitudeScale = Sqrt2 / WindowSamples;
+
+ // Initialize sample buffers
+ m_sampleBuffers = new double[NumChannels][];
+
+ for (int ch = 0; ch < NumChannels; ch++)
+ m_sampleBuffers[ch] = new double[WindowSamples];
+
+ // DFT twiddle factor for sliding update at bin k: e^(j*2π*k/N), where k = targetCycles
+ double twiddleAngle = TwoPI * targetCycles / WindowSamples;
+ m_twiddleReal = Math.Cos(twiddleAngle);
+ m_twiddleImag = Math.Sin(twiddleAngle);
+
+ // Precompute cos/sin tables for full DFT calculation at bin k = targetCycles
+ m_cosTable = new double[WindowSamples];
+ m_sinTable = new double[WindowSamples];
+
+ for (int i = 0; i < WindowSamples; i++)
+ {
+ double angle = TwoPI * targetCycles * i / WindowSamples;
+ m_cosTable[i] = Math.Cos(angle);
+ m_sinTable[i] = Math.Sin(angle);
+ }
+
+ // Initialize phasor accumulators
+ m_phasorReal = new double[NumChannels];
+ m_phasorImag = new double[NumChannels];
+
+ // Initialize frequency tracking (internal)
+ m_smoothedFrequency = NominalFrequencyHz;
+ m_prevSmoothedFrequency = NominalFrequencyHz;
+ }
+
+ #endregion
+
+ #region [ Properties ]
+
+ ///
+ /// Gets the configured sample rate in Hz.
+ ///
+ public double SampleRateHz { get; }
+
+ ///
+ /// Gets the configured output publish rate in Hz.
+ ///
+ public double OutputRateHz { get; }
+
+ ///
+ /// Gets the nominal frequency in Hz.
+ ///
+ public double NominalFrequencyHz { get; }
+
+ ///
+ /// Gets the reference channel index used for frequency tracking (typically VA).
+ ///
+ public PhaseChannel ReferenceChannel { get; }
+
+ ///
+ /// Gets the number of samples in the analysis window.
+ ///
+ public int WindowSamples { get; }
+
+ ///
+ /// Gets the total number of samples processed.
+ ///
+ public long TotalSamplesProcessed { get; private set; }
+
+ ///
+ /// Gets whether the estimator has filled its window and is producing valid estimates.
+ ///
+ public bool IsReady => TotalSamplesProcessed >= WindowSamples;
+
+ #endregion
+
+ #region [ Methods ]
+
+ ///
+ /// Push one interleaved sample-group (VA, VB, VC, IA, IB, IC) with its epoch nanoseconds.
+ ///
+ /// Current sample for phase A voltage.
+ /// Current sample for phase B voltage.
+ /// Current sample for phase C voltage.
+ /// Current sample for phase A current.
+ /// Current sample for phase B current.
+ /// Current sample for phase C current.
+ /// Timestamp in nanoseconds since epoch.
+ /// Handler for phase estimate result.
+ ///
+ /// true when an estimate is available (window has filled) and it is time to
+ /// publish at target ; otherwise, false.
+ ///
+ ///
+ /// The data referenced in the span-based properties of the
+ /// parameter provided to the delegate is owned by the
+ /// instance, it is only valid during the scope of the
+ /// delegate call. If you need to retain the data, make a copy.
+ ///
+ public bool Step(
+ double va,
+ double vb,
+ double vc,
+ double ia,
+ double ib,
+ double ic,
+ long epochNanoseconds,
+ PhaseEstimateHandler phaseEstimateHandler)
+ {
+ ArgumentNullException.ThrowIfNull(phaseEstimateHandler);
+
+ // Missing-data handling: infer dropped samples from the input timestamp cadence, then either
+ // coast across a small gap (phase-continued synthesis) or resynchronize across a large one.
+ if (m_prevInputEpochNs > 0L)
+ {
+ long deltaNs = epochNanoseconds - m_prevInputEpochNs;
+
+ if (deltaNs < 0L)
+ {
+ // Backward timestamp: the input timeline moved earlier than the last sample. This happens when
+ // the source restarts/loops or a new configuration frame re-bases timing. Treat it as a stream
+ // discontinuity and fully reset so the window, frequency state, AND publish schedule adopt the
+ // new timeline. (A plain Resync would keep the stale publish schedule, stalling the down-sampled
+ // output until the new timeline caught up; and simply returning false here -- the previous
+ // behavior -- permanently wedged reporting once any non-increasing timestamp arrived.)
+ Reset();
+ }
+ else if (deltaNs == 0L)
+ {
+ // Duplicate timestamp: skip so the sample is not fed into the SDFT twice. The timeline reference
+ // is unchanged, so the next forward-advancing sample resumes normally.
+ return false;
+ }
+ else
+ {
+ // Samples missing between the previous input and this one (rounding absorbs minor timing jitter).
+ double missing = Math.Round(deltaNs / m_samplePeriodNs) - 1.0D;
+
+ if (missing > 0.0D)
+ {
+ if (IsReady && missing <= m_maxGapFillSamples)
+ {
+ CoastFillGap((int)missing);
+
+ // Re-anchor frequency tracking to the post-coast state so the upcoming real sample yields a
+ // clean single-sample phase delta (a multi-sample delta would wrap and corrupt the estimate).
+ int referenceChannel = (int)ReferenceChannel;
+ m_prevPhaseAngle = Math.Atan2(m_phasorImag[referenceChannel], m_phasorReal[referenceChannel]);
+ m_prevEpochNs = epochNanoseconds - (long)m_samplePeriodNs;
+ }
+ else
+ {
+ Resync();
+ }
+ }
+ }
+ }
+
+ // Apply the real sample to the buffers and SDFT
+ Span samples = [ va, vb, vc, ia, ib, ic ];
+ AdvanceOneSample(samples);
+
+ m_prevInputEpochNs = epochNanoseconds;
+
+ // Check if window has filled
+ if (TotalSamplesProcessed < WindowSamples)
+ return false;
+
+ // Compute per-sample estimate (this is the "high-rate" stream)
+ SamplePhaseEstimate sampleEstimate = ComputeEstimate(epochNanoseconds);
+
+ if (m_publishPeriodNs == 0L)
+ {
+ // Full-rate publishing: publish cadence equals sample cadence (so publish EMA is well-defined here)
+ sampleEstimate.Angles.AsSpan().CopyTo(m_publishAngles);
+ sampleEstimate.Magnitudes.AsSpan().CopyTo(m_publishMagnitudes);
+
+ double freq = sampleEstimate.Frequency;
+ double rocof = sampleEstimate.dFdt;
+
+ if (m_enablePublishEMA)
+ ApplyPublishEMA(m_publishAngles, m_publishMagnitudes, ref freq, ref rocof);
+
+ phaseEstimateHandler(new PhaseEstimate
+ {
+ Frequency = freq,
+ dFdt = rocof,
+ Angles = m_publishAngles,
+ Magnitudes = m_publishMagnitudes
+ });
+
+ return true;
+ }
+
+ if (!m_enableIntervalAveraging)
+ StoreLastSample(sampleEstimate);
+
+ // Initialize publish schedule the first time we become ready
+ if (!m_publishScheduleInitialized)
+ {
+ m_nextPublishEpochNs = AlignToNextBoundary(epochNanoseconds, m_publishPeriodNs);
+ m_publishScheduleInitialized = true;
+ }
+
+ // Accumulate all per-sample estimates within a publish interval.
+ // This acts as an anti-alias low-pass when outputRate < sampleRate.
+ if (m_enableIntervalAveraging)
+ AccumulateInterval(sampleEstimate);
+
+ // Not time to publish yet
+ if (epochNanoseconds < m_nextPublishEpochNs)
+ return false;
+
+ // Time to publish: produce a down-sampled estimate
+ PhaseEstimate estimate = ProduceIntervalEstimate();
+
+ // Advance next publish time
+ if (m_publishPeriodNs > 0L)
+ {
+ // Handle possible gaps safely
+ long behind = epochNanoseconds - m_nextPublishEpochNs;
+ long steps = behind >= 0L ? behind / m_publishPeriodNs + 1L : 1L;
+ m_nextPublishEpochNs += steps * m_publishPeriodNs;
+ }
+
+ phaseEstimateHandler(estimate);
+ return true;
+ }
+
+ // Applies one sample-group to the circular buffers and the sliding DFT (no estimate / no publish).
+ // Shared by real input samples and synthesized gap-fill samples so the window stays contiguous.
+ private void AdvanceOneSample(ReadOnlySpan samples)
+ {
+ bool isFillingUp = TotalSamplesProcessed < WindowSamples;
+ int oldestIndex = m_bufferWriteIndex;
+
+ for (int ch = 0; ch < NumChannels; ch++)
+ {
+ double oldSample = m_sampleBuffers[ch][oldestIndex];
+ double newSample = samples[ch];
+
+ m_sampleBuffers[ch][oldestIndex] = newSample;
+
+ if (isFillingUp)
+ {
+ // During fill-up, add contribution of new sample to DFT at correct phase
+ int dftIndex = (int)(TotalSamplesProcessed % WindowSamples);
+ m_phasorReal[ch] += newSample * m_cosTable[dftIndex];
+ m_phasorImag[ch] -= newSample * m_sinTable[dftIndex];
+ }
+ else
+ {
+ // Sliding DFT update: X_new = (X_old - x_oldest + x_new) * e^(j*2π/N)
+ double unrotatedReal = m_phasorReal[ch] + (newSample - oldSample);
+ double unrotatedImag = m_phasorImag[ch];
+
+ m_phasorReal[ch] = unrotatedReal * m_twiddleReal - unrotatedImag * m_twiddleImag;
+ m_phasorImag[ch] = unrotatedReal * m_twiddleImag + unrotatedImag * m_twiddleReal;
+ }
+ }
+
+ // Advance buffer write index (compare/reset avoids a per-sample modulo)
+ if (++m_bufferWriteIndex >= WindowSamples)
+ m_bufferWriteIndex = 0;
+
+ TotalSamplesProcessed++;
+
+ // Periodic full recalculation for numerical stability: immediately after fill-up, then on a fixed
+ // sample countdown aligned to m_recalculationInterval (avoids a per-sample modulo).
+ bool justFilled = TotalSamplesProcessed == WindowSamples;
+
+ if (justFilled)
+ {
+ m_samplesUntilRecalc = m_initialSamplesUntilRecalc;
+ RecalculateFullDft();
+ }
+ else if (!isFillingUp && --m_samplesUntilRecalc == 0)
+ {
+ m_samplesUntilRecalc = m_recalculationInterval;
+ RecalculateFullDft();
+ }
+ }
+
+ // Fills a detected gap of "missing" samples by synthesizing a phase-continued sinusoid for each
+ // channel from the current phasor state, feeding them through the SDFT so the window stays
+ // temporally contiguous. Only valid when IsReady (a settled phasor exists to extrapolate from).
+ private void CoastFillGap(int missing)
+ {
+ // Per-sample phase advance at the tracked frequency: ω = 2π·f/fs.
+ double omega = TwoPI * m_smoothedFrequency * m_samplePeriodSeconds;
+
+ // Reconstruct each channel's raw-waveform argument at the last real sample from its phasor.
+ // For a tone at bin k, the phasor angle ψ relates to the newest sample's cosine argument by
+ // rawArg = ψ + ω·(N-1), and peak amplitude A = 2·|X|/N.
+ double windowOffset = omega * (WindowSamples - 1);
+
+ Span amplitude = stackalloc double[NumChannels];
+ Span rawArg0 = stackalloc double[NumChannels];
+
+ for (int ch = 0; ch < NumChannels; ch++)
+ {
+ double re = m_phasorReal[ch];
+ double im = m_phasorImag[ch];
+
+ amplitude[ch] = 2.0D * Math.Sqrt(re * re + im * im) / WindowSamples;
+ rawArg0[ch] = Math.Atan2(im, re) + windowOffset;
+ }
+
+ Span synthesized = stackalloc double[NumChannels];
+
+ for (int k = 1; k <= missing; k++)
+ {
+ for (int ch = 0; ch < NumChannels; ch++)
+ synthesized[ch] = amplitude[ch] * Math.Cos(rawArg0[ch] + omega * k);
+
+ AdvanceOneSample(synthesized);
+ }
+ }
+
+ // Resynchronizes after a gap too large to coast: drops the in-flight SDFT/window and frequency
+ // state so the window refills from fresh contiguous samples. Published EMA state and the publish
+ // schedule are intentionally preserved so consumers see continuity while the window refills.
+ // NOTE: to instead snap outputs to the resynchronized values, also call ResetPublishEMA() here.
+ private void Resync()
+ {
+ for (int ch = 0; ch < NumChannels; ch++)
+ Array.Clear(m_sampleBuffers[ch], 0, WindowSamples);
+
+ m_bufferWriteIndex = 0;
+ TotalSamplesProcessed = 0L;
+
+ Array.Clear(m_phasorReal, 0, NumChannels);
+ Array.Clear(m_phasorImag, 0, NumChannels);
+
+ m_prevPhaseAngle = 0.0D;
+ m_hasPrevPhase = false;
+ m_hasLastSampleEstimate = false;
+ m_smoothedFrequency = NominalFrequencyHz;
+ m_prevSmoothedFrequency = NominalFrequencyHz;
+ m_smoothedRocof = 0.0D;
+ m_frequencyInitialized = false;
+ m_rocofInitialized = false;
+ m_prevEpochNs = 0L;
+
+ ResetInterval();
+ }
+
+ ///
+ /// Resets the estimator to its initial state.
+ ///
+ public void Reset()
+ {
+ // Clear sample buffers
+ for (int ch = 0; ch < NumChannels; ch++)
+ Array.Clear(m_sampleBuffers[ch], 0, WindowSamples);
+
+ // Reset indices and counters
+ m_bufferWriteIndex = 0;
+ TotalSamplesProcessed = 0L;
+
+ // Reset phasor accumulators
+ Array.Clear(m_phasorReal, 0, NumChannels);
+ Array.Clear(m_phasorImag, 0, NumChannels);
+
+ // Reset frequency tracking state
+ m_prevPhaseAngle = 0.0D;
+ m_hasPrevPhase = false;
+ m_hasLastSampleEstimate = false;
+ m_smoothedFrequency = NominalFrequencyHz;
+ m_prevSmoothedFrequency = NominalFrequencyHz;
+ m_smoothedRocof = 0.0D;
+ m_frequencyInitialized = false;
+ m_rocofInitialized = false;
+ m_prevEpochNs = 0L;
+ m_prevInputEpochNs = 0L;
+ m_nextPublishEpochNs = 0L;
+ m_publishScheduleInitialized = false;
+
+ ResetInterval();
+ ResetPublishEMA();
+ }
+
+ private void ResetInterval()
+ {
+ m_intervalCount = 0;
+
+ Array.Clear(m_intervalAngleCosSum, 0, NumChannels);
+ Array.Clear(m_intervalAngleSinSum, 0, NumChannels);
+ Array.Clear(m_intervalMagnitudeSum, 0, NumChannels);
+
+ m_intervalFreqSum = 0.0D;
+ m_intervalRocofSum = 0.0D;
+ }
+
+ private void ResetPublishEMA()
+ {
+ m_publishEMAInitialized = false;
+
+ Array.Clear(m_pubAngleCos, 0, NumChannels);
+ Array.Clear(m_pubAngleSin, 0, NumChannels);
+ Array.Clear(m_pubMagnitude, 0, NumChannels);
+
+ m_pubFrequency = 0.0D;
+ m_pubRocof = 0.0D;
+ }
+
+ // Performs a full DFT recalculation from the sample buffers for numerical stability.
+ private void RecalculateFullDft()
+ {
+ // The buffer write index points to the oldest sample (next to be overwritten)
+ // So we read starting from that index
+ int startIndex = m_bufferWriteIndex;
+
+ for (int ch = 0; ch < NumChannels; ch++)
+ {
+ double sumReal = 0.0D;
+ double sumImag = 0.0D;
+
+ for (int i = 0; i < WindowSamples; i++)
+ {
+ int sampleIndex = (startIndex + i) % WindowSamples;
+ double sample = m_sampleBuffers[ch][sampleIndex];
+
+ // DFT at bin k = m_targetCycles: X_k = Σ x[n] * e^(-j*2π*k*n/N)
+ // Using pre-computed tables which already incorporate the bin number
+ sumReal += sample * m_cosTable[i];
+ sumImag -= sample * m_sinTable[i];
+ }
+
+ m_phasorReal[ch] = sumReal;
+ m_phasorImag[ch] = sumImag;
+ }
+ }
+
+ private void AccumulateInterval(in SamplePhaseEstimate estimate)
+ {
+ m_intervalCount++;
+
+ // Angles: accumulate unit vectors to avoid wrap issues
+ for (int ch = 0; ch < NumChannels; ch++)
+ {
+ double angle = estimate.Angles[ch];
+ m_intervalAngleCosSum[ch] += Math.Cos(angle);
+ m_intervalAngleSinSum[ch] += Math.Sin(angle);
+ m_intervalMagnitudeSum[ch] += estimate.Magnitudes[ch];
+ }
+
+ m_intervalFreqSum += estimate.Frequency;
+ m_intervalRocofSum += estimate.dFdt;
+ }
+
+ // Do not call ProduceIntervalEstimate twice without another interval fill,
+ // otherwise you will be smoothing already-smoothed values
+ private PhaseEstimate ProduceIntervalEstimate()
+ {
+ Angle[] angles;
+ double[] magnitudes;
+ double freq;
+ double rocof;
+
+ // If interval averaging is disabled, publish the most recent per-sample estimate ("sample-and-hold").
+ // This avoids pretending to "average" while the knob says we shouldn’t.
+ if (!m_enableIntervalAveraging)
+ {
+ if (!m_hasLastSampleEstimate)
+ throw new InvalidOperationException("No sample estimate available"); // Unexpected
+
+ // Copy scalars to locals to not mutate the stored last sample
+ freq = m_lastFrequency;
+ rocof = m_lastRocof;
+
+ // Copy held sample into publish buffers so ApplyPublishEMA can safely write back.
+ m_lastAngles.AsSpan().CopyTo(m_publishAngles);
+ m_lastMagnitudes.AsSpan().CopyTo(m_publishMagnitudes);
+
+ angles = m_publishAngles;
+ magnitudes = m_publishMagnitudes;
+
+ if (m_enablePublishEMA)
+ ApplyPublishEMA(angles, magnitudes, ref freq, ref rocof);
+
+ return new PhaseEstimate
+ {
+ Frequency = freq,
+ dFdt = rocof,
+ Angles = angles,
+ Magnitudes = magnitudes
+ };
+ }
+
+ // Interval-averaged publish path (anti-aliasing low-pass)
+ int intervalCount = m_intervalCount > 0 ? m_intervalCount : 1;
+
+ angles = m_publishAngles;
+ magnitudes = m_publishMagnitudes;
+
+ for (int ch = 0; ch < NumChannels; ch++)
+ {
+ double meanAngle = Math.Atan2(m_intervalAngleSinSum[ch], m_intervalAngleCosSum[ch]);
+ angles[ch] = NormalizeAngle(meanAngle);
+ magnitudes[ch] = m_intervalMagnitudeSum[ch] / intervalCount;
+ }
+
+ freq = m_intervalFreqSum / intervalCount;
+ rocof = m_intervalRocofSum / intervalCount;
+
+ ResetInterval();
+
+ if (m_enablePublishEMA)
+ ApplyPublishEMA(angles, magnitudes, ref freq, ref rocof);
+
+ return new PhaseEstimate
+ {
+ Frequency = freq,
+ dFdt = rocof,
+ Angles = angles,
+ Magnitudes = magnitudes
+ };
+ }
+
+ private void ApplyPublishEMA(Angle[] angles, double[] magnitudes, ref double freq, ref double rocof)
+ {
+ if (m_publishEMAInitialized)
+ {
+ // Angles: EMA on unit vectors (wrap-safe)
+ for (int ch = 0; ch < NumChannels; ch++)
+ {
+ double angle = angles[ch];
+ double cos = Math.Cos(angle);
+ double sin = Math.Sin(angle);
+
+ m_pubAngleCos[ch] = m_publishAnglesAlpha * cos + m_oneMinusPublishAnglesAlpha * m_pubAngleCos[ch];
+ m_pubAngleSin[ch] = m_publishAnglesAlpha * sin + m_oneMinusPublishAnglesAlpha * m_pubAngleSin[ch];
+
+ m_pubMagnitude[ch] = m_publishMagnitudesAlpha * magnitudes[ch] + m_oneMinusPublishMagnitudesAlpha * m_pubMagnitude[ch];
+ }
+
+ m_pubFrequency = m_publishFrequencyAlpha * freq + m_oneMinusPublishFrequencyAlpha * m_pubFrequency;
+ m_pubRocof = m_publishRocofAlpha * rocof + m_oneMinusPublishRocofAlpha * m_pubRocof;
+ }
+ else
+ {
+ for (int ch = 0; ch < NumChannels; ch++)
+ {
+ double angle = angles[ch];
+ m_pubAngleCos[ch] = Math.Cos(angle);
+ m_pubAngleSin[ch] = Math.Sin(angle);
+ m_pubMagnitude[ch] = magnitudes[ch];
+ }
+
+ m_pubFrequency = freq;
+ m_pubRocof = rocof;
+ m_publishEMAInitialized = true;
+ }
+
+ // Write back smoothed values
+ for (int ch = 0; ch < NumChannels; ch++)
+ {
+ double angle = Math.Atan2(m_pubAngleSin[ch], m_pubAngleCos[ch]);
+ angles[ch] = NormalizeAngle(angle);
+ magnitudes[ch] = m_pubMagnitude[ch];
+ }
+
+ freq = m_pubFrequency;
+ rocof = m_pubRocof;
+ }
+
+ // Computes the per-sample phase estimate from current phasor values.
+ private SamplePhaseEstimate ComputeEstimate(long epochNanoseconds)
+ {
+ Angle[] angles = m_workingAngles;
+ double[] magnitudes = m_workingMagnitudes;
+ int referenceChannel = (int)ReferenceChannel;
+
+ // Reference angle for phase normalization (typically VA); also reused below as the reference
+ // channel's current phase for frequency tracking, which avoids a duplicate Atan2 evaluation.
+ double referenceAngle = Math.Atan2(m_phasorImag[referenceChannel], m_phasorReal[referenceChannel]);
+
+ for (int ch = 0; ch < NumChannels; ch++)
+ {
+ double real = m_phasorReal[ch];
+ double imag = m_phasorImag[ch];
+
+ // Calculate magnitude (RMS)
+ magnitudes[ch] = Math.Sqrt(real * real + imag * imag) * m_magnitudeScale;
+
+ // Calculate angle relative to reference (VA)
+ double rawAngle = Math.Atan2(imag, real);
+ angles[ch] = NormalizeAngle(rawAngle - referenceAngle);
+ }
+
+ // Calculate time delta since last sample
+ double deltaTimeSeconds = m_samplePeriodSeconds;
+
+ if (m_prevEpochNs > 0)
+ {
+ double measuredDelta = (epochNanoseconds - m_prevEpochNs) / (double)NanosecondsPerSecond;
+
+ if (measuredDelta is > 0.0D and < 1.0D)
+ deltaTimeSeconds = measuredDelta;
+ }
+
+ // Frequency from reference channel phase progression (reuses referenceAngle as current phase).
+ // FUTURE: Track frequency from the positive-sequence component instead of a single reference
+ // channel, so a single-channel fault (e.g., a VA PT failure) does not degrade the frequency /
+ // ROCOF estimate while the remaining phases are still healthy.
+ double instantaneousFrequency = NominalFrequencyHz;
+
+ if (m_hasPrevPhase)
+ {
+ double deltaPhase = NormalizeAngle(referenceAngle - m_prevPhaseAngle);
+
+ // Instantaneous frequency from phase progression. The full form is
+ // NominalFrequencyHz + (deltaPhase - 2π·fNom·dt) / (2π·dt), which cancels exactly to
+ // deltaPhase / (2π·dt) — fewer ops and avoids adding/subtracting the large nominal term.
+ instantaneousFrequency = deltaPhase / (TwoPI * deltaTimeSeconds);
+
+ // Clamp instantaneous frequency to ±10% of nominal to reject transient / bad-data excursions.
+ // This saturates silently, so it effectively acts as a data-quality guard. Surfacing that as an
+ // output quality flag is protocol-dependent: SEL CWS currently carries no quality flags, so any
+ // quality indication would have to be expressed through the existing base-model quality fields.
+ // FUTURE: Revisit quality reporting if/when SEL CWS adds native quality flags.
+ instantaneousFrequency = Math.Max(m_minFrequency, Math.Min(m_maxFrequency, instantaneousFrequency));
+ }
+
+ m_prevPhaseAngle = referenceAngle;
+ m_hasPrevPhase = true;
+
+ // Smooth frequency
+ if (m_frequencyInitialized)
+ {
+ m_smoothedFrequency = m_frequencyAlpha * instantaneousFrequency + m_oneMinusFrequencyAlpha * m_smoothedFrequency;
+ }
+ else
+ {
+ m_smoothedFrequency = instantaneousFrequency;
+ m_frequencyInitialized = true;
+ }
+
+ // ROCOF
+ double rocof = 0.0D;
+
+ if (m_rocofInitialized)
+ {
+ double instantaneousRocof = (m_smoothedFrequency - m_prevSmoothedFrequency) / deltaTimeSeconds;
+ m_smoothedRocof = m_rocofAlpha * instantaneousRocof + m_oneMinusRocofAlpha * m_smoothedRocof;
+ rocof = m_smoothedRocof;
+ }
+ else
+ {
+ m_rocofInitialized = true;
+ }
+
+ m_prevSmoothedFrequency = m_smoothedFrequency;
+ m_prevEpochNs = epochNanoseconds;
+
+ // Arrays are reused; callers must treat results as ephemeral per Step call
+ return new SamplePhaseEstimate
+ {
+ Frequency = m_smoothedFrequency,
+ dFdt = rocof,
+ Angles = angles,
+ Magnitudes = magnitudes
+ };
+ }
+
+ // Normalizes an angle to the range [-π, π]
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static Angle NormalizeAngle(double angle)
+ {
+ return new Angle(angle).ToRange(-Math.PI, false);
+ }
+
+ // Converts a time constant (τ) to an EMA alpha (α) for a given update period (Δt)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static double AlphaFromTau(double dtSeconds, double tauSeconds)
+ {
+ // τ == 0 => α == 1 (no smoothing; output follows input immediately)
+ if (tauSeconds <= 0.0D)
+ return 1.0D;
+
+ // Numerically stable for typical dt/tau ratios; dt and tau are positive here
+ return 1.0D - Math.Exp(-dtSeconds / tauSeconds);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static long AlignToNextBoundary(long timestampNs, long periodNs)
+ {
+ long remainder = timestampNs % periodNs;
+ return remainder == 0 ? timestampNs : timestampNs + (periodNs - remainder);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void StoreLastSample(in SamplePhaseEstimate estimate)
+ {
+ estimate.Angles.AsSpan().CopyTo(m_lastAngles);
+ estimate.Magnitudes.AsSpan().CopyTo(m_lastMagnitudes);
+ m_lastFrequency = estimate.Frequency;
+ m_lastRocof = estimate.dFdt;
+ m_hasLastSampleEstimate = true;
+ }
+
+ #endregion
+}
diff --git a/src/UnitTests/Gemstone.PhasorProtocols.UnitTests.csproj b/src/UnitTests/Gemstone.PhasorProtocols.UnitTests.csproj
index aaedc705ed..09f767fa3b 100644
--- a/src/UnitTests/Gemstone.PhasorProtocols.UnitTests.csproj
+++ b/src/UnitTests/Gemstone.PhasorProtocols.UnitTests.csproj
@@ -20,4 +20,8 @@
+
+
+
+
diff --git a/src/UnitTests/IEEEC37_118PhaseEstimatorTest.cs b/src/UnitTests/IEEEC37_118PhaseEstimatorTest.cs
new file mode 100644
index 0000000000..25dbae8c10
--- /dev/null
+++ b/src/UnitTests/IEEEC37_118PhaseEstimatorTest.cs
@@ -0,0 +1,267 @@
+//******************************************************************************************************
+// IEEEC37_118PhaseEstimatorTest.cs - Gbtc
+//
+// Copyright © 2026, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may
+// not use this file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://www.opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+//******************************************************************************************************
+// ReSharper disable InconsistentNaming
+// ReSharper disable PossiblyImpureMethodCallOnReadonlyVariable
+// ReSharper disable RedundantArgumentDefaultValue
+
+using System;
+using Gemstone.Numeric.EE;
+using Gemstone.PhasorProtocols.SelCWS;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Gemstone.PhasorProtocols.UnitTests;
+
+///
+/// Unit tests for , covering the IEEE C37.118-2018 Annex D
+/// phasor, frequency and ROCOF estimation for both P-class and M-class filters.
+///
+///
+/// Converted from the original Gemstone console demonstration program into deterministic assertions.
+///
+[TestClass]
+public class IEEEC37_118PhaseEstimatorTest
+{
+ // 960 Hz (= 16 samples/cycle at 60 Hz) reporting at 60 Hz matches the original Annex D test vectors.
+ private const double SampleRate = 960.0D;
+ private const double OutputRate = 60.0D;
+ private const double NominalFrequency = 60.0D;
+ private const double Sqrt2 = 1.4142135623730951D;
+ private const double DegreesToRadians = Math.PI / 180.0D;
+
+ // Captures the most recent published estimate. Spans are only valid during the callback,
+ // so values are copied out into fields/arrays.
+ private sealed class Capture
+ {
+ public bool Got;
+ public double Frequency;
+ public double Rocof;
+ public long Count;
+ public readonly double[] MagnitudesRms = new double[6];
+ public readonly double[] AnglesDegrees = new double[6];
+
+ public void OnEstimate(in PhaseEstimate estimate)
+ {
+ Got = true;
+ Frequency = estimate.Frequency;
+ Rocof = estimate.dFdt;
+ Count++;
+
+ for (int i = 0; i < 6; i++)
+ {
+ MagnitudesRms[i] = estimate.Magnitudes[i];
+ AnglesDegrees[i] = estimate.Angles[i].ToDegrees();
+ }
+ }
+ }
+
+ private static IEEEC37_118PhaseEstimator BuildEstimator(FilterClass filterClass = FilterClass.P)
+ {
+ return new IEEEC37_118PhaseEstimator(
+ sampleRateHz: SampleRate,
+ outputRateHz: OutputRate,
+ nominalFrequency: LineFrequency.Hz60,
+ filterClass: filterClass);
+ }
+
+ // Balanced three-phase channel phase offsets (radians): VA/VB/VC at 0/-120/+120 deg,
+ // IA/IB/IC at the same with an optional current lead.
+ private static double[] BalancedPhases(double currentLeadDegrees = 0.0D)
+ {
+ return
+ [
+ 0.0D * DegreesToRadians,
+ -120.0D * DegreesToRadians,
+ 120.0D * DegreesToRadians,
+ currentLeadDegrees * DegreesToRadians,
+ (-120.0D + currentLeadDegrees) * DegreesToRadians,
+ (120.0D + currentLeadDegrees) * DegreesToRadians
+ ];
+ }
+
+ private static double[] UniformPeaks(double amplitude)
+ {
+ return [amplitude, amplitude, amplitude, amplitude, amplitude, amplitude];
+ }
+
+ private static long EpochNs(long sampleIndex)
+ {
+ return (long)Math.Round(sampleIndex * 1_000_000_000.0D / SampleRate);
+ }
+
+ // Steps the estimator with a cosine-referenced sinusoidal sample-group (VA, VB, VC, IA, IB, IC)
+ // for an absolute sample index. The Annex D angle convention assumes a cosine reference.
+ private static void StepIndex(IEEEC37_118PhaseEstimator estimator, Capture capture, long sampleIndex, double frequency, double[] peak, double[] phaseRadians)
+ {
+ double t = sampleIndex / SampleRate;
+ double w = 2.0D * Math.PI * frequency * t;
+
+ double va = peak[0] * Math.Cos(w + phaseRadians[0]);
+ double vb = peak[1] * Math.Cos(w + phaseRadians[1]);
+ double vc = peak[2] * Math.Cos(w + phaseRadians[2]);
+ double ia = peak[3] * Math.Cos(w + phaseRadians[3]);
+ double ib = peak[4] * Math.Cos(w + phaseRadians[4]);
+ double ic = peak[5] * Math.Cos(w + phaseRadians[5]);
+
+ estimator.Step(va, vb, vc, ia, ib, ic, EpochNs(sampleIndex), capture.OnEstimate);
+ }
+
+ // Wraps an angle in degrees to (-180, 180].
+ private static double WrapDegrees(double degrees)
+ {
+ double result = (degrees + 180.0D) % 360.0D;
+
+ if (result < 0.0D)
+ result += 360.0D;
+
+ return result - 180.0D;
+ }
+
+ [TestMethod]
+ public void TracksNominalFrequency()
+ {
+ IEEEC37_118PhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ // 50 nominal cycles worth of samples
+ const long totalSamples = (long)(SampleRate / NominalFrequency * 50);
+
+ for (long i = 0; i < totalSamples; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(capture.Got, "Expected an estimate once the filter window filled.");
+ Assert.AreEqual(NominalFrequency, capture.Frequency, 0.1D, "Frequency should track 60 Hz.");
+ Assert.AreEqual(0.0D, capture.Rocof, 0.1D, "ROCOF should be ~0 for a steady frequency.");
+ Assert.AreEqual(100.0D / Sqrt2, capture.MagnitudesRms[(int)PhaseChannel.VA], 0.5D, "VA RMS magnitude.");
+ }
+
+ [TestMethod]
+ public void TracksOffNominalFrequency()
+ {
+ IEEEC37_118PhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ const double ActualFrequency = 61.0D;
+
+ // 30 nominal cycles for convergence
+ const long totalSamples = (long)(SampleRate / NominalFrequency * 30);
+
+ for (long i = 0; i < totalSamples; i++)
+ StepIndex(estimator, capture, i, ActualFrequency, peak, phases);
+
+ Assert.IsTrue(capture.Got);
+ Assert.AreEqual(ActualFrequency, capture.Frequency, 0.2D, "Frequency should track 61 Hz.");
+ }
+
+ [TestMethod]
+ public void MeasuresRmsMagnitudesPerChannel()
+ {
+ IEEEC37_118PhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = [100.0D, 150.0D, 200.0D, 120.0D, 180.0D, 90.0D];
+ double[] phases = BalancedPhases();
+
+ const long totalSamples = (long)(SampleRate / NominalFrequency * 20);
+
+ for (long i = 0; i < totalSamples; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(capture.Got);
+
+ for (int ch = 0; ch < 6; ch++)
+ Assert.AreEqual(peak[ch] / Sqrt2, capture.MagnitudesRms[ch], peak[ch] * 0.02D, $"RMS magnitude for channel {ch}.");
+ }
+
+ [TestMethod]
+ public void ReportsAnglesWithExpectedPhaseRelationships()
+ {
+ IEEEC37_118PhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases(currentLeadDegrees: 30.0D);
+
+ const long totalSamples = (long)(SampleRate / NominalFrequency * 20);
+
+ for (long i = 0; i < totalSamples; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(capture.Got);
+
+ double va = capture.AnglesDegrees[(int)PhaseChannel.VA];
+
+ // Annex D reports absolute synchrophasor angles, so validate the inter-channel relationships.
+ Assert.AreEqual(-120.0D, WrapDegrees(capture.AnglesDegrees[(int)PhaseChannel.VB] - va), 1.5D, "VB ~ -120 deg from VA.");
+ Assert.AreEqual(120.0D, WrapDegrees(capture.AnglesDegrees[(int)PhaseChannel.VC] - va), 1.5D, "VC ~ +120 deg from VA.");
+ Assert.AreEqual(30.0D, WrapDegrees(capture.AnglesDegrees[(int)PhaseChannel.IA] - va), 1.5D, "IA leads VA by ~30 deg.");
+ }
+
+ [TestMethod]
+ public void MClassFilterOrderExceedsPClass()
+ {
+ IEEEC37_118PhaseEstimator pEstimator = BuildEstimator(FilterClass.P);
+ IEEEC37_118PhaseEstimator mEstimator = BuildEstimator(FilterClass.M);
+
+ Assert.IsTrue(mEstimator.FilterOrder > pEstimator.FilterOrder, "M-class filter order should exceed P-class.");
+
+ Capture pCapture = new();
+ Capture mCapture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ const long totalSamples = (long)(SampleRate / NominalFrequency * 50);
+
+ for (long i = 0; i < totalSamples; i++)
+ {
+ StepIndex(pEstimator, pCapture, i, NominalFrequency, peak, phases);
+ StepIndex(mEstimator, mCapture, i, NominalFrequency, peak, phases);
+ }
+
+ Assert.IsTrue(pCapture.Got && mCapture.Got);
+
+ const double expectedRms = 100.0D / Sqrt2;
+ Assert.AreEqual(NominalFrequency, pCapture.Frequency, 0.1D, "P-class frequency.");
+ Assert.AreEqual(NominalFrequency, mCapture.Frequency, 0.1D, "M-class frequency.");
+ Assert.AreEqual(expectedRms, pCapture.MagnitudesRms[(int)PhaseChannel.VA], 0.5D, "P-class VA RMS magnitude.");
+ Assert.AreEqual(expectedRms, mCapture.MagnitudesRms[(int)PhaseChannel.VA], 0.5D, "M-class VA RMS magnitude.");
+ }
+
+ [TestMethod]
+ public void ProducesExpectedDecimationCount()
+ {
+ IEEEC37_118PhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ // Exactly one second of data.
+ const long totalSamples = (long)SampleRate;
+
+ for (long i = 0; i < totalSamples; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ const int samplesPerReport = (int)(SampleRate / OutputRate);
+ long expected = (totalSamples - estimator.WindowSamples) / samplesPerReport;
+
+ Assert.IsTrue(capture.Got);
+ Assert.IsTrue(Math.Abs(capture.Count - expected) <= 1,
+ $"Expected ~{expected} estimates at {OutputRate} Hz report rate, got {capture.Count}.");
+ }
+}
diff --git a/src/UnitTests/SlidingDftPhaseEstimatorTest.cs b/src/UnitTests/SlidingDftPhaseEstimatorTest.cs
new file mode 100644
index 0000000000..93c8a65010
--- /dev/null
+++ b/src/UnitTests/SlidingDftPhaseEstimatorTest.cs
@@ -0,0 +1,356 @@
+//******************************************************************************************************
+// SlidingDftPhaseEstimatorTest.cs - Gbtc
+//
+// Copyright © 2026, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may
+// not use this file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://www.opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+//******************************************************************************************************
+// ReSharper disable InconsistentNaming
+// ReSharper disable PossiblyImpureMethodCallOnReadonlyVariable
+
+using System;
+using Gemstone.Numeric.EE;
+using Gemstone.PhasorProtocols.SelCWS;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Gemstone.PhasorProtocols.UnitTests;
+
+///
+/// Unit tests for , covering the core phasor/frequency estimation
+/// and the missing-data handling (phase-continued coast and resynchronization).
+///
+[TestClass]
+public class SlidingDftPhaseEstimatorTest
+{
+ private const double SampleRate = 3000.0D;
+ private const double NominalFrequency = 60.0D;
+ private const double Sqrt2 = 1.4142135623730951D;
+ private const double DegreesToRadians = Math.PI / 180.0D;
+
+ // Captures the most recent published estimate. Spans are only valid during the callback,
+ // so scalar values are copied out into arrays.
+ private sealed class Capture
+ {
+ public bool Got;
+ public double Frequency;
+ public readonly double[] MagnitudesRms = new double[6];
+ public readonly double[] AnglesDegrees = new double[6];
+
+ // Running sums over every published estimate. With internal smoothing off, the raw per-sample
+ // frequency/ROCOF carry deterministic ripple (spectral-leakage beat off-nominal; timestamp
+ // rounding jitter in dt), so a single sample is unrepresentative. The mean over the run recovers
+ // the true value (the ripple is zero-mean about it).
+ private double m_frequencySum;
+ private double m_rocofSum;
+ private long m_count;
+
+ public double MeanFrequency => m_count > 0 ? m_frequencySum / m_count : 0.0D;
+ public double MeanRocof => m_count > 0 ? m_rocofSum / m_count : 0.0D;
+
+ public void OnEstimate(in PhaseEstimate estimate)
+ {
+ Got = true;
+ Frequency = estimate.Frequency;
+
+ m_frequencySum += estimate.Frequency;
+ m_rocofSum += estimate.dFdt;
+ m_count++;
+
+ for (int i = 0; i < 6; i++)
+ {
+ MagnitudesRms[i] = estimate.Magnitudes[i];
+ AnglesDegrees[i] = estimate.Angles[i].ToDegrees();
+ }
+ }
+ }
+
+ // Builds an estimator configured for deterministic, full-rate testing: publish rate equals sample
+ // rate (an estimate per sample) and all smoothing disabled so outputs reflect the raw per-sample DFT.
+ private static SlidingDftPhaseEstimator BuildEstimator(int maxGapFillSamples = SlidingDftPhaseEstimator.DefaultMaxGapFillSamples)
+ {
+ return new SlidingDftPhaseEstimator(
+ sampleRateHz: SampleRate,
+ outputRateHz: SampleRate,
+ nominalFrequency: LineFrequency.Hz60,
+ referenceChannel: PhaseChannel.VA,
+ targetCycles: 2,
+ enableIntervalAveraging: false,
+ enablePublishEMA: false,
+ publishAnglesTauSeconds: 0.0D,
+ publishMagnitudesTauSeconds: 0.0D,
+ publishFrequencyTauSeconds: 0.0D,
+ publishRocofTauSeconds: 0.0D,
+ sampleFrequencyTauSeconds: 0.0D,
+ sampleRocofTauSeconds: 0.0D,
+ recalculationCycles: 10,
+ maxGapFillSamples: maxGapFillSamples);
+ }
+
+ // Balanced three-phase channel phase offsets (radians): VA/VB/VC at 0/-120/+120 deg,
+ // IA/IB/IC at the same with an optional current lead.
+ private static double[] BalancedPhases(double currentLeadDegrees = 0.0D)
+ {
+ return
+ [
+ 0.0D * DegreesToRadians,
+ -120.0D * DegreesToRadians,
+ 120.0D * DegreesToRadians,
+ currentLeadDegrees * DegreesToRadians,
+ (-120.0D + currentLeadDegrees) * DegreesToRadians,
+ (120.0D + currentLeadDegrees) * DegreesToRadians
+ ];
+ }
+
+ // Contiguous per-sample epoch (nanoseconds) for an absolute sample index.
+ private static long EpochNs(long sampleIndex)
+ {
+ return (long)Math.Round(sampleIndex * 1_000_000_000.0D / SampleRate);
+ }
+
+ // Steps the estimator with the sinusoidal sample-group for an absolute sample index, using an
+ // explicit epoch (so gaps and out-of-order timestamps can be simulated).
+ private static bool StepWithEpoch(SlidingDftPhaseEstimator estimator, Capture capture, long sampleIndex, double frequency, double[] peak, double[] phaseRadians, long epochNs)
+ {
+ double t = sampleIndex / SampleRate;
+ double w = 2.0D * Math.PI * frequency * t;
+
+ double va = peak[0] * Math.Sin(w + phaseRadians[0]);
+ double vb = peak[1] * Math.Sin(w + phaseRadians[1]);
+ double vc = peak[2] * Math.Sin(w + phaseRadians[2]);
+ double ia = peak[3] * Math.Sin(w + phaseRadians[3]);
+ double ib = peak[4] * Math.Sin(w + phaseRadians[4]);
+ double ic = peak[5] * Math.Sin(w + phaseRadians[5]);
+
+ return estimator.Step(va, vb, vc, ia, ib, ic, epochNs, capture.OnEstimate);
+ }
+
+ // Steps with the natural contiguous epoch for the given sample index.
+ private static bool StepIndex(SlidingDftPhaseEstimator estimator, Capture capture, long sampleIndex, double frequency, double[] peak, double[] phaseRadians)
+ {
+ return StepWithEpoch(estimator, capture, sampleIndex, frequency, peak, phaseRadians, EpochNs(sampleIndex));
+ }
+
+ private static double[] UniformPeaks(double amplitude)
+ {
+ return [amplitude, amplitude, amplitude, amplitude, amplitude, amplitude];
+ }
+
+ [TestMethod]
+ public void TracksNominalFrequency()
+ {
+ SlidingDftPhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ for (long i = 0; i < 600; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(capture.Got, "Expected an estimate once the window filled.");
+ // Assert on the mean: per-sample frequency/ROCOF carry zero-mean ripple when smoothing is off.
+ Assert.AreEqual(NominalFrequency, capture.MeanFrequency, 0.1D, "Mean frequency should track 60 Hz.");
+ Assert.AreEqual(0.0D, capture.MeanRocof, 0.1D, "Mean ROCOF should be ~0 for a steady frequency.");
+ Assert.AreEqual(100.0D / Sqrt2, capture.MagnitudesRms[(int)PhaseChannel.VA], 0.5D, "VA RMS magnitude.");
+ }
+
+ [TestMethod]
+ public void TracksOffNominalFrequency()
+ {
+ SlidingDftPhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ const double ActualFrequency = 61.0D; // within the +/-10% clamp
+
+ for (long i = 0; i < 600; i++)
+ StepIndex(estimator, capture, i, ActualFrequency, peak, phases);
+
+ Assert.IsTrue(capture.Got);
+ // Off-nominal per-sample frequency oscillates (spectral-leakage beat) about the true value;
+ // its mean recovers it. A single sample lands mid-beat (~60.5) and is not representative.
+ Assert.AreEqual(ActualFrequency, capture.MeanFrequency, 0.1D, "Mean frequency should track 61 Hz.");
+ }
+
+ [TestMethod]
+ public void MeasuresRmsMagnitudesPerChannel()
+ {
+ SlidingDftPhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = [100.0D, 150.0D, 200.0D, 120.0D, 180.0D, 90.0D];
+ double[] phases = BalancedPhases();
+
+ for (long i = 0; i < 600; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(capture.Got);
+
+ for (int ch = 0; ch < 6; ch++)
+ Assert.AreEqual(peak[ch] / Sqrt2, capture.MagnitudesRms[ch], peak[ch] * 0.02D, $"RMS magnitude for channel {ch}.");
+ }
+
+ [TestMethod]
+ public void ReportsAnglesRelativeToReference()
+ {
+ SlidingDftPhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases(currentLeadDegrees: 30.0D);
+
+ for (long i = 0; i < 600; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(capture.Got);
+ Assert.AreEqual(0.0D, capture.AnglesDegrees[(int)PhaseChannel.VA], 0.05D, "VA is the reference (~0 deg).");
+ Assert.AreEqual(-120.0D, capture.AnglesDegrees[(int)PhaseChannel.VB], 1.5D, "VB ~ -120 deg.");
+ Assert.AreEqual(120.0D, capture.AnglesDegrees[(int)PhaseChannel.VC], 1.5D, "VC ~ +120 deg.");
+ Assert.AreEqual(30.0D, capture.AnglesDegrees[(int)PhaseChannel.IA], 1.5D, "IA leads VA by ~30 deg.");
+ }
+
+ [TestMethod]
+ public void CoastsAcrossSmallGap()
+ {
+ // Auto max-gap-fill resolves to one window (100 samples), so a 50-sample drop coasts.
+ SlidingDftPhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ for (long i = 0; i < 300; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(estimator.IsReady, "Estimator should be ready before the gap.");
+
+ // Drop samples 300..349 (50 missing), then resume at 350.
+ bool produced = StepIndex(estimator, capture, 350, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(produced, "Coasting a sub-threshold gap should still publish an estimate.");
+ Assert.IsTrue(estimator.IsReady, "Estimator should remain ready after coasting.");
+ Assert.AreEqual(NominalFrequency, capture.Frequency, 0.5D, "Frequency should stay stable through a coasted gap.");
+ Assert.AreEqual(100.0D / Sqrt2, capture.MagnitudesRms[(int)PhaseChannel.VA], 2.0D, "Magnitude should stay stable through a coasted gap.");
+ }
+
+ [TestMethod]
+ public void ResynchronizesAcrossLargeGap()
+ {
+ // Small fill bound forces a 50-sample drop to resynchronize.
+ SlidingDftPhaseEstimator estimator = BuildEstimator(maxGapFillSamples: 10);
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ for (long i = 0; i < 300; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(estimator.IsReady, "Estimator should be ready before the gap.");
+
+ // Drop samples 300..349 (50 missing) > maxGapFill (10) -> resync.
+ bool produced = StepIndex(estimator, capture, 350, NominalFrequency, peak, phases);
+
+ Assert.IsFalse(produced, "A gap beyond the fill bound should resync (no estimate this step).");
+ Assert.IsFalse(estimator.IsReady, "Estimator should be refilling after a resync.");
+ }
+
+ [TestMethod]
+ public void IgnoresDuplicateTimestamp()
+ {
+ SlidingDftPhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ for (long i = 0; i < 300; i++)
+ StepIndex(estimator, capture, i, NominalFrequency, peak, phases);
+
+ long processedBefore = estimator.TotalSamplesProcessed;
+
+ // Re-send the previous sample's exact epoch: a duplicate must be skipped, not fed into the SDFT twice.
+ bool produced = StepWithEpoch(estimator, capture, 300, NominalFrequency, peak, phases, EpochNs(299));
+
+ Assert.IsFalse(produced, "A duplicate timestamp should be ignored.");
+ Assert.AreEqual(processedBefore, estimator.TotalSamplesProcessed, "Duplicate samples must not advance processing.");
+
+ // The next forward-advancing sample resumes normally (the duplicate did not wedge the timeline).
+ bool resumed = StepIndex(estimator, capture, 300, NominalFrequency, peak, phases);
+ Assert.IsTrue(resumed, "Reporting resumes on the next forward sample after a duplicate.");
+ }
+
+ [TestMethod]
+ public void RecoversAfterBackwardTimestampRebase()
+ {
+ // Regression: a backward timestamp jump (source restart/loop, or a new config frame that re-bases
+ // timing) must NOT permanently stop reporting. The estimator should treat it as a discontinuity,
+ // reset onto the new timeline, and resume once the window refills.
+ SlidingDftPhaseEstimator estimator = BuildEstimator();
+ Capture capture = new();
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases();
+
+ const long Base1 = 10_000_000_000L; // first stream's epoch base (10 s)
+ const long Base2 = 1_000_000_000L; // re-based stream starts ~9 s earlier (e.g., looped replay)
+
+ for (long i = 0; i < 400; i++)
+ StepWithEpoch(estimator, capture, i, NominalFrequency, peak, phases, Base1 + EpochNs(i));
+
+ Assert.IsTrue(estimator.IsReady, "Estimator should be reporting before the backward re-base.");
+
+ // Feed a fresh contiguous timeline from the earlier base; the first sample is a backward jump.
+ bool reportedAfterRebase = false;
+
+ for (long j = 0; j < 400; j++)
+ {
+ if (StepWithEpoch(estimator, capture, j, NominalFrequency, peak, phases, Base2 + EpochNs(j)))
+ reportedAfterRebase = true;
+ }
+
+ Assert.IsTrue(reportedAfterRebase, "Reporting must resume after a backward timestamp re-base (no permanent stall).");
+ Assert.IsTrue(estimator.IsReady, "Estimator should be ready again on the new timeline.");
+ }
+
+ [TestMethod]
+ public void CoastApproximatesContinuousStream()
+ {
+ // Compare a continuously-fed estimator against one that coasts a 50-sample drop. Once the gap
+ // has slid out of the window, both should agree closely, validating that coasting does not
+ // corrupt long-term state and that the synthesized phase continuation is faithful.
+ SlidingDftPhaseEstimator continuous = BuildEstimator();
+ SlidingDftPhaseEstimator gapped = BuildEstimator();
+ Capture continuousCapture = new();
+ Capture gappedCapture = new();
+
+ double[] peak = UniformPeaks(100.0D);
+ double[] phases = BalancedPhases(currentLeadDegrees: 15.0D);
+
+ for (long i = 0; i <= 200; i++)
+ {
+ StepIndex(continuous, continuousCapture, i, NominalFrequency, peak, phases);
+ StepIndex(gapped, gappedCapture, i, NominalFrequency, peak, phases);
+ }
+
+ // Continuous gets every sample; gapped drops 201..250 (50 missing) then resumes contiguously.
+ for (long i = 201; i <= 400; i++)
+ StepIndex(continuous, continuousCapture, i, NominalFrequency, peak, phases);
+
+ for (long i = 251; i <= 400; i++)
+ StepIndex(gapped, gappedCapture, i, NominalFrequency, peak, phases);
+
+ Assert.IsTrue(continuousCapture.Got && gappedCapture.Got);
+ Assert.AreEqual(continuousCapture.Frequency, gappedCapture.Frequency, 0.05D, "Frequency should converge after the gap flushes.");
+
+ for (int ch = 0; ch < 6; ch++)
+ {
+ Assert.AreEqual(continuousCapture.MagnitudesRms[ch], gappedCapture.MagnitudesRms[ch], 0.5D, $"Magnitude mismatch on channel {ch}.");
+ Assert.AreEqual(continuousCapture.AnglesDegrees[ch], gappedCapture.AnglesDegrees[ch], 0.5D, $"Angle mismatch on channel {ch}.");
+ }
+ }
+}