use num_complex::Complex64;
use serde::{Deserialize, Serialize};
use std::f64::consts::PI;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineParams {
pub id: usize,
pub h: f64,
pub d: f64,
pub xd_prime: f64,
pub e_prime: f64,
pub p_mech: f64,
pub freq_hz: f64,
}
impl MachineParams {
pub fn new(
id: usize,
h: f64,
d: f64,
xd_prime: f64,
e_prime: f64,
p_mech: f64,
freq_hz: f64,
) -> Self {
Self {
id,
h,
d,
xd_prime,
e_prime,
p_mech,
freq_hz,
}
}
pub fn m_inertia(&self) -> f64 {
2.0 * self.h / (2.0 * PI * self.freq_hz)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct MachineState {
pub delta: f64,
pub omega: f64,
}
impl MachineState {
pub fn equilibrium(delta_rad: f64) -> Self {
Self {
delta: delta_rad,
omega: 0.0,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct YredElement {
pub row: usize,
pub col: usize,
pub y: Complex64,
}
pub struct MultiMachineSim {
pub machines: Vec<MachineParams>,
pub y_red: Vec<Vec<Complex64>>,
pub use_coi: bool,
}
impl MultiMachineSim {
pub fn new(machines: Vec<MachineParams>, y_red: Vec<Vec<Complex64>>) -> Self {
Self {
machines,
y_red,
use_coi: true,
}
}
pub fn two_machine(m1: MachineParams, m2: MachineParams, x_line: f64) -> Self {
let x_total = m1.xd_prime + x_line + m2.xd_prime;
let y12 = Complex64::new(0.0, -1.0 / x_total);
let y11 = Complex64::new(0.0, 1.0 / (m1.xd_prime + x_line));
let y22 = Complex64::new(0.0, 1.0 / (m2.xd_prime + x_line));
let y_red = vec![vec![y11, y12], vec![y12.conj(), y22]];
Self::new(vec![m1, m2], y_red)
}
pub fn ring_network(machines: Vec<MachineParams>, x_line: f64) -> Self {
let n = machines.len();
let mut y_red = vec![vec![Complex64::new(0.0, 0.0); n]; n];
for i in 0..n {
let j = (i + 1) % n;
let xt = machines[i].xd_prime + x_line + machines[j].xd_prime;
let y_transfer = Complex64::new(0.0, -1.0 / xt);
y_red[i][j] += y_transfer;
y_red[j][i] += y_transfer;
y_red[i][i] -= y_transfer;
y_red[j][j] -= y_transfer;
}
Self::new(machines, y_red)
}
pub fn electrical_power(&self, states: &[MachineState]) -> Vec<f64> {
let n = self.machines.len();
let mut pe = vec![0.0f64; n];
for (i, (mach_i, st_i)) in self.machines.iter().zip(states.iter()).enumerate() {
let ei = mach_i.e_prime;
let di = st_i.delta;
for (j, (mach_j, st_j)) in self.machines.iter().zip(states.iter()).enumerate() {
let ej = mach_j.e_prime;
let dj = st_j.delta;
let g = self.y_red[i][j].re;
let b = self.y_red[i][j].im;
let dij = di - dj;
pe[i] += ei * ej * (g * dij.cos() + b * dij.sin());
}
}
pe
}
pub fn coi(&self, states: &[MachineState]) -> (f64, f64) {
let mt: f64 = self.machines.iter().map(|m| m.m_inertia()).sum();
if mt < 1e-12 {
return (0.0, 0.0);
}
let delta_coi: f64 = self
.machines
.iter()
.zip(states.iter())
.map(|(m, s)| m.m_inertia() * s.delta)
.sum::<f64>()
/ mt;
let omega_coi: f64 = self
.machines
.iter()
.zip(states.iter())
.map(|(m, s)| m.m_inertia() * s.omega)
.sum::<f64>()
/ mt;
(delta_coi, omega_coi)
}
pub fn derivatives(&self, states: &[MachineState]) -> Vec<(f64, f64)> {
let n = self.machines.len();
let pe = self.electrical_power(states);
let mt: f64 = self.machines.iter().map(|m| m.m_inertia()).sum();
let p_coi_acc: f64 = self
.machines
.iter()
.zip(pe.iter())
.map(|(m, &pe_i)| m.p_mech - pe_i)
.sum();
let (_, omega_coi) = self.coi(states);
(0..n)
.map(|i| {
let m = &self.machines[i];
let mi = m.m_inertia();
let pm = m.p_mech;
let d_delta = states[i].omega;
let coi_correction = if self.use_coi && mt > 1e-12 {
(mi / mt) * p_coi_acc
} else {
0.0
};
let d_omega =
(pm - pe[i] - m.d * (states[i].omega + omega_coi) - coi_correction) / mi;
(d_delta, d_omega)
})
.collect()
}
pub fn step(&self, states: &[MachineState], dt: f64) -> Vec<MachineState> {
let k1 = self.derivatives(states);
let s2: Vec<MachineState> = states
.iter()
.zip(k1.iter())
.map(|(s, &(dd, dw))| MachineState {
delta: s.delta + 0.5 * dt * dd,
omega: s.omega + 0.5 * dt * dw,
})
.collect();
let k2 = self.derivatives(&s2);
let s3: Vec<MachineState> = states
.iter()
.zip(k2.iter())
.map(|(s, &(dd, dw))| MachineState {
delta: s.delta + 0.5 * dt * dd,
omega: s.omega + 0.5 * dt * dw,
})
.collect();
let k3 = self.derivatives(&s3);
let s4: Vec<MachineState> = states
.iter()
.zip(k3.iter())
.map(|(s, &(dd, dw))| MachineState {
delta: s.delta + dt * dd,
omega: s.omega + dt * dw,
})
.collect();
let k4 = self.derivatives(&s4);
states
.iter()
.enumerate()
.map(|(i, s)| {
let (d1, w1) = k1[i];
let (d2, w2) = k2[i];
let (d3, w3) = k3[i];
let (d4, w4) = k4[i];
MachineState {
delta: s.delta + dt / 6.0 * (d1 + 2.0 * d2 + 2.0 * d3 + d4),
omega: s.omega + dt / 6.0 * (w1 + 2.0 * w2 + 2.0 * w3 + w4),
}
})
.collect()
}
pub fn run(
&self,
initial: Vec<MachineState>,
dt: f64,
t_end: f64,
fault_fn: Option<&dyn Fn(f64) -> Option<Vec<Vec<Complex64>>>>,
) -> MultiMachineResult {
let n_steps = (t_end / dt).ceil() as usize;
let mut states = initial;
let mut snapshots = Vec::with_capacity(n_steps + 1);
let mut time = 0.0;
snapshots.push(MultiMachineSnapshot {
time,
states: states.clone(),
pe: self.electrical_power(&states),
});
for step in 1..=n_steps {
time = step as f64 * dt;
if let Some(ff) = fault_fn {
if let Some(y_mod) = ff(time) {
let temp = MultiMachineSim {
machines: self.machines.clone(),
y_red: y_mod,
use_coi: self.use_coi,
};
states = temp.step(&states, dt);
} else {
states = self.step(&states, dt);
}
} else {
states = self.step(&states, dt);
}
snapshots.push(MultiMachineSnapshot {
time,
states: states.clone(),
pe: self.electrical_power(&states),
});
}
MultiMachineResult { snapshots }
}
pub fn is_transient_stable(result: &MultiMachineResult) -> bool {
for snap in &result.snapshots {
let n = snap.states.len();
for i in 0..n {
for j in i + 1..n {
let diff = (snap.states[i].delta - snap.states[j].delta).abs();
if diff > PI {
return false;
}
}
}
}
true
}
pub fn estimate_cct(
&self,
initial: &[MachineState],
y_red_fault: Vec<Vec<Complex64>>,
dt: f64,
t_sim: f64,
tol: f64,
) -> f64 {
let mut lo = 0.0f64;
let mut hi = t_sim;
for _ in 0..30 {
let mid = (lo + hi) / 2.0;
let y_red_fault_clone = y_red_fault.clone();
let y_red_post = self.y_red.clone();
let machines_clone = self.machines.clone();
let fault_fn = |t: f64| -> Option<Vec<Vec<Complex64>>> {
if t <= mid {
Some(y_red_fault_clone.clone())
} else {
Some(y_red_post.clone())
}
};
let temp_sim = MultiMachineSim {
machines: machines_clone,
y_red: self.y_red.clone(),
use_coi: self.use_coi,
};
let result = temp_sim.run(initial.to_vec(), dt, t_sim, Some(&fault_fn));
if Self::is_transient_stable(&result) {
lo = mid;
} else {
hi = mid;
}
if (hi - lo) < tol {
break;
}
}
(lo + hi) / 2.0
}
pub fn kinetic_energy(&self, states: &[MachineState]) -> f64 {
let (_, omega_coi) = self.coi(states);
self.machines
.iter()
.zip(states.iter())
.map(|(m, s)| 0.5 * m.m_inertia() * (s.omega - omega_coi).powi(2))
.sum()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiMachineSnapshot {
pub time: f64,
pub states: Vec<MachineState>,
pub pe: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiMachineResult {
pub snapshots: Vec<MultiMachineSnapshot>,
}
impl MultiMachineResult {
pub fn max_angle_spread_deg(&self) -> f64 {
self.snapshots
.iter()
.map(|snap| {
let deltas: Vec<f64> = snap.states.iter().map(|s| s.delta.to_degrees()).collect();
let dmax = deltas.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let dmin = deltas.iter().cloned().fold(f64::INFINITY, f64::min);
dmax - dmin
})
.fold(f64::NEG_INFINITY, f64::max)
}
pub fn coi_angle(&self, machines: &[MachineParams]) -> Vec<(f64, f64)> {
let mt: f64 = machines.iter().map(|m| m.m_inertia()).sum();
self.snapshots
.iter()
.map(|snap| {
let coi = if mt > 1e-12 {
machines
.iter()
.zip(snap.states.iter())
.map(|(m, s)| m.m_inertia() * s.delta)
.sum::<f64>()
/ mt
} else {
0.0
};
(snap.time, coi)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_machine(id: usize, h: f64, pm: f64) -> MachineParams {
MachineParams::new(id, h, 2.0, 0.2, 1.05, pm, 60.0)
}
#[test]
fn test_two_machine_equilibrium() {
let m1 = make_machine(0, 6.0, 0.5);
let m2 = make_machine(1, 4.0, 0.4);
let sim = MultiMachineSim::two_machine(m1, m2, 0.3);
let states = vec![
MachineState::equilibrium(0.3),
MachineState::equilibrium(-0.2),
];
let result = sim.run(states, 0.01, 2.0, None);
let spread = result.max_angle_spread_deg();
assert!(
spread < 180.0,
"Angle spread should be bounded: {:.2}°",
spread
);
}
#[test]
fn test_electrical_power_symmetric() {
let m1 = make_machine(0, 6.0, 0.8);
let m2 = make_machine(1, 6.0, 0.8);
let sim = MultiMachineSim::two_machine(m1, m2, 0.3);
let states = vec![
MachineState::equilibrium(0.0),
MachineState::equilibrium(0.0),
];
let pe = sim.electrical_power(&states);
assert!(
(pe[0] - pe[1]).abs() < 1e-10,
"Pe0={:.4} Pe1={:.4}",
pe[0],
pe[1]
);
}
#[test]
fn test_coi_computation() {
let m1 = MachineParams::new(0, 6.0, 2.0, 0.2, 1.0, 0.5, 60.0);
let m2 = MachineParams::new(1, 4.0, 2.0, 0.2, 1.0, 0.5, 60.0);
let sim = MultiMachineSim::two_machine(m1, m2, 0.3);
let states = vec![
MachineState {
delta: 0.4,
omega: 0.1,
},
MachineState {
delta: 0.2,
omega: 0.05,
},
];
let (delta_coi, omega_coi) = sim.coi(&states);
let m1_i = 2.0 * 6.0 / (2.0 * PI * 60.0);
let m2_i = 2.0 * 4.0 / (2.0 * PI * 60.0);
let expected_coi = (m1_i * 0.4 + m2_i * 0.2) / (m1_i + m2_i);
assert!(
(delta_coi - expected_coi).abs() < 1e-10,
"COI={:.6} expected={:.6}",
delta_coi,
expected_coi
);
let _ = omega_coi;
}
#[test]
fn test_three_machine_ring() {
let machines: Vec<MachineParams> = (0..3).map(|i| make_machine(i, 6.0, 0.5)).collect();
let sim = MultiMachineSim::ring_network(machines, 0.4);
let states: Vec<MachineState> = (0..3)
.map(|i| MachineState::equilibrium(0.1 * i as f64))
.collect();
let result = sim.run(states, 0.01, 1.0, None);
assert_eq!(result.snapshots.len(), 101);
let _ = result.max_angle_spread_deg();
}
#[test]
fn test_kinetic_energy_at_equilibrium() {
let m1 = make_machine(0, 6.0, 0.5);
let m2 = make_machine(1, 6.0, 0.5);
let sim = MultiMachineSim::two_machine(m1, m2, 0.3);
let states = vec![
MachineState::equilibrium(0.3),
MachineState::equilibrium(-0.3),
];
let ke = sim.kinetic_energy(&states);
assert!(
ke.abs() < 1e-12,
"KE at equilibrium should be zero: {:.2e}",
ke
);
}
#[test]
fn test_fault_simulation() {
let m1 = make_machine(0, 6.0, 0.8);
let m2 = make_machine(1, 4.0, 0.4);
let sim = MultiMachineSim::two_machine(m1, m2, 0.3);
let n = sim.machines.len();
let y_fault = vec![vec![Complex64::new(0.0, 0.0); n]; n];
let y_post = sim.y_red.clone();
let t_clear = 0.1;
let fault_fn = |t: f64| -> Option<Vec<Vec<Complex64>>> {
if t <= t_clear {
Some(y_fault.clone())
} else {
Some(y_post.clone())
}
};
let initial = vec![
MachineState::equilibrium(0.4),
MachineState::equilibrium(-0.2),
];
let result = sim.run(initial, 0.005, 1.0, Some(&fault_fn));
assert!(!result.snapshots.is_empty());
let delta_at_fault = result.snapshots[10].states[0].delta;
assert!(
delta_at_fault >= 0.4,
"Machine 0 should accelerate during fault: δ={:.4}",
delta_at_fault
);
}
#[test]
fn test_is_transient_stable_equilibrium() {
let m1 = make_machine(0, 6.0, 0.5);
let m2 = make_machine(1, 6.0, 0.5);
let sim = MultiMachineSim::two_machine(m1, m2, 0.3);
let states = vec![
MachineState::equilibrium(0.2),
MachineState::equilibrium(-0.2),
];
let result = sim.run(states, 0.01, 2.0, None);
let stable = MultiMachineSim::is_transient_stable(&result);
let _ = stable; }
#[test]
fn test_angle_spread_increases_during_fault() {
let m1 = make_machine(0, 6.0, 0.9); let m2 = make_machine(1, 6.0, 0.1);
let sim = MultiMachineSim::two_machine(m1, m2, 0.3);
let n = sim.machines.len();
let y_fault = vec![vec![Complex64::new(0.0, 0.0); n]; n];
let fault_fn = |_t: f64| -> Option<Vec<Vec<Complex64>>> { Some(y_fault.clone()) };
let initial = vec![
MachineState::equilibrium(0.3),
MachineState::equilibrium(-0.1),
];
let result = sim.run(initial, 0.005, 0.5, Some(&fault_fn));
let spread = result.max_angle_spread_deg();
assert!(
spread > 10.0,
"Angle spread during sustained fault should grow: {:.2}°",
spread
);
}
}