use crate::error::Result;
use crate::network::PowerNetwork;
use crate::powerflow::{PowerFlowConfig, PowerFlowMethod};
use num_complex::Complex64;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct CurvePoint {
pub x: f64,
pub voltage_pu: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PvCurve {
pub bus_idx: usize,
pub points: Vec<CurvePoint>,
pub nose_idx: usize,
pub p_max_mw: f64,
pub v_nose_pu: f64,
pub margin_pct: f64,
}
impl PvCurve {
pub fn all_upper_voltage(&self) -> bool {
self.nose_idx + 1 >= self.points.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QvCurve {
pub bus_idx: usize,
pub points: Vec<CurvePoint>,
pub q_margin_mvar: f64,
pub v_critical_pu: f64,
}
pub fn compute_pv_curve(
base_network: &PowerNetwork,
monitor_bus: usize,
lambda_step: f64,
lambda_max: f64,
) -> Result<PvCurve> {
let config = PowerFlowConfig {
method: PowerFlowMethod::NewtonRaphson,
max_iter: 100,
tolerance: 1e-6,
enforce_q_limits: false,
};
let mut points = Vec::new();
let base_p_load: f64 = base_network.buses.iter().map(|b| b.pd.0).sum();
let mut nose_idx = 0;
let mut p_max = 0.0_f64;
let mut lambda = 0.0_f64;
while lambda <= lambda_max + 1e-9 {
let scaled = scale_network(base_network, lambda);
match scaled.solve_powerflow(&config) {
Ok(result) if result.converged => {
let v = result.voltage_magnitude[monitor_bus];
let p_total = base_p_load * (1.0 + lambda);
points.push(CurvePoint {
x: p_total,
voltage_pu: v,
});
if p_total > p_max {
p_max = p_total;
nose_idx = points.len() - 1;
}
}
_ => break, }
lambda += lambda_step;
}
let v_nose = if nose_idx < points.len() {
points[nose_idx].voltage_pu
} else {
1.0
};
let v_base = points.first().map(|p| p.voltage_pu).unwrap_or(1.0);
let margin_pct = if v_base > 1e-6 {
(v_base - v_nose) / v_base * 100.0
} else {
0.0
};
Ok(PvCurve {
bus_idx: monitor_bus,
points,
nose_idx,
p_max_mw: p_max,
v_nose_pu: v_nose,
margin_pct,
})
}
pub fn compute_qv_curve(
base_network: &PowerNetwork,
test_bus: usize,
q_step_mvar: f64,
q_range_mvar: f64,
) -> Result<QvCurve> {
let config = PowerFlowConfig {
method: PowerFlowMethod::NewtonRaphson,
max_iter: 100,
tolerance: 1e-6,
enforce_q_limits: false,
};
let mut points = Vec::new();
let mut q_inj = -q_range_mvar;
let mut q_min = f64::INFINITY;
let mut v_critical = 0.0_f64;
while q_inj <= q_range_mvar + 1e-9 {
let mut net = base_network.clone();
net.buses[test_bus].qd.0 -= q_inj;
match net.solve_powerflow(&config) {
Ok(result) if result.converged => {
let v = result.voltage_magnitude[test_bus];
points.push(CurvePoint {
x: q_inj,
voltage_pu: v,
});
if v < v_critical || v_critical < 1e-9 {
v_critical = v;
}
if q_inj < q_min {
q_min = q_inj;
}
}
_ => {}
}
q_inj += q_step_mvar;
}
let q_op = points
.iter()
.min_by(|a, b| {
a.x.abs()
.partial_cmp(&b.x.abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|p| p.x)
.unwrap_or(0.0);
let q_margin_mvar = q_op - q_min;
Ok(QvCurve {
bus_idx: test_bus,
points,
q_margin_mvar,
v_critical_pu: v_critical,
})
}
pub fn voltage_stability_index(result: &crate::powerflow::PowerFlowResult) -> f64 {
result
.voltage_magnitude
.iter()
.copied()
.fold(f64::INFINITY, f64::min)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LIndex {
pub l_per_bus: Vec<f64>,
pub l_max: f64,
pub critical_bus: usize,
pub near_collapse: bool,
}
impl LIndex {
pub fn severity(&self) -> &'static str {
if self.l_max > 0.9 {
"Critical"
} else if self.l_max > 0.7 {
"Warning"
} else if self.l_max > 0.5 {
"Moderate"
} else {
"Normal"
}
}
}
pub fn compute_l_index(
network: &PowerNetwork,
result: &crate::powerflow::PowerFlowResult,
) -> Result<LIndex> {
let n = network.buses.len();
if n == 0 {
return Err(crate::error::OxiGridError::InvalidNetwork(
"empty network".into(),
));
}
let v_mag = &result.voltage_magnitude;
let v_ang = &result.voltage_angle;
let y_bus = network
.admittance_matrix()
.map_err(|e| crate::error::OxiGridError::InvalidNetwork(format!("Y-bus: {e}")))?;
let n_ybus = y_bus.shape().0;
let mut l_values = vec![0.0f64; n];
for i in 0..n.min(n_ybus) {
let v_i = v_mag[i];
let q_i = if i < result.q_injected.len() {
result.q_injected[i].abs()
} else {
0.0
};
let b_ii = y_bus
.get(i, i)
.map(|y: &Complex64| y.im.abs())
.unwrap_or(0.1);
let l_raw = if b_ii > 1e-9 && v_i > 1e-9 {
q_i / (v_i * v_i * b_ii * network.base_mva)
} else {
0.0
};
let v_dev = (1.0 - v_i).abs();
l_values[i] = (l_raw + v_dev).min(1.0);
}
let angle_max = v_ang.iter().cloned().fold(0.0f64, f64::max).abs();
for i in 0..n.min(n_ybus) {
let angle_contrib = (v_ang[i].abs() / (angle_max.max(0.1) + 1.0)).min(0.3);
l_values[i] = (l_values[i] + angle_contrib).min(1.0);
}
let (critical_bus, &l_max) = l_values
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0.0));
Ok(LIndex {
l_per_bus: l_values,
l_max,
critical_bus,
near_collapse: l_max > 0.8,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VsmiResult {
pub vsmi_per_bus: Vec<f64>,
pub vsmi_min: f64,
pub weakest_bus: usize,
pub loadability_margin_pct: f64,
}
pub fn compute_vsmi(
network: &PowerNetwork,
result: &crate::powerflow::PowerFlowResult,
lambda_step: f64,
lambda_max: f64,
) -> Result<VsmiResult> {
let v_op = &result.voltage_magnitude;
let monitor_bus = 0;
let pv = compute_pv_curve(network, monitor_bus, lambda_step, lambda_max)?;
let v_nose = pv.v_nose_pu;
let loadability_pct = pv.margin_pct;
let v_collapse_est = v_nose.min(0.6 * v_op[monitor_bus]);
let vsmi_per_bus: Vec<f64> = v_op
.iter()
.map(|&v_i| {
let v_collapse_i = v_collapse_est * v_i / v_op[monitor_bus].max(0.01);
if v_i > 1e-9 {
((v_i - v_collapse_i) / v_i).clamp(0.0, 1.0)
} else {
0.0
}
})
.collect();
let (weakest_bus, &vsmi_min) = vsmi_per_bus
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0.0));
Ok(VsmiResult {
vsmi_per_bus,
vsmi_min,
weakest_bus,
loadability_margin_pct: loadability_pct,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CpfPoint {
pub lambda: f64,
pub voltage_magnitude: Vec<f64>,
pub voltage_angle: Vec<f64>,
pub tangent_norm: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CpfResult {
pub points: Vec<CpfPoint>,
pub lambda_max: f64,
pub v_nose: Vec<f64>,
pub nose_iterations: usize,
}
pub fn run_cpf(network: &PowerNetwork, step_size: f64, max_steps: usize) -> Result<CpfResult> {
let config = PowerFlowConfig {
method: PowerFlowMethod::NewtonRaphson,
max_iter: 100,
tolerance: 1e-6,
enforce_q_limits: false,
};
let mut points = Vec::new();
let mut lambda_prev = -step_size;
let mut v_prev: Option<Vec<f64>> = None;
for step in 0..max_steps {
let lambda = lambda_prev + step_size;
let scaled = scale_network(network, lambda);
match scaled.solve_powerflow(&config) {
Ok(result) if result.converged => {
let tangent_norm = if let Some(ref vp) = v_prev {
let dv: f64 = result
.voltage_magnitude
.iter()
.zip(vp.iter())
.map(|(v, vp)| (v - vp).powi(2))
.sum::<f64>()
.sqrt();
dv / step_size
} else {
0.0
};
let pt = CpfPoint {
lambda,
voltage_magnitude: result.voltage_magnitude.clone(),
voltage_angle: result.voltage_angle.clone(),
tangent_norm,
};
if step > 2 && tangent_norm > 5.0 {
points.push(pt);
break;
}
v_prev = Some(result.voltage_magnitude);
lambda_prev = lambda;
points.push(pt);
}
_ => break, }
}
let lambda_max = points.last().map(|p| p.lambda).unwrap_or(0.0);
let v_nose = points
.last()
.map(|p| p.voltage_magnitude.clone())
.unwrap_or_else(|| vec![1.0; network.buses.len()]);
let nose_iter = points.len();
Ok(CpfResult {
points,
lambda_max,
v_nose,
nose_iterations: nose_iter,
})
}
pub fn find_nose_lambda(network: &PowerNetwork, tol: f64, lambda_guess: f64) -> Result<f64> {
let config = PowerFlowConfig {
method: PowerFlowMethod::NewtonRaphson,
max_iter: 50,
tolerance: 1e-5,
enforce_q_limits: false,
};
let mut lo = 0.0_f64;
let mut hi = lambda_guess;
let converges = |lam: f64| -> bool {
let scaled = scale_network(network, lam);
scaled
.solve_powerflow(&config)
.map(|r| r.converged)
.unwrap_or(false)
};
while converges(hi) && hi < 10.0 {
hi *= 1.5;
}
if !converges(hi) && lo < hi {
for _ in 0..50 {
if hi - lo < tol {
break;
}
let mid = 0.5 * (lo + hi);
if converges(mid) {
lo = mid;
} else {
hi = mid;
}
}
}
Ok(lo)
}
fn scale_network(net: &PowerNetwork, lambda: f64) -> PowerNetwork {
let mut scaled = net.clone();
let factor = 1.0 + lambda;
for bus in &mut scaled.buses {
bus.pd.0 *= factor;
bus.qd.0 *= factor;
}
if let Ok(slack_idx) = scaled.slack_bus_index() {
for gen in &mut scaled.generators {
if gen.bus_id != slack_idx {
gen.pg *= factor;
}
}
}
scaled
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::PowerNetwork;
fn load_ieee14() -> PowerNetwork {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/ieee14.m");
PowerNetwork::from_matpower(path).expect("ieee14 parse")
}
#[test]
fn test_pv_curve_has_points() {
let net = load_ieee14();
let curve = compute_pv_curve(&net, 0, 0.1, 1.0).unwrap();
assert!(!curve.points.is_empty(), "PV curve should have points");
assert!(curve.p_max_mw > 0.0, "Max P should be positive");
}
#[test]
fn test_pv_curve_voltage_decreases() {
let net = load_ieee14();
let curve = compute_pv_curve(&net, 0, 0.05, 0.5).unwrap();
if curve.points.len() >= 2 {
let v_first = curve.points.first().unwrap().voltage_pu;
let v_last = curve.points.last().unwrap().voltage_pu;
assert!(
v_last <= v_first + 0.01,
"Voltage should not increase with load: {:.4} → {:.4}",
v_first,
v_last
);
}
}
#[test]
fn test_qv_curve_has_points() {
let net = load_ieee14();
let curve = compute_qv_curve(&net, 12, 5.0, 50.0).unwrap();
assert!(!curve.points.is_empty(), "QV curve should have points");
}
#[test]
fn test_voltage_stability_index_base_case() {
let net = load_ieee14();
let config = PowerFlowConfig::default();
let result = net.solve_powerflow(&config).unwrap();
let vsi = voltage_stability_index(&result);
assert!(
vsi > 0.5,
"Min voltage should be > 0.5 p.u. at base: {:.4}",
vsi
);
assert!(vsi <= 1.1, "Min voltage should be ≤ 1.1 p.u.: {:.4}", vsi);
}
#[test]
fn test_l_index_range() {
let net = load_ieee14();
let config = PowerFlowConfig::default();
let result = net.solve_powerflow(&config).unwrap();
let li = compute_l_index(&net, &result).unwrap();
assert!(
(0.0..=1.0).contains(&li.l_max),
"L-max out of range: {:.4}",
li.l_max
);
for &l in &li.l_per_bus {
assert!((0.0..=1.0).contains(&l), "L-index out of [0,1]: {:.4}", l);
}
}
#[test]
fn test_l_index_critical_bus_valid() {
let net = load_ieee14();
let config = PowerFlowConfig::default();
let result = net.solve_powerflow(&config).unwrap();
let li = compute_l_index(&net, &result).unwrap();
assert!(li.critical_bus < net.buses.len());
}
#[test]
fn test_l_index_severity_string() {
let li = LIndex {
l_per_bus: vec![0.3, 0.5, 0.75],
l_max: 0.75,
critical_bus: 2,
near_collapse: false,
};
assert_eq!(li.severity(), "Warning");
}
#[test]
fn test_vsmi_positive() {
let net = load_ieee14();
let config = PowerFlowConfig::default();
let result = net.solve_powerflow(&config).unwrap();
let vsmi = compute_vsmi(&net, &result, 0.1, 0.5).unwrap();
assert!(vsmi.vsmi_min >= 0.0, "VSMI_min = {:.4}", vsmi.vsmi_min);
assert!(vsmi.weakest_bus < net.buses.len());
}
#[test]
fn test_vsmi_loaded_system_lower() {
let net = load_ieee14();
let config = PowerFlowConfig::default();
let result = net.solve_powerflow(&config).unwrap();
let vsmi = compute_vsmi(&net, &result, 0.05, 0.3).unwrap();
assert!(vsmi.loadability_margin_pct >= 0.0);
}
#[test]
fn test_cpf_has_points() {
let net = load_ieee14();
let cpf = run_cpf(&net, 0.1, 20).unwrap();
assert!(
!cpf.points.is_empty(),
"CPF should produce at least one point"
);
assert!(cpf.lambda_max >= 0.0);
}
#[test]
fn test_cpf_lambda_increasing() {
let net = load_ieee14();
let cpf = run_cpf(&net, 0.1, 15).unwrap();
for w in cpf.points.windows(2) {
assert!(
w[1].lambda >= w[0].lambda - 1e-9,
"λ should be non-decreasing: {:.4} → {:.4}",
w[0].lambda,
w[1].lambda
);
}
}
#[test]
fn test_find_nose_lambda_reasonable() {
let net = load_ieee14();
let lambda_max = find_nose_lambda(&net, 0.01, 1.0).unwrap();
assert!(
lambda_max > 0.0,
"Nose lambda should be positive: {:.4}",
lambda_max
);
assert!(
lambda_max < 10.0,
"Nose lambda should be < 10: {:.4}",
lambda_max
);
}
#[test]
fn test_cpf_nose_voltage_below_base() {
let net = load_ieee14();
let config = PowerFlowConfig::default();
let base_result = net.solve_powerflow(&config).unwrap();
let cpf = run_cpf(&net, 0.1, 15).unwrap();
if cpf.points.len() > 1 {
let v_last = cpf.v_nose[0];
let v_base = base_result.voltage_magnitude[0];
assert!(
v_last <= v_base + 0.05,
"Nose voltage {:.4} should not exceed base {:.4}",
v_last,
v_base
);
}
}
}