Skip to main content

scirs2_integrate/continuation/
mod.rs

1//! Numerical continuation methods for parametric systems
2//!
3//! This module provides algorithms for tracing solution branches of parametric
4//! equations F(x, λ) = 0 as the parameter λ varies, detecting bifurcations and
5//! turning points along the way.
6//!
7//! ## Methods
8//!
9//! - **Natural parameter continuation**: Increment λ, solve Newton at each step
10//! - **Pseudo-arclength continuation** (Keller's method): Augment with arclength
11//!   condition to continue past turning points and trace closed branches
12//!
13//! ## Detected features
14//!
15//! - **LimitPoint** (fold/turning point): det(F_x) = 0, branch turns back in λ
16//! - **BranchPoint** (bifurcation): two branches cross; requires branch switching
17//!
18//! ## References
19//!
20//! - Keller (1977), "Numerical solution of bifurcation and nonlinear eigenvalue problems"
21//! - Allgower & Georg (1990), "Numerical Continuation Methods"
22//! - Seydel (2010), "Practical Bifurcation and Stability Analysis"
23
24use crate::error::{IntegrateError, IntegrateResult};
25use scirs2_core::ndarray::{Array1, Array2};
26
27// ---------------------------------------------------------------------------
28// Helper
29// ---------------------------------------------------------------------------
30
31#[inline(always)]
32fn f64_to_f(v: f64) -> f64 {
33    v
34}
35
36/// Solve A·x = b by Gaussian elimination with partial pivoting.
37/// Modifies A and b in place.
38fn gauss_solve(a: &mut Array2<f64>, b: &mut Array1<f64>) -> IntegrateResult<Array1<f64>> {
39    let n = b.len();
40    for col in 0..n {
41        // Partial pivot
42        let mut max_row = col;
43        let mut max_val = a[[col, col]].abs();
44        for row in (col + 1)..n {
45            let v = a[[row, col]].abs();
46            if v > max_val {
47                max_val = v;
48                max_row = row;
49            }
50        }
51        if max_val < 1e-300 {
52            return Err(IntegrateError::LinearSolveError(
53                "Singular matrix in continuation solve".to_string(),
54            ));
55        }
56        if max_row != col {
57            for j in col..n {
58                let tmp = a[[col, j]];
59                a[[col, j]] = a[[max_row, j]];
60                a[[max_row, j]] = tmp;
61            }
62            b.swap(col, max_row);
63        }
64        let pivot = a[[col, col]];
65        for row in (col + 1)..n {
66            let factor = a[[row, col]] / pivot;
67            for j in col..n {
68                let update = factor * a[[col, j]];
69                a[[row, j]] -= update;
70            }
71            let bup = factor * b[col];
72            b[row] -= bup;
73        }
74    }
75    let mut x = Array1::<f64>::zeros(n);
76    for i in (0..n).rev() {
77        let mut sum = b[i];
78        for j in (i + 1)..n {
79            sum -= a[[i, j]] * x[j];
80        }
81        x[i] = sum / a[[i, i]];
82    }
83    Ok(x)
84}
85
86/// Compute numerical Jacobian of F(x, λ) w.r.t. x using central differences
87fn numerical_jacobian_x<F>(f: &F, x: &Array1<f64>, lambda: f64, eps: f64) -> Array2<f64>
88where
89    F: Fn(&Array1<f64>, f64) -> Array1<f64>,
90{
91    let n = x.len();
92    let mut jac = Array2::<f64>::zeros((n, n));
93    for j in 0..n {
94        let mut xp = x.clone();
95        let mut xm = x.clone();
96        xp[j] += eps;
97        xm[j] -= eps;
98        let fp = f(&xp, lambda);
99        let fm = f(&xm, lambda);
100        for i in 0..n {
101            jac[[i, j]] = (fp[i] - fm[i]) / (2.0 * eps);
102        }
103    }
104    jac
105}
106
107/// Compute numerical derivative of F w.r.t. λ using central differences
108fn numerical_df_dlambda<F>(f: &F, x: &Array1<f64>, lambda: f64, eps: f64) -> Array1<f64>
109where
110    F: Fn(&Array1<f64>, f64) -> Array1<f64>,
111{
112    let fp = f(x, lambda + eps);
113    let fm = f(x, lambda - eps);
114    let n = fp.len();
115    let mut df = Array1::<f64>::zeros(n);
116    for i in 0..n {
117        df[i] = (fp[i] - fm[i]) / (2.0 * eps);
118    }
119    df
120}
121
122// ---------------------------------------------------------------------------
123// Point classification types
124// ---------------------------------------------------------------------------
125
126/// A point on the solution branch: (state, parameter value)
127#[derive(Debug, Clone)]
128pub struct BranchPoint {
129    /// State vector x at the branch point
130    pub x: Array1<f64>,
131    /// Parameter value λ at the branch point
132    pub lambda: f64,
133    /// Approximate null-vector of F_x (tangent to intersecting branch)
134    pub null_vector: Option<Array1<f64>>,
135}
136
137/// A limit (fold/turning) point where det(F_x) = 0
138#[derive(Debug, Clone)]
139pub struct LimitPoint {
140    /// State at the limit point
141    pub x: Array1<f64>,
142    /// Parameter value at the limit point
143    pub lambda: f64,
144    /// Index along the branch where the limit point occurs
145    pub branch_index: usize,
146    /// Stability change indicator (true if stability changes)
147    pub stability_change: bool,
148}
149
150/// Stability classification for a solution
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum SolutionStability {
153    /// All eigenvalues of F_x have negative real part (stable)
154    Stable,
155    /// At least one eigenvalue of F_x has positive real part (unstable)
156    Unstable,
157    /// Cannot determine (e.g., zero eigenvalue)
158    Unknown,
159}
160
161// ---------------------------------------------------------------------------
162// Continuation result
163// ---------------------------------------------------------------------------
164
165/// Result of a numerical continuation run
166#[derive(Debug, Clone)]
167pub struct ContinuationBranchResult {
168    /// Solution states along the branch
169    pub x: Vec<Array1<f64>>,
170    /// Parameter values along the branch
171    pub lambda: Vec<f64>,
172    /// Stability at each point (if computed)
173    pub stability: Vec<SolutionStability>,
174    /// Detected limit (fold) points
175    pub limit_points: Vec<LimitPoint>,
176    /// Detected branch (bifurcation) points
177    pub branch_points: Vec<BranchPoint>,
178    /// Number of Newton iterations at each step
179    pub newton_iters: Vec<usize>,
180    /// Whether continuation terminated normally
181    pub success: bool,
182    /// Termination message
183    pub message: String,
184}
185
186// ---------------------------------------------------------------------------
187// Configuration
188// ---------------------------------------------------------------------------
189
190/// Configuration for continuation methods
191#[derive(Debug, Clone)]
192pub struct ContinuationConfig {
193    /// Maximum number of steps along the branch
194    pub max_steps: usize,
195    /// Initial step size in λ (natural) or arclength (pseudo-arclength)
196    pub ds: f64,
197    /// Minimum step size
198    pub ds_min: f64,
199    /// Maximum step size
200    pub ds_max: f64,
201    /// Step-size adaptation factor (0 < factor ≤ 1)
202    pub step_adapt_factor: f64,
203    /// Newton solver tolerance
204    pub newton_tol: f64,
205    /// Maximum Newton iterations per step
206    pub max_newton_iter: usize,
207    /// Finite difference epsilon for Jacobians
208    pub fd_eps: f64,
209    /// Whether to compute stability at each point
210    pub compute_stability: bool,
211    /// Determinant threshold for limit point detection
212    pub limit_point_tol: f64,
213    /// Desired Newton iterations per step (for adaptive step size)
214    pub desired_newton_iter: usize,
215}
216
217impl Default for ContinuationConfig {
218    fn default() -> Self {
219        Self {
220            max_steps: 500,
221            ds: 0.01,
222            ds_min: 1e-8,
223            ds_max: 1.0,
224            step_adapt_factor: 0.8,
225            newton_tol: 1e-10,
226            max_newton_iter: 20,
227            fd_eps: 1e-7,
228            compute_stability: true,
229            limit_point_tol: 1e-4,
230            desired_newton_iter: 5,
231        }
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Natural Parameter Continuation
237// ---------------------------------------------------------------------------
238
239/// Natural parameter continuation.
240///
241/// Increments the parameter λ by a fixed (or adaptive) step and solves
242/// F(x, λ) = 0 using Newton's method at each value of λ.
243///
244/// Simple and robust for problems without turning points. Fails near limit
245/// (fold) points where the branch turns back (dλ → 0). Use
246/// `PseudoArcLengthContinuation` to continue past such points.
247///
248/// # Arguments
249///
250/// * `f` - Residual function F(x, λ) → R^n
251/// * `x0` - Initial solution at λ0
252/// * `lambda0` - Initial parameter value
253/// * `lambda_end` - Target parameter value (can be less than λ0 for backward)
254/// * `cfg` - Continuation configuration
255///
256/// # Returns
257///
258/// `ContinuationBranchResult` with the traced branch.
259pub struct NaturalParameterContinuation;
260
261impl NaturalParameterContinuation {
262    /// Run natural parameter continuation from (x0, λ0) to λ_end.
263    pub fn run<F>(
264        f: &F,
265        x0: &Array1<f64>,
266        lambda0: f64,
267        lambda_end: f64,
268        cfg: &ContinuationConfig,
269    ) -> IntegrateResult<ContinuationBranchResult>
270    where
271        F: Fn(&Array1<f64>, f64) -> Array1<f64>,
272    {
273        let n = x0.len();
274        let direction = if lambda_end >= lambda0 { 1.0 } else { -1.0 };
275        let mut ds = cfg.ds.abs() * direction;
276
277        let mut x = x0.clone();
278        let mut lambda = lambda0;
279
280        let mut xs = vec![x.clone()];
281        let mut lambdas = vec![lambda];
282        let mut stabilities = Vec::new();
283        let mut limit_points = Vec::new();
284        let mut branch_points_list = Vec::new();
285        let mut newton_iters = vec![0usize];
286
287        if cfg.compute_stability {
288            let stab = compute_stability_from_jacobian(f, &x, lambda, cfg.fd_eps);
289            stabilities.push(stab);
290        }
291
292        let mut prev_det = jacobian_determinant(f, &x, lambda, cfg.fd_eps);
293
294        for _step in 0..cfg.max_steps {
295            // Clamp step to not overshoot lambda_end
296            if (lambda + ds - lambda_end) * direction > 0.0 {
297                ds = (lambda_end - lambda).abs() * direction;
298                if ds.abs() < cfg.ds_min {
299                    break;
300                }
301            }
302
303            let lambda_new = lambda + ds;
304
305            // Newton solve
306            match newton_solve_continuation(f, &x, lambda_new, cfg) {
307                Ok((x_new, n_iters)) => {
308                    let curr_det = jacobian_determinant(f, &x_new, lambda_new, cfg.fd_eps);
309
310                    // Limit point detection: sign change of det(J_x)
311                    if prev_det * curr_det < 0.0 {
312                        limit_points.push(LimitPoint {
313                            x: x_new.clone(),
314                            lambda: lambda_new,
315                            branch_index: xs.len(),
316                            stability_change: true,
317                        });
318                    }
319
320                    // Branch point detection: |det(J_x)| near zero but no sign change
321                    if curr_det.abs() < cfg.limit_point_tol && (prev_det * curr_det >= 0.0) {
322                        let null_vec =
323                            approximate_null_vector(f, &x_new, lambda_new, cfg.fd_eps, n);
324                        branch_points_list.push(BranchPoint {
325                            x: x_new.clone(),
326                            lambda: lambda_new,
327                            null_vector: Some(null_vec),
328                        });
329                    }
330
331                    // Adaptive step size
332                    let adapt_ratio = cfg.desired_newton_iter as f64 / n_iters.max(1) as f64;
333                    let new_ds = (ds * adapt_ratio.sqrt())
334                        .abs()
335                        .clamp(cfg.ds_min, cfg.ds_max);
336                    ds = new_ds * direction;
337
338                    prev_det = curr_det;
339                    x = x_new.clone();
340                    lambda = lambda_new;
341
342                    xs.push(x_new);
343                    lambdas.push(lambda);
344                    newton_iters.push(n_iters);
345
346                    if cfg.compute_stability {
347                        let stab = compute_stability_from_jacobian(f, &x, lambda, cfg.fd_eps);
348                        stabilities.push(stab);
349                    }
350
351                    // Check if we've reached the target
352                    if (lambda - lambda_end).abs() < cfg.ds_min.abs() {
353                        break;
354                    }
355                }
356                Err(_) => {
357                    // Reduce step size and retry
358                    ds *= cfg.step_adapt_factor;
359                    if ds.abs() < cfg.ds_min {
360                        return Ok(ContinuationBranchResult {
361                            x: xs,
362                            lambda: lambdas,
363                            stability: stabilities,
364                            limit_points,
365                            branch_points: branch_points_list,
366                            newton_iters,
367                            success: false,
368                            message: "Step size below minimum: Newton convergence failure"
369                                .to_string(),
370                        });
371                    }
372                }
373            }
374        }
375
376        Ok(ContinuationBranchResult {
377            x: xs,
378            lambda: lambdas,
379            stability: stabilities,
380            limit_points,
381            branch_points: branch_points_list,
382            newton_iters,
383            success: true,
384            message: "Natural parameter continuation completed".to_string(),
385        })
386    }
387}
388
389// ---------------------------------------------------------------------------
390// Pseudo-Arclength Continuation (Keller's method)
391// ---------------------------------------------------------------------------
392
393/// Pseudo-arclength continuation using Keller's bordering algorithm.
394///
395/// Augments the system with an arclength condition:
396///   F(x, λ) = 0
397///   (x - x_prev)·dx_ds + (λ - λ_prev)·dλ_ds - ds = 0
398///
399/// where (dx_ds, dλ_ds) is the unit tangent to the branch. This allows
400/// continuation past limit (fold) points and along closed branches.
401///
402/// # Algorithm
403///
404/// 1. Compute tangent (dx_ds, dλ_ds) by solving the extended linear system
405/// 2. Predict: (x_pred, λ_pred) = (x_prev, λ_prev) + ds*(dx_ds, dλ_ds)
406/// 3. Correct: Newton on the extended system (n+1 equations, n+1 unknowns)
407pub struct PseudoArcLengthContinuation;
408
409impl PseudoArcLengthContinuation {
410    /// Run pseudo-arclength continuation from (x0, λ0).
411    ///
412    /// # Arguments
413    ///
414    /// * `f` - Residual function F(x, λ) → R^n
415    /// * `x0` - Initial solution at λ0 (must satisfy F(x0, λ0) ≈ 0)
416    /// * `lambda0` - Initial parameter value
417    /// * `lambda_range` - (λ_min, λ_max): stop when λ leaves this range
418    /// * `cfg` - Continuation configuration
419    /// * `direction` - Initial direction: +1.0 increases λ, -1.0 decreases λ
420    pub fn run<F>(
421        f: &F,
422        x0: &Array1<f64>,
423        lambda0: f64,
424        lambda_range: (f64, f64),
425        cfg: &ContinuationConfig,
426        direction: f64,
427    ) -> IntegrateResult<ContinuationBranchResult>
428    where
429        F: Fn(&Array1<f64>, f64) -> Array1<f64>,
430    {
431        let n = x0.len();
432        let (lambda_min, lambda_max) = lambda_range;
433
434        let mut x = x0.clone();
435        let mut lambda = lambda0;
436
437        // Compute initial tangent
438        let (mut tx, mut tl) = compute_tangent(f, &x, lambda, direction, cfg.fd_eps, n)?;
439
440        let mut xs = vec![x.clone()];
441        let mut lambdas = vec![lambda];
442        let mut stabilities = Vec::new();
443        let mut limit_points = Vec::new();
444        let mut branch_points_list = Vec::new();
445        let mut newton_iters = vec![0usize];
446
447        if cfg.compute_stability {
448            let stab = compute_stability_from_jacobian(f, &x, lambda, cfg.fd_eps);
449            stabilities.push(stab);
450        }
451
452        let mut prev_det = jacobian_determinant(f, &x, lambda, cfg.fd_eps);
453        let mut ds = cfg.ds;
454
455        for _step in 0..cfg.max_steps {
456            // Predictor
457            let x_pred = {
458                let mut xp = Array1::<f64>::zeros(n);
459                for i in 0..n {
460                    xp[i] = x[i] + ds * tx[i];
461                }
462                xp
463            };
464            let lambda_pred = lambda + ds * tl;
465
466            // Check range
467            if lambda_pred < lambda_min || lambda_pred > lambda_max {
468                // Try to land exactly on the boundary
469                let lambda_target = if lambda_pred < lambda_min {
470                    lambda_min
471                } else {
472                    lambda_max
473                };
474                // Scale ds to hit boundary
475                let ds_boundary = (lambda_target - lambda) / tl.max(1e-300);
476                if ds_boundary.abs() < cfg.ds_min {
477                    break;
478                }
479                let x_boundary = {
480                    let mut xb = Array1::<f64>::zeros(n);
481                    for i in 0..n {
482                        xb[i] = x[i] + ds_boundary * tx[i];
483                    }
484                    xb
485                };
486                // Newton correct at boundary
487                if let Ok((x_b, ni)) = newton_solve_continuation(f, &x_boundary, lambda_target, cfg)
488                {
489                    xs.push(x_b);
490                    lambdas.push(lambda_target);
491                    newton_iters.push(ni);
492                }
493                break;
494            }
495
496            // Corrector: Newton on extended system
497            match newton_extended(f, &x_pred, lambda_pred, &x, lambda, &tx, tl, ds, cfg, n) {
498                Ok((x_new, lambda_new, n_iters)) => {
499                    let curr_det = jacobian_determinant(f, &x_new, lambda_new, cfg.fd_eps);
500
501                    // Limit point detection
502                    if prev_det * curr_det < 0.0 {
503                        limit_points.push(LimitPoint {
504                            x: x_new.clone(),
505                            lambda: lambda_new,
506                            branch_index: xs.len(),
507                            stability_change: true,
508                        });
509                    }
510
511                    // Branch point detection
512                    if curr_det.abs() < cfg.limit_point_tol && (prev_det * curr_det >= 0.0) {
513                        let null_vec =
514                            approximate_null_vector(f, &x_new, lambda_new, cfg.fd_eps, n);
515                        branch_points_list.push(BranchPoint {
516                            x: x_new.clone(),
517                            lambda: lambda_new,
518                            null_vector: Some(null_vec),
519                        });
520                    }
521
522                    // Update tangent
523                    if let Ok((new_tx, new_tl)) =
524                        compute_tangent(f, &x_new, lambda_new, tl.signum(), cfg.fd_eps, n)
525                    {
526                        // Ensure consistent orientation with previous tangent
527                        let dot = new_tx
528                            .iter()
529                            .zip(tx.iter())
530                            .fold(0.0, |s, (&a, &b)| s + a * b)
531                            + new_tl * tl;
532                        if dot < 0.0 {
533                            tx = new_tx.mapv(|v| -v);
534                            tl = -new_tl;
535                        } else {
536                            tx = new_tx;
537                            tl = new_tl;
538                        }
539                    }
540
541                    // Adaptive step size
542                    let adapt = cfg.desired_newton_iter as f64 / n_iters.max(1) as f64;
543                    ds = (ds * adapt.sqrt()).clamp(cfg.ds_min, cfg.ds_max);
544
545                    prev_det = curr_det;
546                    x = x_new.clone();
547                    lambda = lambda_new;
548
549                    xs.push(x_new);
550                    lambdas.push(lambda);
551                    newton_iters.push(n_iters);
552
553                    if cfg.compute_stability {
554                        let stab = compute_stability_from_jacobian(f, &x, lambda, cfg.fd_eps);
555                        stabilities.push(stab);
556                    }
557                }
558                Err(_) => {
559                    ds *= cfg.step_adapt_factor;
560                    if ds.abs() < cfg.ds_min {
561                        return Ok(ContinuationBranchResult {
562                            x: xs,
563                            lambda: lambdas,
564                            stability: stabilities,
565                            limit_points,
566                            branch_points: branch_points_list,
567                            newton_iters,
568                            success: false,
569                            message: "Step size below minimum".to_string(),
570                        });
571                    }
572                }
573            }
574        }
575
576        Ok(ContinuationBranchResult {
577            x: xs,
578            lambda: lambdas,
579            stability: stabilities,
580            limit_points,
581            branch_points: branch_points_list,
582            newton_iters,
583            success: true,
584            message: "Pseudo-arclength continuation completed".to_string(),
585        })
586    }
587}
588
589// ---------------------------------------------------------------------------
590// Internal helpers
591// ---------------------------------------------------------------------------
592
593/// Newton solve for F(x, λ_fixed) = 0 starting from x_guess
594fn newton_solve_continuation<F>(
595    f: &F,
596    x_guess: &Array1<f64>,
597    lambda: f64,
598    cfg: &ContinuationConfig,
599) -> IntegrateResult<(Array1<f64>, usize)>
600where
601    F: Fn(&Array1<f64>, f64) -> Array1<f64>,
602{
603    let n = x_guess.len();
604    let mut x = x_guess.clone();
605
606    for iter in 0..cfg.max_newton_iter {
607        let fx = f(&x, lambda);
608        let res_norm: f64 = fx.iter().map(|&v| v * v).sum::<f64>().sqrt();
609        if res_norm < cfg.newton_tol {
610            return Ok((x, iter + 1));
611        }
612        let mut jac = numerical_jacobian_x(f, &x, lambda, cfg.fd_eps);
613        let mut neg_fx = fx.mapv(|v| -v);
614        let delta = gauss_solve(&mut jac, &mut neg_fx)?;
615        for i in 0..n {
616            x[i] += delta[i];
617        }
618    }
619
620    let fx_final = f(&x, lambda);
621    let res_norm: f64 = fx_final.iter().map(|&v| v * v).sum::<f64>().sqrt();
622    if res_norm < cfg.newton_tol * 100.0 {
623        return Ok((x, cfg.max_newton_iter));
624    }
625
626    Err(IntegrateError::ConvergenceError(format!(
627        "Newton did not converge in {} iterations (res={:.3e})",
628        cfg.max_newton_iter, res_norm
629    )))
630}
631
632/// Compute tangent vector (tx, tl) to branch at (x, λ), normalized.
633/// Solve: [J_x | J_λ] * [tx; tl] = 0, with normalization ‖(tx,tl)‖ = 1
634fn compute_tangent<F>(
635    f: &F,
636    x: &Array1<f64>,
637    lambda: f64,
638    direction_sign: f64,
639    eps: f64,
640    n: usize,
641) -> IntegrateResult<(Array1<f64>, f64)>
642where
643    F: Fn(&Array1<f64>, f64) -> Array1<f64>,
644{
645    let jx = numerical_jacobian_x(f, x, lambda, eps);
646    let jl = numerical_df_dlambda(f, x, lambda, eps);
647
648    // Solve: J_x * tx = -J_λ * tl  →  normalize
649    // Use bordering: solve J_x * v = -J_λ and then normalize
650    let mut jx_copy = jx.clone();
651    let mut neg_jl = jl.mapv(|v| -v);
652
653    match gauss_solve(&mut jx_copy, &mut neg_jl) {
654        Ok(v) => {
655            // tl = 1 / sqrt(1 + ‖v‖²), tx = v * tl
656            let v_norm_sq: f64 = v.iter().map(|&vi| vi * vi).sum();
657            let tl_abs = 1.0 / (1.0 + v_norm_sq).sqrt();
658            let mut tx = v.mapv(|vi| vi * tl_abs);
659            let mut tl = tl_abs;
660
661            // Orient by direction sign
662            if tl * direction_sign < 0.0 {
663                tx = tx.mapv(|vi| -vi);
664                tl = -tl;
665            }
666            Ok((tx, tl))
667        }
668        Err(_) => {
669            // Fallback: pure λ-direction tangent
670            let tx = Array1::<f64>::zeros(n);
671            let tl = direction_sign;
672            Ok((tx, tl))
673        }
674    }
675}
676
677/// Newton corrector for pseudo-arclength extended system:
678///   F(x, λ) = 0
679///   (x - x0)·tx + (λ - λ0)·tl - ds = 0
680fn newton_extended<F>(
681    f: &F,
682    x_pred: &Array1<f64>,
683    lambda_pred: f64,
684    x0: &Array1<f64>,
685    lambda0: f64,
686    tx: &Array1<f64>,
687    tl: f64,
688    ds: f64,
689    cfg: &ContinuationConfig,
690    n: usize,
691) -> IntegrateResult<(Array1<f64>, f64, usize)>
692where
693    F: Fn(&Array1<f64>, f64) -> Array1<f64>,
694{
695    let mut x = x_pred.clone();
696    let mut lam = lambda_pred;
697    let size = n + 1;
698
699    for iter in 0..cfg.max_newton_iter {
700        let fx = f(&x, lam);
701
702        // Arclength residual
703        let arc_res: f64 = x
704            .iter()
705            .zip(x0.iter())
706            .zip(tx.iter())
707            .fold(0.0, |s, ((&xi, &x0i), &txi)| s + (xi - x0i) * txi)
708            + (lam - lambda0) * tl
709            - ds;
710
711        // Combined residual norm
712        let res_norm: f64 = fx.iter().map(|&v| v * v).sum::<f64>() + arc_res * arc_res;
713        let res_norm = res_norm.sqrt();
714
715        if res_norm < cfg.newton_tol {
716            return Ok((x, lam, iter + 1));
717        }
718
719        // Build extended Jacobian (n+1) × (n+1)
720        // [ J_x   J_λ ] [ dx  ]   [ -F    ]
721        // [ tx^T  tl  ] [ dλ  ] = [ -arc  ]
722        let jx = numerical_jacobian_x(f, &x, lam, cfg.fd_eps);
723        let jl = numerical_df_dlambda(f, &x, lam, cfg.fd_eps);
724
725        let mut big_a = Array2::<f64>::zeros((size, size));
726        let mut big_b = Array1::<f64>::zeros(size);
727
728        for i in 0..n {
729            for j in 0..n {
730                big_a[[i, j]] = jx[[i, j]];
731            }
732            big_a[[i, n]] = jl[i];
733            big_b[i] = -fx[i];
734        }
735        for j in 0..n {
736            big_a[[n, j]] = tx[j];
737        }
738        big_a[[n, n]] = tl;
739        big_b[n] = -arc_res;
740
741        let delta = gauss_solve(&mut big_a, &mut big_b)?;
742
743        for i in 0..n {
744            x[i] += delta[i];
745        }
746        lam += delta[n];
747    }
748
749    Err(IntegrateError::ConvergenceError(format!(
750        "Pseudo-arclength Newton did not converge in {} iterations",
751        cfg.max_newton_iter
752    )))
753}
754
755/// Estimate det(J_x) using Gaussian elimination (sign of product of pivots)
756fn jacobian_determinant<F>(f: &F, x: &Array1<f64>, lambda: f64, eps: f64) -> f64
757where
758    F: Fn(&Array1<f64>, f64) -> Array1<f64>,
759{
760    let n = x.len();
761    let mut jac = numerical_jacobian_x(f, x, lambda, eps);
762
763    // Gaussian elimination to get upper triangular, track sign
764    let mut sign = 1.0_f64;
765    let mut det_approx = 1.0_f64;
766
767    for col in 0..n {
768        let mut max_row = col;
769        let mut max_val = jac[[col, col]].abs();
770        for row in (col + 1)..n {
771            let v = jac[[row, col]].abs();
772            if v > max_val {
773                max_val = v;
774                max_row = row;
775            }
776        }
777        if max_val < 1e-300 {
778            return 0.0;
779        }
780        if max_row != col {
781            for j in col..n {
782                let tmp = jac[[col, j]];
783                jac[[col, j]] = jac[[max_row, j]];
784                jac[[max_row, j]] = tmp;
785            }
786            sign = -sign;
787        }
788        det_approx *= jac[[col, col]];
789        let pivot = jac[[col, col]];
790        for row in (col + 1)..n {
791            let factor = jac[[row, col]] / pivot;
792            for j in col..n {
793                let u = factor * jac[[col, j]];
794                jac[[row, j]] -= u;
795            }
796        }
797    }
798
799    sign * det_approx
800}
801
802/// Compute stability by checking the sign of real parts of diagonal of J_x
803/// (approximation using Gershgorin: all Gershgorin discs in left half-plane → stable)
804fn compute_stability_from_jacobian<F>(
805    f: &F,
806    x: &Array1<f64>,
807    lambda: f64,
808    eps: f64,
809) -> SolutionStability
810where
811    F: Fn(&Array1<f64>, f64) -> Array1<f64>,
812{
813    let n = x.len();
814    let jac = numerical_jacobian_x(f, x, lambda, eps);
815
816    // Gershgorin estimate: disc center = J[i,i], radius = sum_{j≠i} |J[i,j]|
817    let mut all_stable = true;
818    let mut any_unstable = false;
819
820    for i in 0..n {
821        let center = jac[[i, i]];
822        let radius: f64 = (0..n).filter(|&j| j != i).map(|j| jac[[i, j]].abs()).sum();
823        let rightmost = center + radius;
824        let leftmost = center - radius;
825
826        if leftmost > 0.0 {
827            any_unstable = true;
828            all_stable = false;
829        } else if rightmost > 0.0 {
830            all_stable = false;
831        }
832    }
833
834    if any_unstable {
835        SolutionStability::Unstable
836    } else if all_stable {
837        SolutionStability::Stable
838    } else {
839        SolutionStability::Unknown
840    }
841}
842
843/// Approximate null vector of J_x using inverse iteration
844fn approximate_null_vector<F>(
845    f: &F,
846    x: &Array1<f64>,
847    lambda: f64,
848    eps: f64,
849    n: usize,
850) -> Array1<f64>
851where
852    F: Fn(&Array1<f64>, f64) -> Array1<f64>,
853{
854    let jac = numerical_jacobian_x(f, x, lambda, eps);
855
856    // Find column with smallest diagonal (rough indicator of null-ness)
857    let mut min_col = 0;
858    let mut min_val = jac[[0, 0]].abs();
859    for i in 1..n {
860        let v = jac[[i, i]].abs();
861        if v < min_val {
862            min_val = v;
863            min_col = i;
864        }
865    }
866
867    // Return the min-col column of the identity (rough approximation)
868    let mut null = Array1::<f64>::zeros(n);
869    null[min_col] = 1.0;
870    null
871}
872
873// Suppress unused import warning
874#[allow(dead_code)]
875fn _use_f64_to_f() {
876    let _ = f64_to_f(0.0);
877}
878
879// ---------------------------------------------------------------------------
880// Tests
881// ---------------------------------------------------------------------------
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use scirs2_core::ndarray::array;
887
888    /// Simple 1D problem: F(x, λ) = x^3 - x - λ = 0
889    /// This has a fold/limit point at λ = ±2/3*sqrt(1/3) ≈ ±0.3849
890    fn fold_residual(x: &Array1<f64>, lambda: f64) -> Array1<f64> {
891        array![x[0] * x[0] * x[0] - x[0] - lambda]
892    }
893
894    #[test]
895    fn test_natural_continuation_simple() {
896        // Trace x^3 - x = λ from λ=0 to λ=0.3
897        // At λ=0, solutions are x=-1, 0, 1. Start on x=1 branch.
898        let x0 = array![1.0];
899        let cfg = ContinuationConfig {
900            max_steps: 100,
901            ds: 0.05,
902            ..Default::default()
903        };
904        let result = NaturalParameterContinuation::run(&fold_residual, &x0, 0.0, 0.3, &cfg)
905            .expect("Natural continuation failed");
906
907        assert!(result.lambda.len() > 2, "Should have more than 2 points");
908        let last_lambda = *result.lambda.last().expect("no lambda");
909        assert!(
910            (last_lambda - 0.3).abs() < 0.1,
911            "Last lambda={} should be near 0.3",
912            last_lambda
913        );
914    }
915
916    #[test]
917    fn test_pseudo_arclength_continuation() {
918        // Trace x^3 - x = λ, starting near x=1, λ=0, going in +λ direction
919        let x0 = array![1.0];
920        let cfg = ContinuationConfig {
921            max_steps: 200,
922            ds: 0.05,
923            ds_max: 0.2,
924            compute_stability: false,
925            ..Default::default()
926        };
927        let result =
928            PseudoArcLengthContinuation::run(&fold_residual, &x0, 0.0, (-2.0, 2.0), &cfg, 1.0)
929                .expect("Pseudo-arclength continuation failed");
930
931        assert!(result.x.len() > 2, "Branch should have points");
932    }
933
934    #[test]
935    fn test_linear_problem_continuation() {
936        // F(x, λ) = x - λ = 0, trivial branch x = λ
937        let linear_f = |x: &Array1<f64>, lambda: f64| array![x[0] - lambda];
938        let x0 = array![0.0];
939        let cfg = ContinuationConfig {
940            max_steps: 50,
941            ds: 0.1,
942            compute_stability: false,
943            ..Default::default()
944        };
945        let result = NaturalParameterContinuation::run(&linear_f, &x0, 0.0, 1.0, &cfg)
946            .expect("Linear continuation failed");
947
948        // All points should satisfy x ≈ λ
949        for (xi, &li) in result.x.iter().zip(result.lambda.iter()) {
950            assert!(
951                (xi[0] - li).abs() < 1e-6,
952                "x={} should equal lambda={}",
953                xi[0],
954                li
955            );
956        }
957    }
958}