use scirs2_core::ndarray::{Array1, Array2, ArrayView2};
use crate::error::{LinalgError, LinalgResult};
fn build_shifted_real_block(a: &ArrayView2<f64>, re: f64, im: f64) -> Array2<f64> {
let n = a.nrows();
let mut block = Array2::<f64>::zeros((2 * n, 2 * n));
for i in 0..n {
for j in 0..n {
block[[i, j]] = if i == j { re - a[[i, j]] } else { -a[[i, j]] };
}
}
for i in 0..n {
block[[i, i + n]] = -im;
}
for i in 0..n {
block[[i + n, i]] = im;
}
for i in 0..n {
for j in 0..n {
block[[i + n, j + n]] = if i == j { re - a[[i, j]] } else { -a[[i, j]] };
}
}
block
}
fn sigma_min_at_point(a: &ArrayView2<f64>, re: f64, im: f64) -> LinalgResult<f64> {
let block = build_shifted_real_block(a, re, im);
let (_, s, _) = crate::decomposition::svd(&block.view(), false, None)?;
Ok(s[s.len() - 1])
}
#[derive(Debug, Clone)]
pub struct PseudospectrumGrid {
pub re_grid: Array1<f64>,
pub im_grid: Array1<f64>,
pub sigma_min: Array2<f64>,
pub inside: Array2<bool>,
pub epsilon: f64,
}
pub fn epsilon_pseudospectrum(
a: &ArrayView2<f64>,
re_range: (f64, f64),
im_range: (f64, f64),
n_re: usize,
n_im: usize,
epsilon: f64,
) -> LinalgResult<PseudospectrumGrid> {
let n = a.nrows();
if n != a.ncols() {
return Err(LinalgError::ShapeError(format!(
"epsilon_pseudospectrum: A must be square, got {}×{}",
n,
a.ncols()
)));
}
if n == 0 {
return Err(LinalgError::ShapeError(
"epsilon_pseudospectrum: empty matrix".to_string(),
));
}
if n_re < 2 || n_im < 2 {
return Err(LinalgError::ValueError(
"epsilon_pseudospectrum: grid must have at least 2 points in each direction".to_string(),
));
}
if epsilon <= 0.0 {
return Err(LinalgError::ValueError(format!(
"epsilon_pseudospectrum: epsilon must be positive, got {}",
epsilon
)));
}
if re_range.0 >= re_range.1 {
return Err(LinalgError::ValueError(
"epsilon_pseudospectrum: re_range min must be < max".to_string(),
));
}
if im_range.0 >= im_range.1 {
return Err(LinalgError::ValueError(
"epsilon_pseudospectrum: im_range min must be < max".to_string(),
));
}
let re_step = (re_range.1 - re_range.0) / (n_re as f64 - 1.0);
let im_step = (im_range.1 - im_range.0) / (n_im as f64 - 1.0);
let re_grid: Array1<f64> =
Array1::from_iter((0..n_re).map(|k| re_range.0 + k as f64 * re_step));
let im_grid: Array1<f64> =
Array1::from_iter((0..n_im).map(|k| im_range.0 + k as f64 * im_step));
let mut sigma_min = Array2::<f64>::zeros((n_im, n_re));
let mut inside = Array2::<bool>::default((n_im, n_re));
for i in 0..n_im {
for j in 0..n_re {
let re = re_grid[j];
let im = im_grid[i];
let sm = sigma_min_at_point(a, re, im)?;
sigma_min[[i, j]] = sm;
inside[[i, j]] = sm < epsilon;
}
}
Ok(PseudospectrumGrid {
re_grid,
im_grid,
sigma_min,
inside,
epsilon,
})
}
#[derive(Debug, Clone)]
pub struct KreissResult {
pub kreiss_lower_bound: f64,
pub maximising_point: (f64, f64, f64, f64),
pub grid_evaluations: usize,
}
pub fn kreiss_constant(
a: &ArrayView2<f64>,
re_max: Option<f64>,
n_re: Option<usize>,
n_im: Option<usize>,
) -> LinalgResult<KreissResult> {
let n = a.nrows();
if n != a.ncols() {
return Err(LinalgError::ShapeError(format!(
"kreiss_constant: A must be square, got {}×{}",
n,
a.ncols()
)));
}
if n == 0 {
return Err(LinalgError::ShapeError(
"kreiss_constant: empty matrix".to_string(),
));
}
let nr = n_re.unwrap_or(30);
let ni = n_im.unwrap_or(60);
let spectral_abscissa = compute_spectral_abscissa(a)?;
let re_upper = re_max.unwrap_or(5.0 * spectral_abscissa.abs() + 1.0).max(1.0);
let re_min = 1e-4; let im_half = re_upper * 2.0;
let re_step = (re_upper - re_min) / (nr as f64 - 1.0).max(1.0);
let im_step = 2.0 * im_half / (ni as f64 - 1.0).max(1.0);
let mut best_value = 1.0_f64; let mut best_point = (0.0_f64, re_min, 0.0, 1.0);
let mut evaluations = 0_usize;
for i in 0..ni {
let im = -im_half + i as f64 * im_step;
for j in 0..nr {
let re = re_min + j as f64 * re_step;
match sigma_min_at_point(a, re, im) {
Ok(sm) => {
evaluations += 1;
if sm > 0.0 {
let resolvent_norm = 1.0 / sm;
let kreiss_estimate = re * resolvent_norm;
if kreiss_estimate > best_value {
best_value = kreiss_estimate;
best_point = (sm, re, im, resolvent_norm);
}
}
}
Err(_) => { }
}
}
}
Ok(KreissResult {
kreiss_lower_bound: best_value,
maximising_point: best_point,
grid_evaluations: evaluations,
})
}
#[derive(Debug, Clone)]
pub struct PseudospectralAbscissaResult {
pub abscissa: f64,
pub im_at_abscissa: f64,
pub spectral_abscissa: f64,
pub stability_margin: f64,
}
pub fn pseudospectral_abscissa(
a: &ArrayView2<f64>,
epsilon: f64,
n_im: Option<usize>,
) -> LinalgResult<PseudospectralAbscissaResult> {
let n = a.nrows();
if n != a.ncols() {
return Err(LinalgError::ShapeError(format!(
"pseudospectral_abscissa: A must be square, got {}×{}",
n,
a.ncols()
)));
}
if n == 0 {
return Err(LinalgError::ShapeError(
"pseudospectral_abscissa: empty matrix".to_string(),
));
}
if epsilon <= 0.0 {
return Err(LinalgError::ValueError(format!(
"pseudospectral_abscissa: epsilon must be positive, got {}",
epsilon
)));
}
let ni = n_im.unwrap_or(200);
let spectral_abscissa = compute_spectral_abscissa(a)?;
let frobenius_norm_a = frob_norm_view(a);
let im_range = frobenius_norm_a + epsilon + 10.0;
let re_range_low = spectral_abscissa - 5.0 * epsilon - frobenius_norm_a;
let re_range_high = spectral_abscissa + 5.0 * epsilon + 1.0;
let im_step = 2.0 * im_range / (ni as f64 - 1.0).max(1.0);
let mut best_re = spectral_abscissa; let mut best_im = 0.0;
for i in 0..ni {
let im = -im_range + i as f64 * im_step;
let mut lo = re_range_low;
let mut hi = re_range_high;
let sm_hi = match sigma_min_at_point(a, hi, im) {
Ok(v) => v,
Err(_) => continue,
};
if sm_hi >= epsilon {
continue;
}
for _ in 0..60 {
let mid = (lo + hi) / 2.0;
let sm_mid = match sigma_min_at_point(a, mid, im) {
Ok(v) => v,
Err(_) => break,
};
if sm_mid < epsilon {
lo = mid; } else {
hi = mid; }
}
if lo > best_re {
best_re = lo;
best_im = im;
}
}
let stability_margin = -best_re;
Ok(PseudospectralAbscissaResult {
abscissa: best_re,
im_at_abscissa: best_im,
spectral_abscissa,
stability_margin,
})
}
#[derive(Debug, Clone)]
pub struct TransientBoundResult {
pub kreiss_lower_bound: f64,
pub upper_bound: f64,
pub n: usize,
pub time_horizon: f64,
}
pub fn transient_bound(
a: &ArrayView2<f64>,
time_horizon: Option<f64>,
n_re: Option<usize>,
n_im: Option<usize>,
) -> LinalgResult<TransientBoundResult> {
let n = a.nrows();
if n != a.ncols() {
return Err(LinalgError::ShapeError(format!(
"transient_bound: A must be square, got {}×{}",
n,
a.ncols()
)));
}
if n == 0 {
return Err(LinalgError::ShapeError(
"transient_bound: empty matrix".to_string(),
));
}
let t = time_horizon.unwrap_or(1.0);
let kreiss_res = kreiss_constant(a, None, n_re, n_im)?;
let k = kreiss_res.kreiss_lower_bound;
let alpha = compute_spectral_abscissa(a)?;
let e_const = std::f64::consts::E;
let upper_bound = (alpha * t).exp() * e_const * (n as f64) * k;
Ok(TransientBoundResult {
kreiss_lower_bound: k,
upper_bound,
n,
time_horizon: t,
})
}
pub(crate) fn compute_spectral_abscissa(a: &ArrayView2<f64>) -> LinalgResult<f64> {
use crate::eigen::eig;
let (eigenvalues, _eigenvectors) = eig(a, None)?;
let max_re = eigenvalues
.iter()
.map(|z| z.re)
.fold(f64::NEG_INFINITY, f64::max);
Ok(max_re)
}
fn frob_norm_view(a: &ArrayView2<f64>) -> f64 {
a.iter().map(|&v| v * v).sum::<f64>().sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
#[test]
fn test_pseudospectrum_grid_shape() {
let a = array![[0.0_f64, 1.0], [-1.0, 0.0]];
let grid = epsilon_pseudospectrum(
&a.view(), (-2.0, 2.0), (-2.0, 2.0), 8, 6, 0.5,
)
.expect("failed");
assert_eq!(grid.sigma_min.shape(), &[6, 8]);
assert_eq!(grid.inside.shape(), &[6, 8]);
assert_eq!(grid.re_grid.len(), 8);
assert_eq!(grid.im_grid.len(), 6);
}
#[test]
fn test_pseudospectrum_identity_inside_at_eigenvalue() {
let a = array![[2.0_f64, 0.0], [0.0, 2.0]];
let sm = sigma_min_at_point(&a.view(), 2.0, 0.0).expect("failed");
assert!(sm < 1e-10, "σ_min at eigenvalue should be ~0, got {}", sm);
}
#[test]
fn test_pseudospectrum_far_from_spectrum_outside() {
let a = array![[0.0_f64, 0.0], [0.0, 0.0]];
let sm = sigma_min_at_point(&a.view(), 100.0, 0.0).expect("failed");
assert!((sm - 100.0).abs() < 1e-6, "σ_min = {}", sm);
}
#[test]
fn test_kreiss_constant_normal_matrix() {
let a = array![[0.0_f64, -1.0], [1.0, 0.0]];
let res = kreiss_constant(&a.view(), None, Some(15), Some(30)).expect("failed");
assert!(
res.kreiss_lower_bound >= 1.0 - 1e-6,
"K ≥ 1 always, got {}",
res.kreiss_lower_bound
);
}
#[test]
fn test_kreiss_constant_positive_evaluations() {
let a = array![[1.0_f64, 0.0], [0.0, -1.0]];
let res = kreiss_constant(&a.view(), None, Some(10), Some(20)).expect("failed");
assert!(res.grid_evaluations > 0);
}
#[test]
fn test_pseudospectral_abscissa_stable() {
let a = array![[-1.0_f64, 10.0], [0.0, -2.0]];
let res = pseudospectral_abscissa(&a.view(), 0.1, Some(40)).expect("failed");
assert!(
res.abscissa >= res.spectral_abscissa - 1e-6,
"abscissa={}, spectral={}",
res.abscissa, res.spectral_abscissa
);
}
#[test]
fn test_pseudospectral_abscissa_large_epsilon() {
let a = array![[-5.0_f64, 0.0], [0.0, -5.0]];
let res = pseudospectral_abscissa(&a.view(), 1.0, Some(30)).expect("failed");
assert!(res.abscissa >= res.spectral_abscissa - 1e-6);
}
#[test]
fn test_transient_bound_stable_diagonal() {
let a = array![[-1.0_f64, 0.0], [0.0, -2.0]];
let res = transient_bound(&a.view(), Some(0.0), Some(15), Some(30)).expect("failed");
assert!(res.upper_bound >= 1.0, "bound = {}", res.upper_bound);
assert_eq!(res.n, 2);
assert_eq!(res.time_horizon, 0.0);
}
#[test]
fn test_transient_bound_dimension_matches() {
let n = 3;
let mut a = Array2::<f64>::zeros((n, n));
a[[0, 0]] = -1.0;
a[[1, 1]] = -2.0;
a[[2, 2]] = -3.0;
let res = transient_bound(&a.view(), Some(1.0), Some(15), Some(30)).expect("failed");
assert_eq!(res.n, n);
}
}