pub mod augmented;
pub mod corrector;
pub mod direction;
pub mod events;
use crate::network::{ShuntAdm, YBusSparse, build_ybus, stamp_shunts};
use crate::outerloop::{OuterLoop, OuterLoopContext, ReactiveLimits, SolveContext};
use crate::solver::{IslandReport, JacobianBackend, SolveStatus};
use crate::types::{Bus, BusType, Line, Transformer};
use crate::{PowerFlowMethod, PowerFlowOptions};
use augmented::{Layout, Parametrization, dominant_index};
use corrector::{Constraint, CorrectorStatus, Segment, zip_buses};
pub use direction::{BaseSpec, LoadingDirection};
pub use events::{ContinuationEvent, QLimitKind};
use events::{Illinois, worst_violation};
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct StopCriterion {
pub target_lambda: Option<f64>,
pub trace_lower_branch: bool,
}
#[derive(Clone, Debug)]
pub struct ContinuationOptions {
pub power_flow: PowerFlowOptions,
pub direction: LoadingDirection,
pub parametrization: Parametrization,
pub step: f64,
pub step_min: f64,
pub step_max: f64,
pub target_iterations: usize,
pub max_steps: usize,
pub stop: StopCriterion,
pub locate_events: bool,
pub event_tol: f64,
pub event_max_iter: usize,
pub locate_nose: bool,
pub nose_refine_iters: usize,
pub base_mva: f64,
pub allow_zip: bool,
}
impl Default for ContinuationOptions {
fn default() -> Self {
Self {
power_flow: PowerFlowOptions::default(),
direction: LoadingDirection::zero(0),
parametrization: Parametrization::default(),
step: 0.1,
step_min: 1e-4,
step_max: 1.0,
target_iterations: 3,
max_steps: 200,
stop: StopCriterion::default(),
locate_events: true,
event_tol: 1e-6,
event_max_iter: 20,
locate_nose: true,
nose_refine_iters: 12,
base_mva: 100.0,
allow_zip: false,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ContinuationError {
BackendUnsupported(JacobianBackend),
TapControlUnsupported,
ZipTermsUnsupported {
buses: Vec<usize>,
},
MethodUnsupported(PowerFlowMethod),
EmptyDirection,
DirectionLengthMismatch {
got: usize,
want: usize,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum ContinuationWarning {
ApproximateJacobian { buses: Vec<usize> },
}
#[derive(Clone, Debug, PartialEq)]
pub enum ContinuationStatus {
NoseReached,
LimitInducedNose,
TargetReached,
TracedToZero,
StepFloor,
MaxSteps,
Singular,
BaseCaseFailed(SolveStatus),
Rejected(ContinuationError),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CurveBranch {
Upper,
Lower,
}
#[derive(Clone, Debug)]
pub struct CurvePoint {
pub lambda: f64,
pub arclength: f64,
pub voltage_mag: Vec<f64>,
pub voltage_ang: Vec<f64>,
pub bus_type: Vec<BusType>,
pub tangent_lambda: f64,
pub corrector_iterations: usize,
pub branch: CurveBranch,
}
#[derive(Clone, Copy, Debug)]
pub struct WeakBus {
pub bus: usize,
pub participation: f64,
pub voltage_mag: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum CriticalPointKind {
SaddleNode,
LimitInduced { bus: usize },
}
#[derive(Clone, Debug)]
pub struct CriticalPoint {
pub kind: CriticalPointKind,
pub lambda_max: f64,
pub point: CurvePoint,
pub tangent_vmag: Vec<f64>,
pub tangent_ang: Vec<f64>,
pub weakest: Vec<WeakBus>,
pub margin_pu: f64,
pub margin_mw: f64,
pub p_load_base_pu: f64,
pub p_load_nose_pu: f64,
}
impl CriticalPoint {
pub fn margin_mva(&self, base_mva: f64) -> f64 {
self.margin_pu * base_mva
}
pub fn critical_bus(&self) -> Option<usize> {
self.weakest.first().map(|w| w.bus)
}
}
#[derive(Clone, Debug)]
pub struct ContinuationCurve {
pub points: Vec<CurvePoint>,
pub events: Vec<ContinuationEvent>,
pub critical: Option<CriticalPoint>,
pub status: ContinuationStatus,
pub base: Vec<Bus>,
pub islands: Vec<IslandReport>,
pub warnings: Vec<ContinuationWarning>,
pub steps_attempted: usize,
pub corrector_iterations: usize,
pub solves: usize,
pub segments: usize,
pub reanalyses: usize,
}
impl ContinuationCurve {
fn rejected(err: ContinuationError, base: Vec<Bus>) -> Self {
Self {
points: Vec::new(),
events: Vec::new(),
critical: None,
status: ContinuationStatus::Rejected(err),
base,
islands: Vec::new(),
warnings: Vec::new(),
steps_attempted: 0,
corrector_iterations: 0,
solves: 0,
segments: 0,
reanalyses: 0,
}
}
pub fn lambda_max(&self) -> Option<f64> {
self.critical.as_ref().map(|c| c.lambda_max)
}
pub fn lambdas(&self) -> Vec<f64> {
self.points.iter().map(|p| p.lambda).collect()
}
pub fn voltage_trace(&self, bus: usize) -> Vec<f64> {
self.points
.iter()
.filter_map(|p| p.voltage_mag.get(bus).copied())
.collect()
}
pub fn q_limit_events(&self) -> Vec<(usize, QLimitKind, f64)> {
self.events
.iter()
.filter_map(|e| match e {
ContinuationEvent::QLimit {
bus, limit, lambda, ..
} => Some((*bus, *limit, *lambda)),
_ => None,
})
.collect()
}
}
fn tangent_border(
param: Parametrization,
previous: Option<&[f64]>,
n: usize,
) -> (Vec<usize>, Vec<f64>, f64) {
match (param, previous) {
(Parametrization::PseudoArcLength, Some(t)) => ((0..=n).collect(), t[..=n].to_vec(), 1.0),
(_, Some(t)) => {
let k = dominant_index(t);
(vec![k], vec![1.0], t[k].signum())
}
(_, None) => (vec![n], vec![1.0], 1.0),
}
}
fn finished_ybus(
n: usize,
lines: &[Line],
transformers: &[Transformer],
shunts: &[ShuntAdm],
) -> YBusSparse {
let mut y = build_ybus(n, lines, transformers);
stamp_shunts(&mut y, shunts);
y.finish()
}
pub fn run_continuation(
buses: Vec<Bus>,
lines: &[Line],
transformers: &[Transformer],
shunts: &[ShuntAdm],
opts: ContinuationOptions,
) -> ContinuationCurve {
if opts.power_flow.method != PowerFlowMethod::NewtonRaphson {
return ContinuationCurve::rejected(
ContinuationError::MethodUnsupported(opts.power_flow.method),
buses,
);
}
if opts.power_flow.control_taps {
return ContinuationCurve::rejected(ContinuationError::TapControlUnsupported, buses);
}
if opts.power_flow.backend == JacobianBackend::Block {
return ContinuationCurve::rejected(
ContinuationError::BackendUnsupported(JacobianBackend::Block),
buses,
);
}
if opts.direction.d_p.len() != buses.len() || opts.direction.d_q.len() != buses.len() {
return ContinuationCurve::rejected(
ContinuationError::DirectionLengthMismatch {
got: opts.direction.d_p.len(),
want: buses.len(),
},
buses,
);
}
if opts.direction.is_empty() {
return ContinuationCurve::rejected(ContinuationError::EmptyDirection, buses);
}
let zip = zip_buses(&buses);
if !zip.is_empty() && !opts.allow_zip {
return ContinuationCurve::rejected(
ContinuationError::ZipTermsUnsupported { buses: zip },
buses,
);
}
trace(buses, lines, transformers, shunts, opts, zip)
}
fn switch_q_limits(buses: &mut [Bus], ybus: &mut YBusSparse) -> Vec<usize> {
let mut limits = ReactiveLimits::new();
{
let mut ctx = SolveContext::new(buses, ybus);
let mut lc = OuterLoopContext {
net: &mut ctx,
islands: &[],
iteration: 0,
};
let _ = limits.check(&mut lc);
}
limits.switches().to_vec()
}
#[allow(clippy::too_many_arguments)]
fn probe(
seg: &mut Segment,
work: &mut [Bus],
anchor: &[Bus],
ybus: &YBusSparse,
base: &BaseSpec,
dir: &LoadingDirection,
tangent: &[f64],
z0: &[f64],
lambda0: f64,
s: f64,
param: Parametrization,
k: usize,
tol: f64,
max_iter: usize,
) -> Option<(f64, usize)> {
work.clone_from_slice(anchor);
let n = seg.layout.n_unknowns;
for k in 0..n {
seg.layout.add(work, k, s * tangent[k]);
}
let lambda_pred = lambda0 + s * tangent[n];
let z_pred: Vec<f64> = (0..=n)
.map(|k| seg.layout.get(work, lambda_pred, k))
.collect();
let constraint = match param {
Parametrization::Natural => Constraint::Natural {
target: lambda_pred,
},
Parametrization::Local => Constraint::Local {
k,
target: z0[k] + s * tangent[k],
},
Parametrization::PseudoArcLength => Constraint::Arc {
tangent: tangent.to_vec(),
z0: z0.to_vec(),
step: s,
},
};
let out = seg.correct(
work,
ybus,
base,
dir,
&constraint,
lambda_pred,
tol,
max_iter,
);
if out.status != CorrectorStatus::Converged {
return None;
}
if work
.iter()
.any(|b| b.bus_type == BusType::PQ && b.voltage_mag <= 0.0)
{
return None;
}
let moved: f64 = (0..=n)
.map(|k| {
let d = seg.layout.get(work, out.lambda, k) - z_pred[k];
d * d
})
.sum::<f64>()
.sqrt();
if moved > s {
return None;
}
Some((out.lambda, out.iterations))
}
struct Bracket<'a> {
anchor: &'a [Bus],
tangent: &'a [f64],
z0: &'a [f64],
lambda0: f64,
step: f64,
param: Parametrization,
k: usize,
}
#[allow(clippy::too_many_arguments)]
fn locate<F>(
seg: &mut Segment,
work: &mut Vec<Bus>,
ybus: &YBusSparse,
base: &BaseSpec,
dir: &LoadingDirection,
br: &Bracket<'_>,
tol: f64,
max_iter: usize,
event_tol: f64,
max_trials: usize,
f_lo: f64,
f_hi: f64,
endpoint_lambda: f64,
mut event: F,
) -> Option<(f64, f64, usize)>
where
F: FnMut(&mut Segment, &[Bus], &YBusSparse) -> f64,
{
let mut bracket = Illinois {
lo: 0.0,
hi: br.step,
f_lo,
f_hi,
};
let mut best: (f64, f64, f64, Vec<Bus>) = (endpoint_lambda, br.step, f_hi, work.clone());
let mut iterations = 0usize;
for _ in 0..max_trials {
let s = bracket.next();
let Some((lambda, iters)) = probe(
seg, work, br.anchor, ybus, base, dir, br.tangent, br.z0, br.lambda0, s, br.param,
br.k, tol, max_iter,
) else {
bracket.hi = 0.5 * (bracket.lo + bracket.hi);
continue;
};
iterations += iters;
let f = event(seg, work, ybus);
if f >= 0.0 && f < best.2 {
best = (lambda, s, f, work.clone());
}
if (0.0..event_tol).contains(&f) || bracket.width() < 1e-12 * br.step.max(1.0) {
return Some((lambda, s, iterations));
}
bracket.update(s, f);
}
work.clone_from_slice(&best.3);
Some((best.0, best.1, iterations))
}
#[allow(clippy::too_many_arguments)]
fn build_critical(
kind: CriticalPointKind,
seg: &Segment,
buses: &[Bus],
tangent: &[f64],
lambda: f64,
arclength: f64,
load_per_lambda: f64,
p_load_base_pu: f64,
base_mva: f64,
) -> CriticalPoint {
let n_buses = buses.len();
let mut tangent_ang = vec![0.0; n_buses];
let mut tangent_vmag = vec![0.0; n_buses];
for (row, &i) in seg.layout.non_slack_idx.iter().enumerate() {
tangent_ang[i] = tangent[row];
}
for (row, &i) in seg.layout.pq_idx.iter().enumerate() {
tangent_vmag[i] = tangent[seg.layout.n_angle + row];
}
let peak = tangent_vmag.iter().fold(0.0f64, |a, &b| a.max(b.abs()));
let mut weakest: Vec<WeakBus> = (0..n_buses)
.filter(|&i| tangent_vmag[i] != 0.0)
.map(|i| WeakBus {
bus: i,
participation: if peak > 0.0 {
tangent_vmag[i].abs() / peak
} else {
0.0
},
voltage_mag: buses[i].voltage_mag,
})
.collect();
weakest.sort_by(|a, b| b.participation.total_cmp(&a.participation));
let margin_pu = lambda * load_per_lambda;
CriticalPoint {
kind,
lambda_max: lambda,
point: snapshot(
buses,
lambda,
arclength,
tangent[seg.n()],
0,
CurveBranch::Upper,
),
tangent_vmag,
tangent_ang,
weakest,
margin_pu,
margin_mw: margin_pu * base_mva,
p_load_base_pu,
p_load_nose_pu: p_load_base_pu + margin_pu,
}
}
fn snapshot(
buses: &[Bus],
lambda: f64,
arclength: f64,
tangent_lambda: f64,
iterations: usize,
branch: CurveBranch,
) -> CurvePoint {
CurvePoint {
lambda,
arclength,
voltage_mag: buses.iter().map(|b| b.voltage_mag).collect(),
voltage_ang: buses.iter().map(|b| b.voltage_ang).collect(),
bus_type: buses.iter().map(|b| b.bus_type).collect(),
tangent_lambda,
corrector_iterations: iterations,
branch,
}
}
fn trace(
buses: Vec<Bus>,
lines: &[Line],
transformers: &[Transformer],
shunts: &[ShuntAdm],
opts: ContinuationOptions,
zip: Vec<usize>,
) -> ContinuationCurve {
let n_buses = buses.len();
let mut ybus = finished_ybus(n_buses, lines, transformers, shunts);
let (tol, max_iter) = (opts.power_flow.tol, opts.power_flow.max_iter);
let lu_backend = opts.power_flow.backend.lu_backend();
let mut warnings = Vec::new();
if !zip.is_empty() {
warnings.push(ContinuationWarning::ApproximateJacobian { buses: zip });
}
let base_report = crate::run_power_flow(
buses,
lines,
transformers,
shunts,
crate::TapData::none(),
opts.power_flow.clone(),
);
if base_report.stats.status != SolveStatus::Converged
&& !base_report.islands.iter().all(|i| {
!matches!(
i.status,
crate::solver::IslandStatus::Singular
| crate::solver::IslandStatus::MaxIterationsReached
)
})
{
return ContinuationCurve {
points: Vec::new(),
events: Vec::new(),
critical: None,
status: ContinuationStatus::BaseCaseFailed(base_report.stats.status),
base: base_report.buses,
islands: base_report.islands,
warnings,
steps_attempted: 0,
corrector_iterations: 0,
solves: 0,
segments: 0,
reanalyses: 0,
};
}
let mut buses = base_report.buses;
let islands = base_report.islands;
let base_switches: Vec<usize> = base_report
.outer
.as_ref()
.map(|o| o.q_limit_switches.clone())
.unwrap_or_default();
let base_state = buses.clone();
let mut base = BaseSpec::capture(&buses);
let mut dir = opts.direction.clone();
for b in &buses {
if b.bus_type == BusType::Slack {
dir.d_p[b.idx] = 0.0;
dir.d_q[b.idx] = 0.0;
}
}
for &b in base_switches.iter() {
let clamped = buses[b].q_spec;
dir.freeze_reactive(b, &mut base, clamped);
}
let p_load_base_pu: f64 = buses
.iter()
.filter(|b| b.p_spec < 0.0)
.map(|b| -b.p_spec)
.sum();
let load_per_lambda = dir.total_active_load_increase();
let initial_border = tangent_border(
opts.parametrization,
None,
Layout::analyze(&buses).n_unknowns,
);
let mut seg = Segment::analyze(&buses, &ybus, &dir, initial_border.0.clone(), lu_backend);
let mut segments = 1usize;
let mut reanalyses = 0usize;
let mut solves = 0usize;
let mut events: Vec<ContinuationEvent> = Vec::new();
let mut points: Vec<CurvePoint> = Vec::new();
let mut corrector_iterations = 0usize;
macro_rules! finish {
($status:expr, $critical:expr) => {
return ContinuationCurve {
points,
events,
critical: $critical,
status: $status,
base: base_state,
islands,
warnings,
steps_attempted: 0,
corrector_iterations,
solves: solves + seg.solves,
segments,
reanalyses: reanalyses + seg.reanalyses,
}
};
}
let Some(mut tangent) = seg.tangent(
&buses,
&ybus,
&initial_border.0,
&initial_border.1,
initial_border.2,
) else {
finish!(ContinuationStatus::Singular, None);
};
let mut lambda = 0.0f64;
let mut arclength = 0.0f64;
let mut step = opts.step.clamp(opts.step_min, opts.step_max);
let mut work = buses.clone();
let mut critical: Option<CriticalPoint> = None;
let mut status = ContinuationStatus::MaxSteps;
let mut steps_attempted = 0usize;
points.push(snapshot(
&buses,
lambda,
arclength,
tangent[seg.n()],
0,
CurveBranch::Upper,
));
while steps_attempted < opts.max_steps {
steps_attempted += 1;
let n = seg.n();
let anchor = buses.clone();
let lambda0 = lambda;
let z0: Vec<f64> = (0..=n)
.map(|k| seg.layout.get(&anchor, lambda0, k))
.collect();
let t_lambda_before = tangent[n];
let k = dominant_index(&tangent);
let bracket = Bracket {
anchor: &anchor,
tangent: &tangent,
z0: &z0,
lambda0,
step,
param: opts.parametrization,
k,
};
let Some((new_lambda, iterations)) = probe(
&mut seg,
&mut work,
&anchor,
&ybus,
&base,
&dir,
&tangent,
&z0,
lambda0,
step,
opts.parametrization,
k,
tol,
max_iter,
) else {
events.push(ContinuationEvent::StepRejected {
lambda: lambda0,
step,
});
step *= 0.5;
if step < opts.step_min {
status = ContinuationStatus::StepFloor;
break;
}
continue;
};
corrector_iterations += iterations;
let mut hit_limit = false;
let mut event_lambda = new_lambda;
let mut event_arc = step;
if opts.power_flow.enforce_q_limits
&& worst_violation(&work, &seg.q_injections(&work, &ybus)) > 0.0
{
hit_limit = true;
let f_hi = worst_violation(&work, &seg.q_injections(&work, &ybus));
if opts.locate_events {
let f_lo = worst_violation(&anchor, &seg.q_injections(&anchor, &ybus));
if f_lo < 0.0 {
if let Some((l, s_at, iters)) = locate(
&mut seg,
&mut work,
&ybus,
&base,
&dir,
&bracket,
tol,
max_iter,
opts.event_tol,
opts.event_max_iter,
f_lo,
f_hi,
new_lambda,
|s, b, y| worst_violation(b, &s.q_injections(b, y)),
) {
event_lambda = l;
event_arc = s_at;
corrector_iterations += iters;
}
}
}
}
if hit_limit {
buses.clone_from_slice(&work);
lambda = event_lambda;
arclength += event_arc;
let switched = switch_q_limits(&mut buses, &mut ybus);
debug_assert!(
!switched.is_empty(),
"the locator must return a point at which a machine is genuinely \
outside its limits, or the same crossing is found forever"
);
for &b in &switched {
let clamped = buses[b].q_spec;
let limit = if clamped >= buses[b].q_max {
QLimitKind::Max
} else {
QLimitKind::Min
};
dir.freeze_reactive(b, &mut base, clamped);
events.push(ContinuationEvent::QLimit {
bus: b,
limit,
lambda,
iterations,
});
}
solves += seg.solves;
reanalyses += seg.reanalyses;
let carried_layout = Layout::analyze(&buses);
let carried = carried_layout.embed(&seg.layout, &tangent);
let border = tangent_border(
opts.parametrization,
Some(&carried),
carried_layout.n_unknowns,
);
seg = Segment::analyze(&buses, &ybus, &dir, border.0.clone(), lu_backend);
segments += 1;
let out = seg.correct(
&mut buses,
&ybus,
&base,
&dir,
&Constraint::Natural { target: lambda },
lambda,
tol,
max_iter,
);
corrector_iterations += out.iterations;
let Some(t_new) = seg.tangent(&buses, &ybus, &border.0, &border.1, border.2) else {
status = ContinuationStatus::Singular;
break;
};
tangent = t_new;
work = buses.clone();
let t_lambda_after = tangent[seg.n()];
points.push(snapshot(
&buses,
lambda,
arclength,
t_lambda_after,
out.iterations,
CurveBranch::Upper,
));
if t_lambda_after < 0.0 {
critical = Some(build_critical(
CriticalPointKind::LimitInduced { bus: switched[0] },
&seg,
&buses,
&tangent,
lambda,
arclength,
load_per_lambda,
p_load_base_pu,
opts.base_mva,
));
status = ContinuationStatus::LimitInducedNose;
break;
}
step = (step * 0.25).clamp(opts.step_min, opts.step_max);
continue;
}
buses.clone_from_slice(&work);
lambda = new_lambda;
arclength += step;
let tb = tangent_border(opts.parametrization, Some(&tangent), seg.n());
let Some(t_new) = seg.tangent(&buses, &ybus, &tb.0, &tb.1, tb.2) else {
status = ContinuationStatus::Singular;
break;
};
let t_lambda_after = t_new[seg.n()];
let branch = if t_lambda_after < 0.0 {
CurveBranch::Lower
} else {
CurveBranch::Upper
};
points.push(snapshot(
&buses,
lambda,
arclength,
t_lambda_after,
iterations,
branch,
));
if critical.is_none() && t_lambda_before > 0.0 && t_lambda_after <= 0.0 {
let nose_border = tangent_border(opts.parametrization, Some(&tangent), seg.n());
let refined = if opts.locate_nose {
locate(
&mut seg,
&mut work,
&ybus,
&base,
&dir,
&bracket,
tol,
max_iter,
1e-9,
opts.nose_refine_iters,
-t_lambda_before,
-t_lambda_after,
new_lambda,
|s: &mut Segment, b: &[Bus], y: &YBusSparse| {
-s.tangent(b, y, &nose_border.0, &nose_border.1, nose_border.2)
.map_or(0.0, |t| t[s.n()])
},
)
} else {
None
};
let (nose_state, nose_lambda) = match refined {
Some((l, _, _)) => (work.clone(), l),
None => (buses.clone(), lambda),
};
let nose_tangent = seg
.tangent(
&nose_state,
&ybus,
&nose_border.0,
&nose_border.1,
nose_border.2,
)
.unwrap_or_else(|| t_new.clone());
critical = Some(build_critical(
CriticalPointKind::SaddleNode,
&seg,
&nose_state,
&nose_tangent,
nose_lambda,
arclength,
load_per_lambda,
p_load_base_pu,
opts.base_mva,
));
status = ContinuationStatus::NoseReached;
tangent = t_new;
if !opts.stop.trace_lower_branch {
break;
}
continue;
}
tangent = t_new;
if let Some(target) = opts.stop.target_lambda {
if lambda >= target {
let out = seg.correct(
&mut buses,
&ybus,
&base,
&dir,
&Constraint::Natural { target },
target,
tol,
max_iter,
);
corrector_iterations += out.iterations;
if out.status == CorrectorStatus::Converged {
lambda = out.lambda;
if let Some(last) = points.last_mut() {
*last = snapshot(
&buses,
lambda,
arclength,
tangent[seg.n()],
out.iterations,
branch,
);
}
}
status = ContinuationStatus::TargetReached;
break;
}
}
if opts.stop.trace_lower_branch && critical.is_some() && lambda <= 0.0 {
status = ContinuationStatus::TracedToZero;
break;
}
let scale =
(opts.target_iterations.max(1) as f64 / iterations.max(1) as f64).clamp(0.5, 2.0);
step = (step * scale).clamp(opts.step_min, opts.step_max);
}
ContinuationCurve {
points,
events,
critical,
status,
base: base_state,
islands,
warnings,
steps_attempted,
corrector_iterations,
solves: solves + seg.solves,
segments,
reanalyses: reanalyses + seg.reanalyses,
}
}