gridoxide_core/continuation/mod.rs
1//! Continuation power flow: how much further can this network be loaded?
2//!
3//! An ordinary Newton solve cannot answer that. At the collapse point the
4//! Jacobian is singular, so Newton simply fails to converge — and from the
5//! outside that is indistinguishable from a bad initial guess, because
6//! `sparse::RealSparseSystem` detects singularity only as a non-finite result
7//! vector and no backend in the crate offers a condition estimate. Marching λ
8//! upward with ordinary solves therefore brackets the limit from below and
9//! never finds it.
10//!
11//! Continuation reformulates the problem so the singularity is a **regular
12//! point** of a bordered system, and walks the curve through it. See
13//! [`augmented`] for why that works and `docs/src/powerflow/continuation.md`
14//! for the derivation.
15//!
16//! ```no_run
17//! # use gridoxide_core::continuation::{run_continuation, ContinuationOptions, LoadingDirection};
18//! # fn example(buses: Vec<gridoxide_core::types::Bus>, lines: &[gridoxide_core::types::Line]) {
19//! let direction = LoadingDirection::scale_loads(&buses);
20//! let curve = run_continuation(
21//! buses, lines, &[], &[],
22//! ContinuationOptions { direction, ..Default::default() },
23//! );
24//! if let Some(critical) = &curve.critical {
25//! println!("λ_max = {:.4}, weakest bus {}", critical.lambda_max, critical.weakest[0].bus);
26//! }
27//! # }
28//! ```
29//!
30//! **λ_max is a property of the direction, not of the network.** Two studies
31//! that stress different buses get different noses and neither is wrong.
32
33pub mod augmented;
34pub mod corrector;
35pub mod direction;
36pub mod events;
37
38use crate::network::{ShuntAdm, YBusSparse, build_ybus, stamp_shunts};
39use crate::outerloop::{OuterLoop, OuterLoopContext, ReactiveLimits, SolveContext};
40use crate::solver::{IslandReport, JacobianBackend, SolveStatus};
41use crate::types::{Bus, BusType, Line, Transformer};
42use crate::{PowerFlowMethod, PowerFlowOptions};
43
44use augmented::{Layout, Parametrization, dominant_index};
45use corrector::{Constraint, CorrectorStatus, Segment, zip_buses};
46pub use direction::{BaseSpec, LoadingDirection};
47pub use events::{ContinuationEvent, QLimitKind};
48use events::{Illinois, worst_violation};
49
50/// Where to stop walking.
51#[derive(Clone, Copy, Debug, PartialEq, Default)]
52pub struct StopCriterion {
53 /// Stop once λ reaches this, landing exactly on it with a `Natural`
54 /// corrector. `None` walks to the nose.
55 pub target_lambda: Option<f64>,
56 /// Keep going past the nose and down the low-voltage branch until λ falls
57 /// back below its starting value.
58 pub trace_lower_branch: bool,
59}
60
61/// What a continuation run needs to know.
62#[derive(Clone, Debug)]
63pub struct ContinuationOptions {
64 /// The λ = 0 base-case solve and the shared knobs — `tol`, `max_iter`,
65 /// `backend`, `init`, `enforce_q_limits`. `method` must be
66 /// `NewtonRaphson`, and `control_taps` is refused: a tap move is a discrete
67 /// change to Y-bus *values*, needing its own event function and a
68 /// re-stamping mid-curve, and silently freezing the taps at their base-case
69 /// positions would be a wrong answer that looks right.
70 pub power_flow: PowerFlowOptions,
71 pub direction: LoadingDirection,
72 pub parametrization: Parametrization,
73 pub step: f64,
74 pub step_min: f64,
75 pub step_max: f64,
76 /// Corrector iterations at or below which the step grows.
77 pub target_iterations: usize,
78 pub max_steps: usize,
79 pub stop: StopCriterion,
80 /// Locate the exact λ at which each generator saturates, rather than
81 /// switching at whatever λ a step happened to land on. Only meaningful with
82 /// `power_flow.enforce_q_limits`.
83 pub locate_events: bool,
84 pub event_tol: f64,
85 pub event_max_iter: usize,
86 pub locate_nose: bool,
87 pub nose_refine_iters: usize,
88 /// **Reporting only** — the crate's internals carry no system base, and
89 /// `Bus::u_rated` is a voltage base. Used solely to turn the per-unit
90 /// margin into MW.
91 pub base_mva: f64,
92 /// Proceed despite voltage-dependent load terms the Jacobian does not
93 /// differentiate. Off by default: see [`ContinuationError::ZipTermsUnsupported`].
94 pub allow_zip: bool,
95}
96
97impl Default for ContinuationOptions {
98 fn default() -> Self {
99 Self {
100 power_flow: PowerFlowOptions::default(),
101 direction: LoadingDirection::zero(0),
102 parametrization: Parametrization::default(),
103 step: 0.1,
104 step_min: 1e-4,
105 step_max: 1.0,
106 target_iterations: 3,
107 max_steps: 200,
108 stop: StopCriterion::default(),
109 locate_events: true,
110 event_tol: 1e-6,
111 event_max_iter: 20,
112 locate_nose: true,
113 nose_refine_iters: 12,
114 base_mva: 100.0,
115 allow_zip: false,
116 }
117 }
118}
119
120/// A request continuation will not attempt, stated rather than approximated.
121#[derive(Clone, Debug, PartialEq)]
122pub enum ContinuationError {
123 /// `JacobianBackend::Block` groups each bus's two unknowns into a 2×2
124 /// block and has no home for a scalar λ. Refused rather than silently
125 /// substituted.
126 BackendUnsupported(JacobianBackend),
127 /// A tap move is a discrete change to Y-bus values; continuing with the
128 /// taps frozen at their base-case positions would be a different question
129 /// than the one asked.
130 TapControlUnsupported,
131 /// The Jacobian carries no `∂s_eff/∂|V|` term for ZIP loads. An ordinary
132 /// solve survives that — the mismatch is exact, so only the step direction
133 /// is off — but here the Jacobian's singularity *is* the answer, so a
134 /// missing term puts the nose in the wrong place while still looking
135 /// entirely plausible. Set `allow_zip` to proceed with a warning.
136 ZipTermsUnsupported {
137 buses: Vec<usize>,
138 },
139 MethodUnsupported(PowerFlowMethod),
140 /// Nothing moves, so there is no curve to trace.
141 EmptyDirection,
142 DirectionLengthMismatch {
143 got: usize,
144 want: usize,
145 },
146}
147
148/// A caveat that does not stop the run.
149#[derive(Clone, Debug, PartialEq)]
150pub enum ContinuationWarning {
151 /// `allow_zip` was set; the tangent and the located nose are approximate.
152 ApproximateJacobian { buses: Vec<usize> },
153}
154
155/// How the walk ended.
156#[derive(Clone, Debug, PartialEq)]
157pub enum ContinuationStatus {
158 /// The tangent's λ-component changed sign: a fold was found.
159 NoseReached,
160 /// A generator saturated and the re-seeded tangent pointed downward in λ.
161 LimitInducedNose,
162 TargetReached,
163 /// The lower branch was followed back below the starting λ.
164 TracedToZero,
165 /// The step floor was hit without any of the above — the curve could not be
166 /// continued, and λ_max is a lower bound rather than an answer.
167 StepFloor,
168 MaxSteps,
169 /// Even the bordered matrix was singular, which a simple fold cannot cause.
170 Singular,
171 /// The λ = 0 solve did not converge; there is no curve to start from.
172 BaseCaseFailed(SolveStatus),
173 Rejected(ContinuationError),
174}
175
176/// Which side of the nose a point sits on.
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178pub enum CurveBranch {
179 Upper,
180 Lower,
181}
182
183/// One converged point on the curve.
184#[derive(Clone, Debug)]
185pub struct CurvePoint {
186 pub lambda: f64,
187 /// Cumulative arclength — the parameter the walk actually advances in, and
188 /// the one that stays meaningful through the nose where λ does not.
189 pub arclength: f64,
190 pub voltage_mag: Vec<f64>,
191 pub voltage_ang: Vec<f64>,
192 /// Bus types change along the curve as generators saturate, so each point
193 /// carries its own.
194 pub bus_type: Vec<BusType>,
195 /// `dλ/dσ`. Its sign flip is the nose.
196 pub tangent_lambda: f64,
197 pub corrector_iterations: usize,
198 pub branch: CurveBranch,
199}
200
201/// A bus's share of the collapse mode.
202#[derive(Clone, Copy, Debug)]
203pub struct WeakBus {
204 pub bus: usize,
205 /// `|dV/dσ|` at the nose, normalized so the weakest bus is 1.0.
206 pub participation: f64,
207 pub voltage_mag: f64,
208}
209
210/// Which kind of critical point was found — and they are genuinely different
211/// answers.
212#[derive(Clone, Copy, Debug, PartialEq)]
213pub enum CriticalPointKind {
214 /// `dλ/dσ` passed through zero: the Jacobian is singular. A fold.
215 SaddleNode,
216 /// A generator hit its reactive limit and the tangent pointed downward in λ
217 /// immediately afterwards. λ_max is the event itself, and the Jacobian is
218 /// **not** singular there. A continuation that only watches for a smooth
219 /// fold marches past this and reports a nonsense λ_max.
220 LimitInduced { bus: usize },
221}
222
223/// The answer.
224#[derive(Clone, Debug)]
225pub struct CriticalPoint {
226 pub kind: CriticalPointKind,
227 pub lambda_max: f64,
228 pub point: CurvePoint,
229 /// The tangent's voltage-magnitude components, per bus. At a fold this
230 /// spans the Jacobian's null space — the collapse mode itself.
231 pub tangent_vmag: Vec<f64>,
232 pub tangent_ang: Vec<f64>,
233 /// Sorted, largest participation first.
234 pub weakest: Vec<WeakBus>,
235 /// `λ_max · Σ(−Δp)` over net consumers, per-unit.
236 pub margin_pu: f64,
237 /// `margin_pu · base_mva`. Reporting only.
238 pub margin_mw: f64,
239 pub p_load_base_pu: f64,
240 pub p_load_nose_pu: f64,
241}
242
243impl CriticalPoint {
244 /// The margin on a different system base than the one the run was told.
245 pub fn margin_mva(&self, base_mva: f64) -> f64 {
246 self.margin_pu * base_mva
247 }
248
249 /// The bus whose voltage collapses.
250 pub fn critical_bus(&self) -> Option<usize> {
251 self.weakest.first().map(|w| w.bus)
252 }
253}
254
255/// Everything a run produced.
256#[derive(Clone, Debug)]
257pub struct ContinuationCurve {
258 pub points: Vec<CurvePoint>,
259 pub events: Vec<ContinuationEvent>,
260 pub critical: Option<CriticalPoint>,
261 pub status: ContinuationStatus,
262 /// The solved λ = 0 state.
263 pub base: Vec<Bus>,
264 /// From the base-case solve. With more than one island, λ_max is *the first
265 /// island to collapse* — one λ and one parametrization row couple them all.
266 pub islands: Vec<IslandReport>,
267 pub warnings: Vec<ContinuationWarning>,
268 pub steps_attempted: usize,
269 pub corrector_iterations: usize,
270 /// Bordered solves performed, and symbolic factorizations built. One
271 /// factorization per segment; a segment ends only at a bus-type change.
272 pub solves: usize,
273 pub segments: usize,
274 /// Symbolic factorizations rebuilt because the continuation index moved —
275 /// see `augmented`'s module docs for why the index is part of the pattern.
276 pub reanalyses: usize,
277}
278
279impl ContinuationCurve {
280 fn rejected(err: ContinuationError, base: Vec<Bus>) -> Self {
281 Self {
282 points: Vec::new(),
283 events: Vec::new(),
284 critical: None,
285 status: ContinuationStatus::Rejected(err),
286 base,
287 islands: Vec::new(),
288 warnings: Vec::new(),
289 steps_attempted: 0,
290 corrector_iterations: 0,
291 solves: 0,
292 segments: 0,
293 reanalyses: 0,
294 }
295 }
296
297 /// λ at the nose, when one was found.
298 pub fn lambda_max(&self) -> Option<f64> {
299 self.critical.as_ref().map(|c| c.lambda_max)
300 }
301
302 /// Every point's λ, in walk order.
303 pub fn lambdas(&self) -> Vec<f64> {
304 self.points.iter().map(|p| p.lambda).collect()
305 }
306
307 /// One bus's voltage magnitude along the whole curve — a P-V curve, ready
308 /// to plot.
309 pub fn voltage_trace(&self, bus: usize) -> Vec<f64> {
310 self.points
311 .iter()
312 .filter_map(|p| p.voltage_mag.get(bus).copied())
313 .collect()
314 }
315
316 /// Just the machines that saturated, in the order they did, as
317 /// `(bus, limit, λ)`.
318 ///
319 /// [`events`](Self::events) also carries the walk's own bookkeeping —
320 /// rejected steps, and so on — which is diagnostic rather than an answer.
321 /// Almost every caller wants this instead.
322 pub fn q_limit_events(&self) -> Vec<(usize, QLimitKind, f64)> {
323 self.events
324 .iter()
325 .filter_map(|e| match e {
326 ContinuationEvent::QLimit {
327 bus, limit, lambda, ..
328 } => Some((*bus, *limit, *lambda)),
329 _ => None,
330 })
331 .collect()
332 }
333}
334
335/// The bordering row for a tangent solve, as `(columns, values, sign)`.
336///
337/// The tangent must point the same way as the previous one, or the walk turns
338/// round at the nose and retraces the branch it came from. Two ways to get that:
339///
340/// - border with `e_k`, `k` the previous tangent's largest component, and take
341/// the right-hand side's sign from that component. The bottom equation reads
342/// `t_k = ±1`, which fixes the direction through the one component least
343/// likely to be near zero. **One nonzero**, which is what keeps the
344/// factorization cheap.
345/// - border with the previous tangent itself, so the bottom equation reads
346/// `t·t_prev = 1`. Cleaner in principle, and dense — see `augmented`'s module
347/// docs for what that costs.
348///
349/// The first tangent has no predecessor, so it is seeded with `e_n`: "increase
350/// λ". Note that row can only ever return `t_λ > 0`, which is right for the
351/// first step and would make the nose undetectable if used throughout.
352fn tangent_border(
353 param: Parametrization,
354 previous: Option<&[f64]>,
355 n: usize,
356) -> (Vec<usize>, Vec<f64>, f64) {
357 match (param, previous) {
358 (Parametrization::PseudoArcLength, Some(t)) => ((0..=n).collect(), t[..=n].to_vec(), 1.0),
359 (_, Some(t)) => {
360 let k = dominant_index(t);
361 (vec![k], vec![1.0], t[k].signum())
362 }
363 (_, None) => (vec![n], vec![1.0], 1.0),
364 }
365}
366
367fn finished_ybus(
368 n: usize,
369 lines: &[Line],
370 transformers: &[Transformer],
371 shunts: &[ShuntAdm],
372) -> YBusSparse {
373 let mut y = build_ybus(n, lines, transformers);
374 stamp_shunts(&mut y, shunts);
375 y.finish()
376}
377
378/// Traces the P-V curve from the base case to the nose.
379///
380/// See the module docs for the shape of the answer.
381pub fn run_continuation(
382 buses: Vec<Bus>,
383 lines: &[Line],
384 transformers: &[Transformer],
385 shunts: &[ShuntAdm],
386 opts: ContinuationOptions,
387) -> ContinuationCurve {
388 if opts.power_flow.method != PowerFlowMethod::NewtonRaphson {
389 return ContinuationCurve::rejected(
390 ContinuationError::MethodUnsupported(opts.power_flow.method),
391 buses,
392 );
393 }
394 if opts.power_flow.control_taps {
395 return ContinuationCurve::rejected(ContinuationError::TapControlUnsupported, buses);
396 }
397 if opts.power_flow.backend == JacobianBackend::Block {
398 return ContinuationCurve::rejected(
399 ContinuationError::BackendUnsupported(JacobianBackend::Block),
400 buses,
401 );
402 }
403 if opts.direction.d_p.len() != buses.len() || opts.direction.d_q.len() != buses.len() {
404 return ContinuationCurve::rejected(
405 ContinuationError::DirectionLengthMismatch {
406 got: opts.direction.d_p.len(),
407 want: buses.len(),
408 },
409 buses,
410 );
411 }
412 if opts.direction.is_empty() {
413 return ContinuationCurve::rejected(ContinuationError::EmptyDirection, buses);
414 }
415 let zip = zip_buses(&buses);
416 if !zip.is_empty() && !opts.allow_zip {
417 return ContinuationCurve::rejected(
418 ContinuationError::ZipTermsUnsupported { buses: zip },
419 buses,
420 );
421 }
422
423 trace(buses, lines, transformers, shunts, opts, zip)
424}
425
426/// Runs `ReactiveLimits` exactly once against the current state.
427///
428/// The **only** place in continuation that flips a bus type. Keeping it a call
429/// into `outerloop::qlimits` rather than a reimplementation means the PV → PQ
430/// rule — including that it is one-directional, and that `q_spec` is pinned at
431/// the limit — is written down once in the crate and cannot drift between the
432/// ordinary solve and this one.
433fn switch_q_limits(buses: &mut [Bus], ybus: &mut YBusSparse) -> Vec<usize> {
434 let mut limits = ReactiveLimits::new();
435 {
436 let mut ctx = SolveContext::new(buses, ybus);
437 let mut lc = OuterLoopContext {
438 net: &mut ctx,
439 islands: &[],
440 iteration: 0,
441 };
442 let _ = limits.check(&mut lc);
443 }
444 limits.switches().to_vec()
445}
446
447/// Predict along `tangent` by arclength `s` from `anchor`, then correct.
448///
449/// Used for the ordinary step *and* for every locator trial, so a bisected
450/// point is produced by exactly the same code path as an accepted one — a
451/// located event is a genuine point on the curve, not an interpolation between
452/// two.
453///
454/// # Two guards, and why a converged corrector is not enough
455///
456/// The power-flow equations in polar form have solutions a continuation must
457/// not accept, and a corrector that only checks its own mismatch will happily
458/// walk onto one:
459///
460/// - **The mirror.** `(−|V|, θ + π)` satisfies the equations exactly and carries
461/// the *same* λ, so it is invisible to any λ-based check. A step that
462/// overshoots can land there, and the walk then re-traverses the whole curve
463/// on the mirror sheet — converging every time, reporting negative voltage
464/// magnitudes, and eventually finding a nose at the right λ for the wrong
465/// reason. Observed, not hypothesized: it is what a `step_max`-sized step does
466/// on a two-bus case before this guard existed.
467/// - **Any other branch.** More generally, the defining property of a
468/// *continuation* step is that the corrector lands near the predictor. If the
469/// correction moves further than the prediction did, the step did not follow
470/// the curve — it found some other solution — and the honest response is to
471/// halve the step rather than to accept a point that is on a different branch.
472///
473/// Both are reported as `None`, which puts the caller on its existing
474/// step-halving path.
475#[allow(clippy::too_many_arguments)]
476fn probe(
477 seg: &mut Segment,
478 work: &mut [Bus],
479 anchor: &[Bus],
480 ybus: &YBusSparse,
481 base: &BaseSpec,
482 dir: &LoadingDirection,
483 tangent: &[f64],
484 z0: &[f64],
485 lambda0: f64,
486 s: f64,
487 param: Parametrization,
488 k: usize,
489 tol: f64,
490 max_iter: usize,
491) -> Option<(f64, usize)> {
492 work.clone_from_slice(anchor);
493 let n = seg.layout.n_unknowns;
494 for k in 0..n {
495 seg.layout.add(work, k, s * tangent[k]);
496 }
497 let lambda_pred = lambda0 + s * tangent[n];
498 let z_pred: Vec<f64> = (0..=n)
499 .map(|k| seg.layout.get(work, lambda_pred, k))
500 .collect();
501 // `k` is pinned by the caller rather than re-derived here. That matters for
502 // the locator, which re-probes the same step at many arclengths: letting
503 // each trial re-pick its own continuation index would change the matrix
504 // pattern under the search, throwing away the factorization every trial.
505 let constraint = match param {
506 Parametrization::Natural => Constraint::Natural {
507 target: lambda_pred,
508 },
509 Parametrization::Local => Constraint::Local {
510 k,
511 target: z0[k] + s * tangent[k],
512 },
513 Parametrization::PseudoArcLength => Constraint::Arc {
514 tangent: tangent.to_vec(),
515 z0: z0.to_vec(),
516 step: s,
517 },
518 };
519 let out = seg.correct(
520 work,
521 ybus,
522 base,
523 dir,
524 &constraint,
525 lambda_pred,
526 tol,
527 max_iter,
528 );
529 if out.status != CorrectorStatus::Converged {
530 return None;
531 }
532 if work
533 .iter()
534 .any(|b| b.bus_type == BusType::PQ && b.voltage_mag <= 0.0)
535 {
536 return None;
537 }
538 let moved: f64 = (0..=n)
539 .map(|k| {
540 let d = seg.layout.get(work, out.lambda, k) - z_pred[k];
541 d * d
542 })
543 .sum::<f64>()
544 .sqrt();
545 if moved > s {
546 return None;
547 }
548 Some((out.lambda, out.iterations))
549}
550
551/// Everything the two locators share: an anchor point, the tangent leaving it,
552/// and the step that straddled the crossing.
553struct Bracket<'a> {
554 anchor: &'a [Bus],
555 tangent: &'a [f64],
556 z0: &'a [f64],
557 lambda0: f64,
558 step: f64,
559 param: Parametrization,
560 /// The step's own continuation index, held fixed for every trial.
561 k: usize,
562}
563
564/// Narrows `[0, step]` until the scalar `event` crosses zero, leaving `work` at
565/// the located point and returning `(λ, arclength, corrector iterations)`.
566///
567/// `event` is measured at each trial by re-predicting and re-correcting, so
568/// every trial is a genuine solution of the power flow rather than an
569/// interpolation. That is what "exact event location" means here, and it is
570/// what distinguishes the reported λ from "whichever λ the step happened to
571/// reach".
572///
573/// **The point returned is the first one at which the event has *just*
574/// happened — `f ≥ 0`, as small as the search could make it — not the closest
575/// point to `f = 0`.** The distinction is not cosmetic. The caller reacts to a
576/// located reactive-limit event by handing the point to `ReactiveLimits`, which
577/// switches a bus only when it is genuinely outside its limit; a point a
578/// hair *inside* would leave it unswitched, and the walk would locate the same
579/// crossing again on the next step, forever. Seeding the search with the
580/// step's own endpoint — known to be past the event, since that is what
581/// bracketed it — guarantees such a point always exists.
582#[allow(clippy::too_many_arguments)]
583fn locate<F>(
584 seg: &mut Segment,
585 work: &mut Vec<Bus>,
586 ybus: &YBusSparse,
587 base: &BaseSpec,
588 dir: &LoadingDirection,
589 br: &Bracket<'_>,
590 tol: f64,
591 max_iter: usize,
592 event_tol: f64,
593 max_trials: usize,
594 f_lo: f64,
595 f_hi: f64,
596 endpoint_lambda: f64,
597 mut event: F,
598) -> Option<(f64, f64, usize)>
599where
600 F: FnMut(&mut Segment, &[Bus], &YBusSparse) -> f64,
601{
602 let mut bracket = Illinois {
603 lo: 0.0,
604 hi: br.step,
605 f_lo,
606 f_hi,
607 };
608 // Seeded with the step's own endpoint, which `work` still holds: the search
609 // can only improve on it, and never has to report a point short of the
610 // event. Keyed on the value of `f`, not on recency — a bracketing search's
611 // last trial is not necessarily its best.
612 let mut best: (f64, f64, f64, Vec<Bus>) = (endpoint_lambda, br.step, f_hi, work.clone());
613 let mut iterations = 0usize;
614
615 for _ in 0..max_trials {
616 let s = bracket.next();
617 // Every trial uses the *same* parametrization and the *same* pinned
618 // continuation index as the step that bracketed the event, so a located
619 // point is produced by exactly the code path an accepted one is — and,
620 // just as importantly, the matrix pattern does not change under the
621 // search. An earlier version forced pseudo-arclength here for
622 // robustness; because that parametrization's bordering row is dense,
623 // it made every trial cost ~90x a sparse one and dominated the whole
624 // run.
625 let Some((lambda, iters)) = probe(
626 seg, work, br.anchor, ybus, base, dir, br.tangent, br.z0, br.lambda0, s, br.param,
627 br.k, tol, max_iter,
628 ) else {
629 // A failed trial says nothing about the sign, so shrink toward the
630 // side already known to be feasible rather than guessing.
631 bracket.hi = 0.5 * (bracket.lo + bracket.hi);
632 continue;
633 };
634 iterations += iters;
635 let f = event(seg, work, ybus);
636 if f >= 0.0 && f < best.2 {
637 best = (lambda, s, f, work.clone());
638 }
639 if (0.0..event_tol).contains(&f) || bracket.width() < 1e-12 * br.step.max(1.0) {
640 return Some((lambda, s, iterations));
641 }
642 bracket.update(s, f);
643 }
644
645 work.clone_from_slice(&best.3);
646 Some((best.0, best.1, iterations))
647}
648
649/// Assembles the answer at a located critical point.
650#[allow(clippy::too_many_arguments)]
651fn build_critical(
652 kind: CriticalPointKind,
653 seg: &Segment,
654 buses: &[Bus],
655 tangent: &[f64],
656 lambda: f64,
657 arclength: f64,
658 load_per_lambda: f64,
659 p_load_base_pu: f64,
660 base_mva: f64,
661) -> CriticalPoint {
662 let n_buses = buses.len();
663 let mut tangent_ang = vec![0.0; n_buses];
664 let mut tangent_vmag = vec![0.0; n_buses];
665 for (row, &i) in seg.layout.non_slack_idx.iter().enumerate() {
666 tangent_ang[i] = tangent[row];
667 }
668 for (row, &i) in seg.layout.pq_idx.iter().enumerate() {
669 tangent_vmag[i] = tangent[seg.layout.n_angle + row];
670 }
671
672 // At a fold the tangent spans the Jacobian's null space, so its magnitude
673 // components *are* the collapse mode — the weakest-bus ranking falls out of
674 // the predictor rather than needing an eigensolver.
675 let peak = tangent_vmag.iter().fold(0.0f64, |a, &b| a.max(b.abs()));
676 let mut weakest: Vec<WeakBus> = (0..n_buses)
677 .filter(|&i| tangent_vmag[i] != 0.0)
678 .map(|i| WeakBus {
679 bus: i,
680 participation: if peak > 0.0 {
681 tangent_vmag[i].abs() / peak
682 } else {
683 0.0
684 },
685 voltage_mag: buses[i].voltage_mag,
686 })
687 .collect();
688 weakest.sort_by(|a, b| b.participation.total_cmp(&a.participation));
689
690 let margin_pu = lambda * load_per_lambda;
691 CriticalPoint {
692 kind,
693 lambda_max: lambda,
694 point: snapshot(
695 buses,
696 lambda,
697 arclength,
698 tangent[seg.n()],
699 0,
700 CurveBranch::Upper,
701 ),
702 tangent_vmag,
703 tangent_ang,
704 weakest,
705 margin_pu,
706 margin_mw: margin_pu * base_mva,
707 p_load_base_pu,
708 p_load_nose_pu: p_load_base_pu + margin_pu,
709 }
710}
711
712fn snapshot(
713 buses: &[Bus],
714 lambda: f64,
715 arclength: f64,
716 tangent_lambda: f64,
717 iterations: usize,
718 branch: CurveBranch,
719) -> CurvePoint {
720 CurvePoint {
721 lambda,
722 arclength,
723 voltage_mag: buses.iter().map(|b| b.voltage_mag).collect(),
724 voltage_ang: buses.iter().map(|b| b.voltage_ang).collect(),
725 bus_type: buses.iter().map(|b| b.bus_type).collect(),
726 tangent_lambda,
727 corrector_iterations: iterations,
728 branch,
729 }
730}
731
732/// The walk itself, factoring with the sparse LU the power-flow options select.
733fn trace(
734 buses: Vec<Bus>,
735 lines: &[Line],
736 transformers: &[Transformer],
737 shunts: &[ShuntAdm],
738 opts: ContinuationOptions,
739 zip: Vec<usize>,
740) -> ContinuationCurve {
741 let n_buses = buses.len();
742 let mut ybus = finished_ybus(n_buses, lines, transformers, shunts);
743 let (tol, max_iter) = (opts.power_flow.tol, opts.power_flow.max_iter);
744 let lu_backend = opts.power_flow.backend.lu_backend();
745
746 let mut warnings = Vec::new();
747 if !zip.is_empty() {
748 warnings.push(ContinuationWarning::ApproximateJacobian { buses: zip });
749 }
750
751 // The base case goes through the ordinary entry point, so classification,
752 // the chosen initializer and — where asked for — reactive limits at λ = 0
753 // are exactly what an ordinary solve would have done.
754 let base_report = crate::run_power_flow(
755 buses,
756 lines,
757 transformers,
758 shunts,
759 crate::TapData::none(),
760 opts.power_flow.clone(),
761 );
762 if base_report.stats.status != SolveStatus::Converged
763 && !base_report.islands.iter().all(|i| {
764 !matches!(
765 i.status,
766 crate::solver::IslandStatus::Singular
767 | crate::solver::IslandStatus::MaxIterationsReached
768 )
769 })
770 {
771 return ContinuationCurve {
772 points: Vec::new(),
773 events: Vec::new(),
774 critical: None,
775 status: ContinuationStatus::BaseCaseFailed(base_report.stats.status),
776 base: base_report.buses,
777 islands: base_report.islands,
778 warnings,
779 steps_attempted: 0,
780 corrector_iterations: 0,
781 solves: 0,
782 segments: 0,
783 reanalyses: 0,
784 };
785 }
786
787 let mut buses = base_report.buses;
788 let islands = base_report.islands;
789 let base_switches: Vec<usize> = base_report
790 .outer
791 .as_ref()
792 .map(|o| o.q_limit_switches.clone())
793 .unwrap_or_default();
794 let base_state = buses.clone();
795
796 // Snapshot and direction are taken *after* the base solve, so
797 // `mark_unreferenced_islands` has already zeroed any sourceless island and a
798 // base-case reactive clamp is already in `q_spec`. Slack entries are dropped
799 // outright: a slack bus contributes no equation, so a direction there moves
800 // nothing and would only inflate the reported margin.
801 let mut base = BaseSpec::capture(&buses);
802 let mut dir = opts.direction.clone();
803 for b in &buses {
804 if b.bus_type == BusType::Slack {
805 dir.d_p[b.idx] = 0.0;
806 dir.d_q[b.idx] = 0.0;
807 }
808 }
809 // A machine the *base* solve clamped needs its reactive direction frozen
810 // exactly as one clamped mid-walk does. Missing this is silent and severe:
811 // `ReactiveLimits` pinned `q_spec` at the limit, and every subsequent
812 // `q_spec ← q_base + λ·Δq` write would then ramp it straight back off the
813 // limit — a reactive limit that is enforced at λ = 0 and quietly
814 // un-enforced everywhere after it. The curve still converges at every step,
815 // so nothing about it looks wrong; only an independent check of where each
816 // machine actually saturates catches it.
817 for &b in base_switches.iter() {
818 let clamped = buses[b].q_spec;
819 dir.freeze_reactive(b, &mut base, clamped);
820 }
821
822 let p_load_base_pu: f64 = buses
823 .iter()
824 .filter(|b| b.p_spec < 0.0)
825 .map(|b| -b.p_spec)
826 .sum();
827 let load_per_lambda = dir.total_active_load_increase();
828
829 let initial_border = tangent_border(
830 opts.parametrization,
831 None,
832 Layout::analyze(&buses).n_unknowns,
833 );
834 let mut seg = Segment::analyze(&buses, &ybus, &dir, initial_border.0.clone(), lu_backend);
835 let mut segments = 1usize;
836 let mut reanalyses = 0usize;
837 let mut solves = 0usize;
838 let mut events: Vec<ContinuationEvent> = Vec::new();
839 let mut points: Vec<CurvePoint> = Vec::new();
840 let mut corrector_iterations = 0usize;
841
842 macro_rules! finish {
843 ($status:expr, $critical:expr) => {
844 return ContinuationCurve {
845 points,
846 events,
847 critical: $critical,
848 status: $status,
849 base: base_state,
850 islands,
851 warnings,
852 steps_attempted: 0,
853 corrector_iterations,
854 solves: solves + seg.solves,
855 segments,
856 reanalyses: reanalyses + seg.reanalyses,
857 }
858 };
859 }
860
861 let Some(mut tangent) = seg.tangent(
862 &buses,
863 &ybus,
864 &initial_border.0,
865 &initial_border.1,
866 initial_border.2,
867 ) else {
868 finish!(ContinuationStatus::Singular, None);
869 };
870
871 let mut lambda = 0.0f64;
872 let mut arclength = 0.0f64;
873 let mut step = opts.step.clamp(opts.step_min, opts.step_max);
874 let mut work = buses.clone();
875 let mut critical: Option<CriticalPoint> = None;
876 let mut status = ContinuationStatus::MaxSteps;
877 let mut steps_attempted = 0usize;
878
879 points.push(snapshot(
880 &buses,
881 lambda,
882 arclength,
883 tangent[seg.n()],
884 0,
885 CurveBranch::Upper,
886 ));
887
888 while steps_attempted < opts.max_steps {
889 steps_attempted += 1;
890 let n = seg.n();
891 let anchor = buses.clone();
892 let lambda0 = lambda;
893 let z0: Vec<f64> = (0..=n)
894 .map(|k| seg.layout.get(&anchor, lambda0, k))
895 .collect();
896 let t_lambda_before = tangent[n];
897 // One continuation index for the whole step — the tangent solve, the
898 // corrector, and every locator trial — so the sparsity pattern is
899 // stable across all of them and one symbolic factorization serves.
900 let k = dominant_index(&tangent);
901 let bracket = Bracket {
902 anchor: &anchor,
903 tangent: &tangent,
904 z0: &z0,
905 lambda0,
906 step,
907 param: opts.parametrization,
908 k,
909 };
910
911 let Some((new_lambda, iterations)) = probe(
912 &mut seg,
913 &mut work,
914 &anchor,
915 &ybus,
916 &base,
917 &dir,
918 &tangent,
919 &z0,
920 lambda0,
921 step,
922 opts.parametrization,
923 k,
924 tol,
925 max_iter,
926 ) else {
927 events.push(ContinuationEvent::StepRejected {
928 lambda: lambda0,
929 step,
930 });
931 step *= 0.5;
932 if step < opts.step_min {
933 status = ContinuationStatus::StepFloor;
934 break;
935 }
936 continue;
937 };
938 corrector_iterations += iterations;
939
940 // --- reactive-limit events -------------------------------------------
941 // Checked before the point is accepted, because the answer is *where*
942 // the machine saturated, not that it had by the time a step landed.
943 let mut hit_limit = false;
944 let mut event_lambda = new_lambda;
945 let mut event_arc = step;
946 if opts.power_flow.enforce_q_limits
947 && worst_violation(&work, &seg.q_injections(&work, &ybus)) > 0.0
948 {
949 hit_limit = true;
950 let f_hi = worst_violation(&work, &seg.q_injections(&work, &ybus));
951 if opts.locate_events {
952 let f_lo = worst_violation(&anchor, &seg.q_injections(&anchor, &ybus));
953 if f_lo < 0.0 {
954 if let Some((l, s_at, iters)) = locate(
955 &mut seg,
956 &mut work,
957 &ybus,
958 &base,
959 &dir,
960 &bracket,
961 tol,
962 max_iter,
963 opts.event_tol,
964 opts.event_max_iter,
965 f_lo,
966 f_hi,
967 new_lambda,
968 |s, b, y| worst_violation(b, &s.q_injections(b, y)),
969 ) {
970 event_lambda = l;
971 event_arc = s_at;
972 corrector_iterations += iters;
973 }
974 }
975 }
976 }
977
978 // The event path and the ordinary accept path are mutually exclusive,
979 // and each commits `buses`, `lambda` and `arclength` exactly once.
980 // Letting the event path fall through into the accept path would
981 // advance the arclength twice and leave `lambda` describing a different
982 // point than `buses` does — a desynchronization that survives every
983 // convergence check and corrupts everything downstream of it.
984 if hit_limit {
985 buses.clone_from_slice(&work);
986 lambda = event_lambda;
987 arclength += event_arc;
988 let switched = switch_q_limits(&mut buses, &mut ybus);
989 debug_assert!(
990 !switched.is_empty(),
991 "the locator must return a point at which a machine is genuinely \
992 outside its limits, or the same crossing is found forever"
993 );
994 for &b in &switched {
995 let clamped = buses[b].q_spec;
996 let limit = if clamped >= buses[b].q_max {
997 QLimitKind::Max
998 } else {
999 QLimitKind::Min
1000 };
1001 dir.freeze_reactive(b, &mut base, clamped);
1002 events.push(ContinuationEvent::QLimit {
1003 bus: b,
1004 limit,
1005 lambda,
1006 iterations,
1007 });
1008 }
1009
1010 // A bus type moved, so `n_unknowns` and the sparsity pattern moved
1011 // with it — the condition `PersistentSolver::reset` documents.
1012 // Rebuild the segment, re-converge at the located λ (the state is
1013 // continuous across the switch, so this should take at most an
1014 // iteration or two) and re-seed the tangent, which belonged to the
1015 // old Jacobian.
1016 solves += seg.solves;
1017 reanalyses += seg.reanalyses;
1018 let carried_layout = Layout::analyze(&buses);
1019 let carried = carried_layout.embed(&seg.layout, &tangent);
1020 let border = tangent_border(
1021 opts.parametrization,
1022 Some(&carried),
1023 carried_layout.n_unknowns,
1024 );
1025 seg = Segment::analyze(&buses, &ybus, &dir, border.0.clone(), lu_backend);
1026 segments += 1;
1027 let out = seg.correct(
1028 &mut buses,
1029 &ybus,
1030 &base,
1031 &dir,
1032 &Constraint::Natural { target: lambda },
1033 lambda,
1034 tol,
1035 max_iter,
1036 );
1037 corrector_iterations += out.iterations;
1038 let Some(t_new) = seg.tangent(&buses, &ybus, &border.0, &border.1, border.2) else {
1039 status = ContinuationStatus::Singular;
1040 break;
1041 };
1042 tangent = t_new;
1043 work = buses.clone();
1044 let t_lambda_after = tangent[seg.n()];
1045 points.push(snapshot(
1046 &buses,
1047 lambda,
1048 arclength,
1049 t_lambda_after,
1050 out.iterations,
1051 CurveBranch::Upper,
1052 ));
1053
1054 // A limit-induced bifurcation: the machine saturating is itself the
1055 // maximum, and the Jacobian is not singular there. A continuation
1056 // watching only for a smooth fold marches past this and reports a
1057 // nonsense λ_max.
1058 if t_lambda_after < 0.0 {
1059 critical = Some(build_critical(
1060 CriticalPointKind::LimitInduced { bus: switched[0] },
1061 &seg,
1062 &buses,
1063 &tangent,
1064 lambda,
1065 arclength,
1066 load_per_lambda,
1067 p_load_base_pu,
1068 opts.base_mva,
1069 ));
1070 status = ContinuationStatus::LimitInducedNose;
1071 break;
1072 }
1073 step = (step * 0.25).clamp(opts.step_min, opts.step_max);
1074 continue;
1075 }
1076
1077 // --- accept -----------------------------------------------------------
1078 buses.clone_from_slice(&work);
1079 lambda = new_lambda;
1080 arclength += step;
1081
1082 // Bordered so the new tangent is guaranteed to point the same way as
1083 // the old one, which leaves `t_λ` free to change sign at the fold.
1084 let tb = tangent_border(opts.parametrization, Some(&tangent), seg.n());
1085 let Some(t_new) = seg.tangent(&buses, &ybus, &tb.0, &tb.1, tb.2) else {
1086 status = ContinuationStatus::Singular;
1087 break;
1088 };
1089 let t_lambda_after = t_new[seg.n()];
1090 let branch = if t_lambda_after < 0.0 {
1091 CurveBranch::Lower
1092 } else {
1093 CurveBranch::Upper
1094 };
1095 points.push(snapshot(
1096 &buses,
1097 lambda,
1098 arclength,
1099 t_lambda_after,
1100 iterations,
1101 branch,
1102 ));
1103
1104 // --- the nose ----------------------------------------------------------
1105 if critical.is_none() && t_lambda_before > 0.0 && t_lambda_after <= 0.0 {
1106 // `dλ/dσ` changed sign across this step, so the fold is inside it.
1107 // Refine on arclength with the same predict-correct the step used,
1108 // measuring `t_λ` at each trial. `−t_λ` is the bracketed scalar so
1109 // it runs negative-to-positive, as the locator expects.
1110 let nose_border = tangent_border(opts.parametrization, Some(&tangent), seg.n());
1111 let refined = if opts.locate_nose {
1112 locate(
1113 &mut seg,
1114 &mut work,
1115 &ybus,
1116 &base,
1117 &dir,
1118 &bracket,
1119 tol,
1120 max_iter,
1121 1e-9,
1122 opts.nose_refine_iters,
1123 -t_lambda_before,
1124 -t_lambda_after,
1125 // `−t_λ` at each trial, so the bracketed scalar runs
1126 // negative-to-positive as the locator expects. Bordering
1127 // with the step's own tangent keeps every trial pointing
1128 // the same way, which is what lets `t_λ` be read as a
1129 // signed quantity at all.
1130 new_lambda,
1131 |s: &mut Segment, b: &[Bus], y: &YBusSparse| {
1132 -s.tangent(b, y, &nose_border.0, &nose_border.1, nose_border.2)
1133 .map_or(0.0, |t| t[s.n()])
1134 },
1135 )
1136 } else {
1137 None
1138 };
1139 let (nose_state, nose_lambda) = match refined {
1140 Some((l, _, _)) => (work.clone(), l),
1141 None => (buses.clone(), lambda),
1142 };
1143 let nose_tangent = seg
1144 .tangent(
1145 &nose_state,
1146 &ybus,
1147 &nose_border.0,
1148 &nose_border.1,
1149 nose_border.2,
1150 )
1151 .unwrap_or_else(|| t_new.clone());
1152 critical = Some(build_critical(
1153 CriticalPointKind::SaddleNode,
1154 &seg,
1155 &nose_state,
1156 &nose_tangent,
1157 nose_lambda,
1158 arclength,
1159 load_per_lambda,
1160 p_load_base_pu,
1161 opts.base_mva,
1162 ));
1163 status = ContinuationStatus::NoseReached;
1164 tangent = t_new;
1165 if !opts.stop.trace_lower_branch {
1166 break;
1167 }
1168 continue;
1169 }
1170
1171 tangent = t_new;
1172
1173 // --- termination --------------------------------------------------------
1174 if let Some(target) = opts.stop.target_lambda {
1175 if lambda >= target {
1176 let out = seg.correct(
1177 &mut buses,
1178 &ybus,
1179 &base,
1180 &dir,
1181 &Constraint::Natural { target },
1182 target,
1183 tol,
1184 max_iter,
1185 );
1186 corrector_iterations += out.iterations;
1187 if out.status == CorrectorStatus::Converged {
1188 lambda = out.lambda;
1189 if let Some(last) = points.last_mut() {
1190 *last = snapshot(
1191 &buses,
1192 lambda,
1193 arclength,
1194 tangent[seg.n()],
1195 out.iterations,
1196 branch,
1197 );
1198 }
1199 }
1200 status = ContinuationStatus::TargetReached;
1201 break;
1202 }
1203 }
1204 if opts.stop.trace_lower_branch && critical.is_some() && lambda <= 0.0 {
1205 status = ContinuationStatus::TracedToZero;
1206 break;
1207 }
1208
1209 // --- step adaptation ------------------------------------------------------
1210 let scale =
1211 (opts.target_iterations.max(1) as f64 / iterations.max(1) as f64).clamp(0.5, 2.0);
1212 step = (step * scale).clamp(opts.step_min, opts.step_max);
1213 }
1214
1215 ContinuationCurve {
1216 points,
1217 events,
1218 critical,
1219 status,
1220 base: base_state,
1221 islands,
1222 warnings,
1223 steps_attempted,
1224 corrector_iterations,
1225 solves: solves + seg.solves,
1226 segments,
1227 reanalyses: reanalyses + seg.reanalyses,
1228 }
1229}