Skip to main content

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}