use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PllConfig {
pub kp_pll: f64,
pub ki_pll: f64,
pub omega_0: f64,
}
impl PllConfig {
pub fn default_50hz() -> Self {
Self {
kp_pll: 40.0,
ki_pll: 400.0,
omega_0: 2.0 * std::f64::consts::PI * 50.0,
}
}
pub fn default_60hz() -> Self {
Self {
kp_pll: 40.0,
ki_pll: 400.0,
omega_0: 2.0 * std::f64::consts::PI * 60.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PllState {
pub omega_pll: f64,
pub delta_pll: f64,
pub error_int: f64,
}
impl PllState {
pub fn new(omega_0: f64) -> Self {
Self {
omega_pll: omega_0,
delta_pll: 0.0,
error_int: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct PhaseLockedLoop {
pub config: PllConfig,
pub state: PllState,
}
impl PhaseLockedLoop {
pub fn new(config: PllConfig) -> Self {
let omega_0 = config.omega_0;
Self {
state: PllState::new(omega_0),
config,
}
}
pub fn step(&mut self, v_q: f64, dt: f64) -> f64 {
let kp = self.config.kp_pll;
let ki = self.config.ki_pll;
let d_int = v_q * dt;
self.state.error_int += d_int;
let omega_pll = self.config.omega_0 + kp * v_q + ki * self.state.error_int;
self.state.omega_pll = omega_pll;
self.state.delta_pll += omega_pll * dt;
self.state.delta_pll = wrap_angle(self.state.delta_pll);
omega_pll
}
pub fn angle(&self) -> f64 {
self.state.delta_pll
}
pub fn frequency_hz(&self) -> f64 {
self.state.omega_pll / (2.0 * std::f64::consts::PI)
}
}
#[derive(Debug, Clone)]
pub struct GridFollowingInverter {
pub rated_power_mva: f64,
pub i_max_pu: f64,
pub kp_current: f64,
pub ki_current: f64,
pub pll: PhaseLockedLoop,
pub id_int: f64,
pub iq_int: f64,
}
impl GridFollowingInverter {
pub fn new(rated_mva: f64, pll_config: PllConfig) -> Self {
Self {
rated_power_mva: rated_mva,
i_max_pu: 1.1,
kp_current: 5.0,
ki_current: 200.0,
pll: PhaseLockedLoop::new(pll_config),
id_int: 0.0,
iq_int: 0.0,
}
}
pub fn step(&mut self, p_ref: f64, q_ref: f64, v_pcc: (f64, f64), dt: f64) -> (f64, f64) {
let (v_d, v_q) = v_pcc;
self.pll.step(v_q, dt);
let i_d_ref = if v_d.abs() > 1e-6 {
(2.0 / 3.0) * p_ref / v_d
} else {
0.0
};
let i_q_ref = if v_d.abs() > 1e-6 {
-(2.0 / 3.0) * q_ref / v_d
} else {
0.0
};
let i_mag = (i_d_ref * i_d_ref + i_q_ref * i_q_ref).sqrt();
let (i_d_ref, i_q_ref) = if i_mag > self.i_max_pu {
let scale = self.i_max_pu / i_mag;
(i_d_ref * scale, i_q_ref * scale)
} else {
(i_d_ref, i_q_ref)
};
let i_d_err = i_d_ref - 0.0; let i_q_err = i_q_ref - 0.0;
self.id_int += i_d_err * dt;
self.iq_int += i_q_err * dt;
let _v_cmd_d = self.kp_current * i_d_err + self.ki_current * self.id_int + v_d;
let _v_cmd_q = self.kp_current * i_q_err + self.ki_current * self.iq_int + v_q;
(i_d_ref, i_q_ref)
}
pub fn reset(&mut self) {
self.id_int = 0.0;
self.iq_int = 0.0;
}
}
fn wrap_angle(theta: f64) -> f64 {
let pi = std::f64::consts::PI;
let two_pi = 2.0 * pi;
let mut t = theta % two_pi;
if t > pi {
t -= two_pi;
} else if t < -pi {
t += two_pi;
}
t
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
#[test]
fn test_pll_lock() {
let cfg = PllConfig::default_50hz();
let mut pll = PhaseLockedLoop::new(cfg);
let dt = 1e-4;
let true_offset = PI / 4.0;
let omega_true = 2.0 * PI * 50.0;
let n = (0.5 / dt) as usize;
for k in 0..n {
let t = k as f64 * dt;
let true_angle = omega_true * t + true_offset;
let angle_err = wrap_angle(true_angle - pll.state.delta_pll);
pll.step(angle_err, dt);
}
let t_final = n as f64 * dt;
let true_angle_final = wrap_angle(omega_true * t_final + true_offset);
let err = wrap_angle(true_angle_final - pll.state.delta_pll).abs();
assert!(
err < 0.01,
"PLL angle error after 0.5 s = {:.4} rad (expected < 0.01 rad)",
err
);
}
#[test]
fn test_grid_following_current_reference() {
let pll_cfg = PllConfig::default_50hz();
let mut inv = GridFollowingInverter::new(1.0, pll_cfg);
let (id, iq) = inv.step(0.5, 0.0, (1.0, 0.0), 1e-3);
let expected_id = (2.0 / 3.0) * 0.5;
assert!(
(id - expected_id).abs() < 1e-6,
"i_d_ref = {:.6} expected {:.6}",
id,
expected_id
);
assert!(
iq.abs() < 1e-9,
"i_q_ref should be zero for Q=0; got {:.6}",
iq
);
}
#[test]
fn test_grid_following_current_limiter() {
let pll_cfg = PllConfig::default_50hz();
let mut inv = GridFollowingInverter::new(1.0, pll_cfg);
inv.i_max_pu = 1.1;
let (id, iq) = inv.step(5.0, 5.0, (1.0, 0.0), 1e-3);
let i_mag = (id * id + iq * iq).sqrt();
assert!(
i_mag <= inv.i_max_pu + 1e-9,
"Current magnitude {:.4} exceeds limit {:.4}",
i_mag,
inv.i_max_pu
);
}
#[test]
fn test_pll_60hz_nominal_frequency() {
let cfg = PllConfig::default_60hz();
let pll = PhaseLockedLoop::new(cfg.clone());
assert!((pll.frequency_hz() - 60.0).abs() < 1e-6);
}
#[test]
fn test_pll_angle_advances_at_nominal_omega() {
let cfg = PllConfig::default_50hz();
let mut pll = PhaseLockedLoop::new(cfg);
let dt = 1.0 / 50.0; let omega_ret = pll.step(0.0, dt);
assert!(
(omega_ret - pll.config.omega_0).abs() < 1e-6,
"omega = {:.4}",
omega_ret
);
}
#[test]
fn test_inverter_reset_clears_integral_states() {
let cfg = PllConfig::default_50hz();
let mut inv = GridFollowingInverter::new(1.0, cfg);
for _ in 0..10 {
inv.step(0.8, 0.2, (1.0, 0.01), 1e-3);
}
inv.reset();
assert!((inv.id_int).abs() < 1e-12, "id_int = {:.4e}", inv.id_int);
assert!((inv.iq_int).abs() < 1e-12, "iq_int = {:.4e}", inv.iq_int);
}
#[test]
fn test_inverter_q_ref_produces_negative_iq() {
let pll_cfg = PllConfig::default_50hz();
let mut inv = GridFollowingInverter::new(1.0, pll_cfg);
let (_id, iq) = inv.step(0.0, 0.5, (1.0, 0.0), 1e-3);
assert!(
iq < 0.0,
"i_q should be negative for positive Q ref, got {:.4}",
iq
);
let expected_iq = -(2.0 / 3.0) * 0.5 / 1.0;
assert!(
(iq - expected_iq).abs() < 1e-6,
"iq = {:.6}, expected {:.6}",
iq,
expected_iq
);
}
#[test]
fn test_inverter_zero_vd_gives_zero_current() {
let pll_cfg = PllConfig::default_50hz();
let mut inv = GridFollowingInverter::new(1.0, pll_cfg);
let (id, iq) = inv.step(1.0, 0.5, (0.0, 0.0), 1e-3);
assert!(
(id).abs() < 1e-9,
"id should be zero for v_d=0, got {:.4e}",
id
);
assert!(
(iq).abs() < 1e-9,
"iq should be zero for v_d=0, got {:.4e}",
iq
);
}
}