Skip to main content

embedded_dsp/
pll.rs

1//! Phase-Locked Loops (PLL) and carrier recovery for power electronics, motor resolvers, and SDR.
2//!
3//! Includes:
4//! - [`SogiPll`]: Second-Order Generalized Integrator PLL for single-phase grid synchronization (solar inverters, UPS) and resolver angle tracking.
5//! - [`CostasLoop`]: Carrier phase and frequency recovery loop for BPSK/QPSK demodulation.
6
7#[allow(unused_imports)]
8use crate::math::FloatMath;
9
10/// Second-Order Generalized Integrator Phase-Locked Loop (SOGI-PLL).
11///
12/// Implements orthogonal signal generation ($v_\alpha, v_\beta$) from a single-phase input $v(t)$
13/// and tracks fundamental frequency and phase in real-time.
14///
15/// Discretized using trapezoidal (Tustin) integration for zero frequency warping at the center frequency.
16#[derive(Debug, Clone, Copy)]
17pub struct SogiPll {
18    // SOGI internal filter states and coefficients
19    sample_rate_hz: f32,
20    omega_center: f32, // Nominal center frequency in rad/s
21    k_sogi: f32,       // SOGI damping factor (typically sqrt(2) ≈ 1.414)
22    v_alpha: f32,      // In-phase filtered orthogonal component
23    v_beta: f32,       // Quadrature (90 deg lagging) filtered component
24    x1: f32,           // Integrator 1 state
25    x2: f32,           // Integrator 2 state
26
27    // Loop filter (PI) & NCO states
28    kp: f32,           // Proportional gain
29    ki: f32,           // Integral gain
30    phase: f32,        // Estimated phase θ in [-π, π]
31    omega_est: f32,    // Estimated frequency in rad/s
32    integrator_pi: f32,// PI controller accumulator
33}
34
35impl SogiPll {
36    /// Creates a new SOGI-PLL tuned to `center_freq_hz` at `sample_rate_hz`.
37    ///
38    /// - `k_sogi`: SOGI damping factor (default `1.414`).
39    /// - `kp`: Loop filter proportional gain (e.g. `60.0`).
40    /// - `ki`: Loop filter integral gain (e.g. `1400.0`).
41    pub fn new(center_freq_hz: f32, sample_rate_hz: f32, k_sogi: f32, kp: f32, ki: f32) -> Self {
42        let omega_center = 2.0 * core::f32::consts::PI * center_freq_hz;
43        Self {
44            sample_rate_hz,
45            omega_center,
46            k_sogi,
47            v_alpha: 0.0,
48            v_beta: 0.0,
49            x1: 0.0,
50            x2: 0.0,
51            kp,
52            ki,
53            phase: 0.0,
54            omega_est: omega_center,
55            integrator_pi: 0.0,
56        }
57    }
58
59    /// Process a single input sample and return the tracked instantaneous phase $\theta \in [-\pi, \pi]$.
60    pub fn process(&mut self, input: f32) -> f32 {
61        let ts = 1.0 / self.sample_rate_hz;
62        let half_ts = 0.5 * ts;
63
64        // 1. SOGI Orthogonal Signal Generation (Tustin integration)
65        let err = input - self.v_alpha;
66        let k_err = self.k_sogi * err;
67        let w = self.omega_est;
68
69        // State update for SOGI
70        let d_x1 = (k_err - self.v_beta) * w;
71        let d_x2 = self.v_alpha * w;
72
73        let x1_new = self.x1 + half_ts * d_x1;
74        let x2_new = self.x2 + half_ts * d_x2;
75
76        self.v_alpha = x1_new;
77        self.v_beta = x2_new;
78
79        self.x1 += ts * (k_err - self.v_beta) * w;
80        self.x2 += ts * self.v_alpha * w;
81
82        // 2. Park Transform Phase Detector: q-axis error = -v_alpha * sin(θ) + v_beta * cos(θ)
83        let sin_p = self.phase.sin();
84        let cos_p = self.phase.cos();
85        let v_q = -self.v_alpha * sin_p + self.v_beta * cos_p;
86
87        // 3. Loop Filter (PI controller on v_q)
88        self.integrator_pi += self.ki * ts * v_q;
89        let delta_omega = self.kp * v_q + self.integrator_pi;
90        self.omega_est = self.omega_center + delta_omega;
91
92        // 4. Integrator NCO -> Phase update
93        self.phase += self.omega_est * ts;
94
95        // Wrap phase to [-π, π]
96        let pi = core::f32::consts::PI;
97        let two_pi = 2.0 * pi;
98        while self.phase > pi {
99            self.phase -= two_pi;
100        }
101        while self.phase < -pi {
102            self.phase += two_pi;
103        }
104
105        self.phase
106    }
107
108    /// Returns the estimated fundamental frequency in Hz.
109    #[inline(always)]
110    pub fn frequency_hz(&self) -> f32 {
111        self.omega_est / (2.0 * core::f32::consts::PI)
112    }
113
114    /// Returns the filtered orthogonal components `(v_alpha, v_beta)`.
115    #[inline(always)]
116    pub fn orthogonal_components(&self) -> (f32, f32) {
117        (self.v_alpha, self.v_beta)
118    }
119
120    /// Returns the instantaneous phase $\theta \in [-\pi, \pi]$.
121    #[inline(always)]
122    pub fn phase(&self) -> f32 {
123        self.phase
124    }
125
126    /// Reset PLL internal states.
127    pub fn reset(&mut self) {
128        self.v_alpha = 0.0;
129        self.v_beta = 0.0;
130        self.x1 = 0.0;
131        self.x2 = 0.0;
132        self.phase = 0.0;
133        self.omega_est = self.omega_center;
134        self.integrator_pi = 0.0;
135    }
136}
137
138/// Costas Loop for BPSK / QPSK carrier phase and frequency tracking.
139#[derive(Debug, Clone, Copy)]
140pub struct CostasLoop {
141    sample_rate_hz: f32,
142    phase: f32,
143    freq_rad_per_sample: f32,
144    center_freq_rad: f32,
145    alpha: f32, // Proportional loop filter parameter
146    beta: f32,  // Integral loop filter parameter
147}
148
149impl CostasLoop {
150    /// Create a new Costas Loop.
151    pub fn new(center_freq_hz: f32, sample_rate_hz: f32, loop_bandwidth_hz: f32, damping: f32) -> Self {
152        let center_freq_rad = 2.0 * core::f32::consts::PI * center_freq_hz / sample_rate_hz;
153        let theta = 2.0 * core::f32::consts::PI * loop_bandwidth_hz / sample_rate_hz;
154        let d = 1.0 + 2.0 * damping * theta + theta * theta;
155        let alpha = (4.0 * damping * theta) / d;
156        let beta = (4.0 * theta * theta) / d;
157
158        Self {
159            sample_rate_hz,
160            phase: 0.0,
161            freq_rad_per_sample: center_freq_rad,
162            center_freq_rad,
163            alpha,
164            beta,
165        }
166    }
167
168    /// Process a modulated carrier sample and return the demodulated baseband in-phase (I) sample.
169    pub fn process_sample(&mut self, sample: f32) -> (f32, f32) {
170        let cos_val = self.phase.cos();
171        let sin_val = (-self.phase).sin();
172
173        let i_arm = sample * cos_val;
174        let q_arm = sample * sin_val;
175
176        // BPSK phase error detector: e = I * sign(Q) or e = I * Q
177        let error = (i_arm * q_arm).clamp(-1.0, 1.0);
178
179        // Loop filter update
180        self.freq_rad_per_sample += self.beta * error;
181        self.phase += self.freq_rad_per_sample + self.alpha * error;
182
183        // Wrap phase to [-π, π]
184        let pi = core::f32::consts::PI;
185        while self.phase > pi {
186            self.phase -= 2.0 * pi;
187        }
188        while self.phase < -pi {
189            self.phase += 2.0 * pi;
190        }
191
192        (i_arm, q_arm)
193    }
194
195    /// Current tracked carrier frequency in Hz.
196    #[inline(always)]
197    pub fn frequency_hz(&self) -> f32 {
198        self.freq_rad_per_sample * self.sample_rate_hz / (2.0 * core::f32::consts::PI)
199    }
200
201    /// Nominal center frequency in Hz.
202    #[inline(always)]
203    pub fn center_frequency_hz(&self) -> f32 {
204        self.center_freq_rad * self.sample_rate_hz / (2.0 * core::f32::consts::PI)
205    }
206}