use faer::Mat;
use faer::linalg::solvers::Solve;
use num_complex::Complex;
use crate::sparse::RealFactorization;
use super::arnoldi::{self, ArnoldiOptions, RitzPair};
use super::shift::ShiftedDae;
use super::DynamicSystem;
#[derive(Clone, Debug, PartialEq)]
pub struct Mode {
pub eigenvalue: Complex<f64>,
pub damping: f64,
pub frequency: f64,
pub time_constant: f64,
pub participation: Vec<(usize, f64)>,
pub shape: Vec<(usize, Complex<f64>)>,
pub residual: f64,
pub eigenvector: Vec<Complex<f64>>,
pub left_eigenvector: Vec<Complex<f64>>,
}
impl Mode {
pub fn is_unstable(&self) -> bool {
self.eigenvalue.re > 0.0
}
pub fn is_oscillatory(&self) -> bool {
self.frequency > 0.0
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Method {
Dense,
Sparse {
shift: Complex<f64>,
restarts: usize,
},
}
impl std::fmt::Display for Method {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Method::Dense => write!(f, "dense (every mode)"),
Method::Sparse { shift, restarts } => write!(
f,
"sparse Arnoldi near {:+.4}{:+.4}j, {restarts} restart(s)",
shift.re, shift.im
),
}
}
}
#[derive(Clone, Debug)]
pub struct SmallSignal {
pub modes: Vec<Mode>,
pub state_names: Vec<String>,
pub method: Method,
pub converged: bool,
}
impl SmallSignal {
pub fn critical(&self) -> Option<&Mode> {
self.modes.first()
}
pub fn unstable(&self) -> Vec<&Mode> {
self.modes.iter().filter(|m| m.is_unstable()).collect()
}
pub fn participants(&self, mode: &Mode) -> Vec<(String, f64)> {
mode.participation
.iter()
.map(|&(k, p)| (self.state_names[k].clone(), p))
.collect()
}
pub fn shape(&self, mode: &Mode) -> Vec<(String, f64, f64)> {
mode.shape
.iter()
.map(|&(k, c)| (self.state_names[k].clone(), c.norm(), c.arg().to_degrees()))
.collect()
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum SmallSignalError {
NoStates,
NotAnEquilibrium { max_derivative: f64 },
SingularNetwork,
Eigen,
TooLarge { states: usize, limit: usize },
Shift(super::shift::ShiftError),
Arnoldi(super::arnoldi::ArnoldiError),
NoLeftEigenvector,
DefectiveMode,
NoSuchParameter(ParameterRef),
}
impl std::fmt::Display for SmallSignalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SmallSignalError::NoStates => write!(f, "the system has no differential states"),
SmallSignalError::NotAnEquilibrium { max_derivative } => write!(
f,
"not at an equilibrium (largest state derivative {max_derivative:.3e}); \
a linearization about a point the system is not sitting at describes nothing"
),
SmallSignalError::SingularNetwork => {
write!(
f,
"the algebraic block is singular, so the network cannot be eliminated"
)
}
SmallSignalError::Eigen => write!(f, "the eigen-decomposition did not converge"),
SmallSignalError::TooLarge { states, limit } => write!(
f,
"{states} states is past the {limit} the dense method is honest about; \
a system this size wants sparse Arnoldi targeting a region of the complex \
plane, which is what `analyze_near` does"
),
SmallSignalError::Shift(e) => write!(f, "{e}"),
SmallSignalError::Arnoldi(e) => write!(f, "{e}"),
SmallSignalError::NoLeftEigenvector => write!(
f,
"this mode carries no left eigenvector, so it has no sensitivity — the sparse \
method could not pair it unambiguously"
),
SmallSignalError::DefectiveMode => write!(
f,
"the mode is defective (wᴴu = 0), so its eigenvalue derivative is unbounded \
rather than large"
),
SmallSignalError::NoSuchParameter(p) => write!(
f,
"device {} has no tunable parameter {:?}",
p.device, p.name
),
}
}
}
impl From<super::shift::ShiftError> for SmallSignalError {
fn from(e: super::shift::ShiftError) -> Self {
SmallSignalError::Shift(e)
}
}
impl From<super::arnoldi::ArnoldiError> for SmallSignalError {
fn from(e: super::arnoldi::ArnoldiError) -> Self {
SmallSignalError::Arnoldi(e)
}
}
impl std::error::Error for SmallSignalError {}
const EQUILIBRIUM_TOL: f64 = 1e-6;
const MAX_DENSE_STATES: usize = 2000;
#[derive(Clone, Copy, Debug)]
pub struct SmallSignalOptions {
pub shift: Complex<f64>,
pub count: usize,
pub max_dim: usize,
pub tol: f64,
pub max_restarts: usize,
}
impl Default for SmallSignalOptions {
fn default() -> Self {
Self::near_frequency(1.0, 0.05)
}
}
impl SmallSignalOptions {
pub fn near_frequency(hz: f64, zeta: f64) -> Self {
let omega_d = std::f64::consts::TAU * hz;
let zeta = zeta.clamp(0.0, 0.999);
let sigma = -zeta * omega_d / (1.0 - zeta * zeta).sqrt();
Self {
shift: Complex::new(sigma, omega_d),
count: 6,
max_dim: 0,
tol: 1e-10,
max_restarts: 20,
}
}
pub fn count(mut self, count: usize) -> Self {
self.count = count;
self
}
}
pub(crate) fn fill_at_equilibrium(system: &DynamicSystem) -> Vec<f64> {
let layout = system.pattern.layout();
let mut scratch: Vec<super::models::ModelJacobian> = (0..layout.n_devices())
.map(|d| super::models::ModelJacobian::zeros(layout.dev_len[d]))
.collect();
let mut values: Vec<f64> = Vec::with_capacity(system.pattern.nnz());
system.latch(&system.x0, &system.v0);
system.pattern.fill(
&system.models,
&system.x0,
&system.v0,
1.0,
&system.ybus,
&mut scratch,
&mut values,
);
values
}
pub(crate) fn reduced_state_matrix(
system: &DynamicSystem,
) -> Result<(usize, Vec<f64>), SmallSignalError> {
let layout = system.pattern.layout().clone();
let (n_x, n_net) = (layout.n_diff, 2 * layout.n_bus);
if n_x == 0 {
return Err(SmallSignalError::NoStates);
}
let drift = system.max_derivative();
if drift > EQUILIBRIUM_TOL {
return Err(SmallSignalError::NotAnEquilibrium {
max_derivative: drift,
});
}
if n_x > MAX_DENSE_STATES {
return Err(SmallSignalError::TooLarge {
states: n_x,
limit: MAX_DENSE_STATES,
});
}
let values = fill_at_equilibrium(system);
let triplets = system.pattern.to_triplets(&values);
let mut a_x = vec![0.0; n_x * n_x];
let mut a_v = vec![0.0; n_x * n_net];
let mut c_x = vec![0.0; n_net * n_x];
let mut c_v: Vec<(usize, usize, f64)> = Vec::new();
for (r, c, value) in triplets {
match (r < n_x, c < n_x) {
(true, true) => {
let identity = if r == c { 1.0 } else { 0.0 };
a_x[r * n_x + c] = identity - value;
}
(true, false) => a_v[r * n_net + (c - n_x)] = -value,
(false, true) => c_x[(r - n_x) * n_x + c] = value,
(false, false) => c_v.push((r - n_x, c - n_x, value)),
}
}
let network = RealFactorization::new(n_net, &c_v).ok_or(SmallSignalError::SingularNetwork)?;
let mut z = vec![0.0; n_net * n_x];
let mut column = vec![0.0; n_net];
for k in 0..n_x {
for row in 0..n_net {
column[row] = c_x[row * n_x + k];
}
let solved = network
.solve(&column)
.ok_or(SmallSignalError::SingularNetwork)?;
for row in 0..n_net {
z[row * n_x + k] = solved[row];
}
}
let mut a = vec![0.0; n_x * n_x];
for i in 0..n_x {
for j in 0..n_x {
let mut acc = a_x[i * n_x + j];
for m in 0..n_net {
acc -= a_v[i * n_net + m] * z[m * n_x + j];
}
a[i * n_x + j] = acc;
}
}
Ok((n_x, a))
}
pub fn state_matrix(system: &DynamicSystem) -> Result<Vec<Vec<f64>>, SmallSignalError> {
let (n_x, flat) = reduced_state_matrix(system)?;
Ok(flat.chunks(n_x).map(|row| row.to_vec()).collect())
}
pub fn analyze(system: &DynamicSystem) -> Result<SmallSignal, SmallSignalError> {
if system.pattern.layout().n_diff > MAX_DENSE_STATES {
return analyze_near(system, &SmallSignalOptions::default());
}
let (n_x, flat) = reduced_state_matrix(system)?;
let a = Mat::<f64>::from_fn(n_x, n_x, |i, j| flat[i * n_x + j]);
let angle_states = angle_states(system);
let eigen = a.eigen().map_err(|_| SmallSignalError::Eigen)?;
let u = eigen.U();
let s = eigen.S();
let mut identity = Mat::<faer::c64>::zeros(n_x, n_x);
for i in 0..n_x {
identity[(i, i)] = faer::c64::new(1.0, 0.0);
}
let w = u.partial_piv_lu().solve(&identity);
let mut modes = Vec::with_capacity(n_x);
for i in 0..n_x {
let lambda = Complex::new(s[i].re, s[i].im);
let right: Vec<Complex<f64>> = (0..n_x)
.map(|k| Complex::new(u[(k, i)].re, u[(k, i)].im))
.collect();
let left_row: Vec<Complex<f64>> = (0..n_x)
.map(|k| Complex::new(w[(i, k)].re, w[(i, k)].im))
.collect();
modes.push(mode_from(
lambda,
participation_of(&right, &left_row),
shape_of(is_oscillatory(lambda), &right, &angle_states),
0.0,
normalized(right),
normalized(left_row.iter().map(|c| c.conj()).collect()),
));
}
sort_by_damping(&mut modes);
Ok(SmallSignal {
modes,
state_names: system.differential_state_names(),
method: Method::Dense,
converged: true,
})
}
pub fn analyze_near(
system: &DynamicSystem,
opts: &SmallSignalOptions,
) -> Result<SmallSignal, SmallSignalError> {
let n_x = system.pattern.layout().n_diff;
if n_x == 0 {
return Err(SmallSignalError::NoStates);
}
let drift = system.max_derivative();
if drift > EQUILIBRIUM_TOL {
return Err(SmallSignalError::NotAnEquilibrium {
max_derivative: drift,
});
}
let sigma = opts.shift;
let op = ShiftedDae::new(system, sigma)?;
let arnoldi_opts = ArnoldiOptions {
count: opts.count,
max_dim: opts.max_dim,
tol: opts.tol,
max_restarts: opts.max_restarts,
};
let right = arnoldi::eigenpairs(n_x, |b| op.apply(b), &arnoldi_opts)?;
let left = arnoldi::eigenpairs(n_x, |b| op.apply_adjoint(b), &arnoldi_opts)?;
let angle_states = angle_states(system);
let mut modes = Vec::with_capacity(right.pairs.len());
for pair in &right.pairs {
let lambda = sigma + 1.0 / pair.theta;
let (participation, left_vector) = match matching_left(&left.pairs, pair.theta) {
Some(z) => {
let left_row: Vec<Complex<f64>> = z.iter().map(|c| c.conj()).collect();
(
participation_of(&pair.vector, &left_row),
normalized(z.to_vec()),
)
}
None => (Vec::new(), Vec::new()),
};
modes.push(mode_from(
lambda,
participation,
shape_of(is_oscillatory(lambda), &pair.vector, &angle_states),
pair.residual,
normalized(pair.vector.clone()),
left_vector,
));
}
sort_by_damping(&mut modes);
Ok(SmallSignal {
modes,
state_names: system.differential_state_names(),
method: Method::Sparse {
shift: sigma,
restarts: right.restarts,
},
converged: right.converged(),
})
}
const PAIRING_BOUND: f64 = 1e-2;
const PAIRING_MARGIN: f64 = 10.0;
fn matching_left(pairs: &[RitzPair], theta: Complex<f64>) -> Option<&[Complex<f64>]> {
let target = theta.conj();
let scale = theta.norm().max(f64::MIN_POSITIVE);
let mut ranked: Vec<(f64, &RitzPair)> = pairs
.iter()
.map(|p| ((p.theta - target).norm() / scale, p))
.collect();
ranked.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let (best, pair) = *ranked.first()?;
if best > PAIRING_BOUND {
return None;
}
if let Some(&(second, _)) = ranked.get(1)
&& second < best * PAIRING_MARGIN
{
return None;
}
Some(&pair.vector)
}
fn angle_states(system: &DynamicSystem) -> Vec<usize> {
let layout = system.pattern.layout();
(0..layout.n_devices())
.filter_map(|d| {
let offset = layout.dev_offset[d];
system.models[d].angle_index().map(|k| offset + k)
})
.collect()
}
fn participation_of(right: &[Complex<f64>], left_row: &[Complex<f64>]) -> Vec<(usize, f64)> {
let raw: Vec<f64> = right
.iter()
.zip(left_row)
.map(|(u, w)| (u * w).norm())
.collect();
let total: f64 = raw.iter().sum();
let mut participation: Vec<(usize, f64)> = raw
.iter()
.enumerate()
.map(|(k, p)| (k, if total > 0.0 { p / total } else { 0.0 }))
.collect();
participation.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
participation.retain(|&(_, p)| p > 1e-6);
participation
}
const NEGLIGIBLE: f64 = 1e-9;
fn shape_of(
oscillatory: bool,
right: &[Complex<f64>],
angle_states: &[usize],
) -> Vec<(usize, Complex<f64>)> {
if !oscillatory {
return Vec::new();
}
let raw: Vec<(usize, Complex<f64>)> = angle_states.iter().map(|&k| (k, right[k])).collect();
match raw
.iter()
.max_by(|a, b| {
a.1.norm()
.partial_cmp(&b.1.norm())
.unwrap_or(std::cmp::Ordering::Equal)
})
.filter(|(_, c)| c.norm() > 0.0)
{
Some(&(_, pivot)) => raw.into_iter().map(|(k, c)| (k, c / pivot)).collect(),
None => Vec::new(),
}
}
fn normalized(mut vector: Vec<Complex<f64>>) -> Vec<Complex<f64>> {
let scale = vector.iter().map(|c| c.norm_sqr()).sum::<f64>().sqrt();
if scale > 0.0 {
for value in &mut vector {
*value /= scale;
}
}
vector
}
fn is_oscillatory(lambda: Complex<f64>) -> bool {
lambda.im.abs() > NEGLIGIBLE * lambda.norm()
}
fn mode_from(
lambda: Complex<f64>,
participation: Vec<(usize, f64)>,
shape: Vec<(usize, Complex<f64>)>,
residual: f64,
eigenvector: Vec<Complex<f64>>,
left_eigenvector: Vec<Complex<f64>>,
) -> Mode {
let magnitude = lambda.norm();
let decaying = lambda.re.abs() > NEGLIGIBLE * magnitude;
Mode {
eigenvalue: lambda,
damping: if magnitude > 0.0 && decaying {
-lambda.re / magnitude
} else {
0.0
},
frequency: if is_oscillatory(lambda) {
lambda.im.abs() / std::f64::consts::TAU
} else {
0.0
},
time_constant: if decaying {
-1.0 / lambda.re
} else {
f64::INFINITY
},
participation,
shape,
residual,
eigenvector,
left_eigenvector,
}
}
fn sort_by_damping(modes: &mut [Mode]) {
modes.sort_by(|a, b| {
a.damping
.partial_cmp(&b.damping)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
#[derive(Clone, Debug, PartialEq)]
pub struct ParameterRef {
pub device: usize,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct ParameterSensitivity {
pub parameter: ParameterRef,
pub value: f64,
pub d_eigenvalue: Complex<f64>,
pub d_damping: f64,
pub d_frequency: f64,
}
const DIFFERENCE_STEP: f64 = 1e-5;
pub fn sensitivities(
system: &mut DynamicSystem,
mode: &Mode,
parameters: &[ParameterRef],
) -> Result<Vec<ParameterSensitivity>, SmallSignalError> {
let layout = system.pattern.layout().clone();
let (n_x, n_net) = (layout.n_diff, 2 * layout.n_bus);
if n_x == 0 {
return Err(SmallSignalError::NoStates);
}
if mode.eigenvector.len() != n_x || mode.left_eigenvector.len() != n_x {
return Err(SmallSignalError::NoLeftEigenvector);
}
let base = fill_at_equilibrium(system);
let triplets = system.pattern.to_triplets(&base);
let c_v: Vec<(usize, usize, f64)> = triplets
.iter()
.filter(|&&(r, c, _)| r >= n_x && c >= n_x)
.map(|&(r, c, value)| (r - n_x, c - n_x, value))
.collect();
let network = RealFactorization::new(n_net, &c_v).ok_or(SmallSignalError::SingularNetwork)?;
let u = &mode.eigenvector;
let w_x = &mode.left_eigenvector;
let mut c_x_u = vec![Complex::new(0.0, 0.0); n_net];
for &(r, c, value) in &triplets {
if r >= n_x && c < n_x {
c_x_u[r - n_x] += value * u[c];
}
}
let v = solve_complex_real(&network, &c_x_u, false)
.ok_or(SmallSignalError::SingularNetwork)?
.into_iter()
.map(|value| -value)
.collect::<Vec<_>>();
let mut a_v_t_w = vec![Complex::new(0.0, 0.0); n_net];
for &(r, c, value) in &triplets {
if r < n_x && c >= n_x {
a_v_t_w[c - n_x] += -value * w_x[r];
}
}
let w_v = solve_complex_real(&network, &a_v_t_w, true)
.ok_or(SmallSignalError::SingularNetwork)?
.into_iter()
.map(|value| -value)
.collect::<Vec<_>>();
let denominator: Complex<f64> = w_x.iter().zip(u).map(|(w, u)| w.conj() * u).sum();
if denominator.norm() < 1e-12 {
return Err(SmallSignalError::DefectiveMode);
}
let mut out = Vec::with_capacity(parameters.len());
let mut perturbed = Vec::with_capacity(base.len());
for reference in parameters {
let device = system
.models
.get(reference.device)
.ok_or_else(|| SmallSignalError::NoSuchParameter(reference.clone()))?;
let value = device
.parameter(&reference.name)
.ok_or_else(|| SmallSignalError::NoSuchParameter(reference.clone()))?;
let step = DIFFERENCE_STEP * value.abs().max(1.0);
let mut difference = vec![0.0; base.len()];
for (sign, weight) in [(1.0, 0.5), (-1.0, -0.5)] {
let model = &mut system.models[reference.device];
let previous = model
.set_parameter(&reference.name, value + sign * step)
.ok_or_else(|| SmallSignalError::NoSuchParameter(reference.clone()))?;
perturbed.clear();
perturbed.extend_from_slice(&fill_at_equilibrium(system));
system.models[reference.device].set_parameter(&reference.name, previous);
for (slot, value) in difference.iter_mut().zip(&perturbed) {
*slot += weight * value / step;
}
}
let mut numerator = Complex::new(0.0, 0.0);
for (k, &(r, c, _)) in triplets.iter().enumerate() {
let entry = if r < n_x {
-difference[k]
} else {
difference[k]
};
if entry == 0.0 {
continue;
}
let left = if r < n_x { w_x[r] } else { w_v[r - n_x] };
let right = if c < n_x { u[c] } else { v[c - n_x] };
numerator += left.conj() * entry * right;
}
let d_eigenvalue = numerator / denominator;
out.push(ParameterSensitivity {
parameter: reference.clone(),
value,
d_eigenvalue,
d_damping: damping_derivative(mode.eigenvalue, d_eigenvalue),
d_frequency: frequency_derivative(mode.eigenvalue, d_eigenvalue),
});
}
Ok(out)
}
pub fn tunable_parameters(system: &DynamicSystem) -> Vec<ParameterRef> {
system
.models
.iter()
.enumerate()
.flat_map(|(device, model)| {
model.tunable().iter().map(move |name| ParameterRef {
device,
name: (*name).to_string(),
})
})
.collect()
}
fn solve_complex_real(
network: &RealFactorization,
rhs: &[Complex<f64>],
transpose: bool,
) -> Option<Vec<Complex<f64>>> {
let real: Vec<f64> = rhs.iter().map(|c| c.re).collect();
let imaginary: Vec<f64> = rhs.iter().map(|c| c.im).collect();
let (real, imaginary) = if transpose {
(
network.solve_transpose(&real)?,
network.solve_transpose(&imaginary)?,
)
} else {
(network.solve(&real)?, network.solve(&imaginary)?)
};
Some(
real.into_iter()
.zip(imaginary)
.map(|(re, im)| Complex::new(re, im))
.collect(),
)
}
fn damping_derivative(lambda: Complex<f64>, d_lambda: Complex<f64>) -> f64 {
let magnitude = lambda.norm();
if magnitude == 0.0 {
return 0.0;
}
let cube = magnitude * magnitude * magnitude;
let d_sigma = -(lambda.im * lambda.im) / cube;
let d_omega = (lambda.re * lambda.im) / cube;
d_sigma * d_lambda.re + d_omega * d_lambda.im
}
fn frequency_derivative(lambda: Complex<f64>, d_lambda: Complex<f64>) -> f64 {
if !is_oscillatory(lambda) {
return 0.0;
}
let sign = if lambda.im >= 0.0 { 1.0 } else { -1.0 };
sign * d_lambda.im / std::f64::consts::TAU
}