Skip to main content

dune_core/
pss.rs

1//! `.pss` — periodic steady state of a driven circuit by shooting
2//! (issue #13, D-0028 track 4). No 3f5 contract: the analysis is new.
3//! ngspice-47 has a `.pss` card (Perticaroli/Lannutti,
4//! `analysis/dcpss.c`), but its algorithm is a free-running
5//! oscillator search — it re-estimates the frequency from the
6//! transient on every cycle and never solves `x(T) = x(0)`, so on a
7//! driven circuit it drifts off the drive frequency (a 1 kHz RC
8//! comes out at 750 Hz). Dune keeps ngspice's card and plot names
9//! and replaces the method (docs/dune-core.md, "Regime permanente
10//! periódico").
11//!
12//! **Method.** Shooting–Newton on the period map. With `Φ(x₀)` the
13//! state a transient started from `x₀` at `t₀` reaches at `t₀ + T`,
14//! the periodic steady state solves `F(x₀) = Φ(x₀) − x₀ = 0`;
15//! Newton's step solves `(I − Φ′) δ = Φ(x₀) − x₀` with the monodromy
16//! matrix `Φ′`. `Φ′` is never formed: GMRES needs only the products
17//! `Φ′ v`, each one directional finite difference of two transients
18//! retraced on the frozen grid of the current iterate (matrix-free
19//! shooting, Telichevesky–Kundert–White 1995). A circuit with `k`
20//! slow modes needs about `k + 1` products per Newton step, against
21//! `n` for a dense finite-difference Jacobian; a linear circuit
22//! converges in one Newton step.
23//!
24//! **Transient map.** Every shooting run is a fresh engine running
25//! the full `DCtran` loop (LTE control, breakpoints, trap/Gear/
26//! TR-BDF2, the step budget of D-0022) from the state `x₀` instead
27//! of an operating point: the device states are seeded from `x₀`
28//! ([`Engine::tran_period`]), so charges and junction voltages are
29//! functions of `x₀` alone — the full MNA vector is the shooting
30//! state, and its algebraic components fall out of the Jacobian on
31//! their own (their columns of `Φ′` vanish).
32//!
33//! **Autonomous oscillators** (no periodic source, issue #20): the
34//! period `T` is a Newton unknown, the phase pinned by holding
35//! OSCNODE at its value on a rising mid-range crossing — see
36//! `Circuit::pss_autonomous` and docs/dune-core.md.
37//!
38//! **Transmission lines** (issue #20): a line's state is its history,
39//! and in the periodic regime the history is the orbit itself,
40//! `x(t − τ) = x(t − τ + kT)`. The lines read the previous orbit as
41//! their past (`matrix::PeriodicPast`), a fixed point iterated (Picard) on a
42//! frozen step grid while Newton solves `x₀` — see
43//! `Shooter::lines_excited`. Lossy LTRA lines need `.options
44//! ltrafit` (their infinite periodic past is summed in closed form by
45//! the recursive model) and have their DC drop pinned (`ltra.rs`).
46
47use dune_raw::{Data, VarType};
48use num_complex::Complex64;
49
50use crate::circuit::{Circuit, Spec, VarKind, saved_node};
51use crate::engine::{Engine, MAX_OUTPUT_VALUES, PeriodRun, StepRec, TranParams};
52use crate::error::Error;
53use crate::integ::Method;
54use crate::matrix::PeriodicPast;
55use crate::options::Options;
56use crate::solution::Solution;
57use crate::srcfn::Wave;
58
59/// Plot name of the period in the time domain (ngspice-47's).
60pub const PSS_TIME_PLOT: &str = "Time Domain Periodic Steady State Analysis";
61/// Plot name of the harmonics (ngspice-47's).
62pub const PSS_FREQ_PLOT: &str = "Frequency Domain Periodic Steady State Analysis";
63
64/// The `.pss` request (see [`Circuit::pss`]).
65#[derive(Clone, Debug, PartialEq)]
66pub struct PssParams {
67    /// Fundamental frequency in Hz; `None` takes it from the
68    /// periodic sources (the greatest common divisor of their
69    /// frequencies). Without periodic sources (an autonomous
70    /// oscillator) it is a guess of the period's inverse, and `None`
71    /// estimates it from the stabilization transient.
72    pub freq: Option<f64>,
73    /// Stabilization time: a plain transient runs from 0 to the first
74    /// period boundary at or after `tstab` (at least one period)
75    /// before the shooting starts there.
76    pub tstab: f64,
77    /// ngspice's oscillation node. A driven circuit only checks and
78    /// reports it; an autonomous one (no periodic source) needs it:
79    /// its phase condition holds this node at its value on a rising
80    /// mid-range crossing, and its crossings estimate the period.
81    pub osc_node: Option<String>,
82    /// Samples per period in the time-domain plot (the plot has
83    /// `points + 1` rows, both ends included, like ngspice's).
84    pub points: usize,
85    /// Rows of the frequency-domain plot: DC plus `harmonics − 1`
86    /// harmonics (ngspice's count).
87    pub harmonics: usize,
88    /// Most shooting iterations (adaptive period transients).
89    pub max_iter: usize,
90    /// Scale of the convergence tolerance: converged when every
91    /// unknown has `|x(t₀+T) − x(t₀)| ≤ steady_coeff · (RELTOL ·
92    /// peak + VNTOL|ABSTOL)`, `peak` its largest magnitude over the
93    /// period.
94    pub steady_coeff: f64,
95    /// Run the stabilization transient with `UIC`.
96    pub uic: bool,
97}
98
99impl Default for PssParams {
100    fn default() -> Self {
101        PssParams {
102            freq: None,
103            tstab: 0.0,
104            osc_node: None,
105            points: 128,
106            harmonics: 10,
107            max_iter: 50,
108            steady_coeff: 1.0,
109            uic: false,
110        }
111    }
112}
113
114/// Result of a periodic steady-state analysis.
115#[derive(Clone, Debug)]
116pub struct Pss {
117    /// One period sampled at `points + 1` equispaced times from `t₀`
118    /// to `t₀ + T` ([`PSS_TIME_PLOT`], scale `time`; node voltages and
119    /// branch currents as in `.tran`).
120    pub time: Solution,
121    /// The complex harmonics `X_k`, `k = 0 … harmonics − 1`, at
122    /// `k·f` ([`PSS_FREQ_PLOT`], scale `frequency`): `X_0` the mean,
123    /// and `x(t) = X_0 + Σ Re(X_k e^{jkωt})` — `.ac`'s phasor
124    /// convention, phases referred to `t = 0`. ngspice writes only
125    /// the magnitudes; `mag()` of these columns is that plot.
126    pub spectrum: Solution,
127    /// The period `T` (s): the drive's for a driven circuit, the
128    /// shooting's result for an autonomous oscillator.
129    pub period: f64,
130    /// Start of the reported period (a multiple of `T`).
131    pub t0: f64,
132    /// The residual `max_i |x_i(t₀+T) − x_i(t₀)| / tol_i` of each
133    /// shooting iteration (converged when ≤ 1).
134    pub residuals: Vec<f64>,
135    /// The period of each shooting iteration, aligned with
136    /// `residuals` (constant for a driven circuit; the Newton iterate
137    /// `T` of an autonomous one).
138    pub periods: Vec<f64>,
139    pub notes: Vec<String>,
140}
141
142/// The perturbation of the directional differences, in units of the
143/// convergence tolerance (the scaled unknowns are `x_i / tol_i`).
144const FD_EPS: f64 = 1e-2;
145/// The replays of the Jacobian products tighten the Newton
146/// tolerances of every step by this factor: a frozen-grid replay is
147/// smooth in `x₀` only up to its Newton error, which the difference
148/// would amplify by `1/ε`.
149const REPLAY_TIGHTEN: f64 = 1e-3;
150/// Most halvings of a Newton step whose residual did not decrease.
151const MAX_BACKTRACK: usize = 4;
152
153impl Circuit {
154    /// Run `.pss`: the periodic steady state of the circuit driven at
155    /// the fundamental `p.freq` (or the one its sources share), by
156    /// shooting–Newton with matrix-free GMRES on the period map (see
157    /// the module docs). Fails with [`Error::PssConvergence`] when
158    /// `p.max_iter` shooting iterations do not bring the period
159    /// mismatch within tolerance.
160    pub fn pss(&self, opts: &Options, p: &PssParams) -> Result<Pss, Error> {
161        // The shooting's own default method (`Options::pss_method`),
162        // pinned so that every transient below integrates with it.
163        let stab_method = opts.pss_stab_method();
164        let opts = &Options {
165            method: Some(opts.pss_method()),
166            ..opts.clone()
167        };
168        let bad = |m: &str| Err(Error::Build(format!(".pss {m}")));
169        if !(p.tstab.is_finite() && p.tstab >= 0.0) {
170            return bad("stabilization time must be finite and not negative");
171        }
172        if p.points < 4 || p.points > 10_000_000 {
173            return bad("needs 4 to 10 000 000 points per period");
174        }
175        if p.harmonics < 1 || p.harmonics > p.points / 2 {
176            return bad("harmonics must be between 1 and points/2");
177        }
178        if p.max_iter < 1 {
179            return bad("needs at least one shooting iteration");
180        }
181        if !(p.steady_coeff.is_finite() && p.steady_coeff > 0.0) {
182            return bad("steady coefficient must be positive");
183        }
184        if opts.dev_noise {
185            // Issue #22: the same reason as the TRNOISE/TRRANDOM
186            // sources below. The option used to be ignored here
187            // (only `.tran` installs the generators), so the result
188            // looked as if it had taken the noise into account.
189            return Err(Error::Build(
190                ".pss: `.options devnoise` is on, and transient device noise is a random \
191                 stimulus, which has no periodic steady state; turn it off for the shooting"
192                    .into(),
193            ));
194        }
195        for spec in &self.specs {
196            let noisy = match spec {
197                Spec::Vsource(v) => &v.wave,
198                Spec::Isource(i) => &i.wave,
199                _ => &None,
200            };
201            if matches!(noisy, Some(Wave::TrNoise(_) | Wave::TrRandom(_))) {
202                // Issue #13, part 2: a random stimulus has no periodic
203                // steady state; shooting would chase a moving target.
204                return Err(Error::Unsupported {
205                    name: spec.name().to_string(),
206                    what: "a transient-noise source (TRNOISE/TRRANDOM) in .pss — a random \
207                           stimulus has no periodic steady state"
208                        .into(),
209                });
210            }
211            if let Spec::Ltra(l) = spec
212                && !opts.ltra_fit
213            {
214                // Issue #20: the periodic past of a lossy line is
215                // infinite; only the recursive model sums it.
216                let lp = crate::ltra::LtraParams::new(&l.name, &l.model, opts.reltol, opts.abstol)
217                    .map_err(Error::Build)?;
218                if matches!(
219                    lp.case,
220                    crate::ltra::LtraCase::Rlc | crate::ltra::LtraCase::Rc
221                ) {
222                    return Err(Error::Build(format!(
223                        ".pss: the lossy (RLC/RC) line `{}` needs `.options ltrafit`: in the \
224                         periodic regime its history is the orbit repeated over the whole past, \
225                         which the exact convolution (a walk over a history that starts at an \
226                         operating point) cannot hold, and the recursive model sums in closed \
227                         form",
228                        l.name
229                    )));
230                }
231            }
232        }
233        let mut notes = Vec::new();
234        let sources = self.source_frequencies()?;
235        if sources.is_empty() {
236            // No periodic stimulus: a free-running oscillator, its
237            // period an unknown of the shooting (see `pss_autonomous`).
238            return self.pss_autonomous(opts, p, stab_method);
239        }
240        let freq = match p.freq {
241            Some(f) => {
242                if !(f.is_finite() && f > 0.0) {
243                    return bad("frequency must be positive");
244                }
245                for (name, fs) in &sources {
246                    let k = fs / f;
247                    if (k - k.round()).abs() > 1e-6 * k.max(1.0) || k.round() < 1.0 {
248                        notes.push(format!(
249                            "pss: source `{name}` ({fs:e} Hz) is not a harmonic of the \
250                             fundamental {f:e} Hz; the circuit is not {:e}-s periodic",
251                            1.0 / f
252                        ));
253                    }
254                }
255                f
256            }
257            None => harvest(&sources)?,
258        };
259        let period = 1.0 / freq;
260        let tstep = period / p.points as f64;
261        let max_step = f64::min(tstep, period / 50.0);
262        let n_stab = (p.tstab / period - 1e-9).ceil().max(1.0);
263        if n_stab > 1e9 {
264            return bad("stabilization spans too many periods");
265        }
266        let t0 = n_stab * period;
267        let run = PeriodRun {
268            t0,
269            period,
270            tstep,
271            max_step,
272        };
273
274        // Stabilization: a plain transient to t0.
275        let built = self.build(opts)?;
276        notes.extend(built.notes.iter().cloned());
277        let mut engine = Engine::new(built);
278        // Work guards (hostile cards must cost nothing, CLAUDE.md
279        // princípio 4): a period transient keeps about `points` rows,
280        // the stabilization takes `n_stab · points` steps, the DFT
281        // `harmonics · points` products per unknown.
282        let nvars = engine.vars.len() as f64;
283        let pts = p.points as f64;
284        if (pts + 1.0) * nvars > MAX_OUTPUT_VALUES as f64
285            || n_stab * pts > 1e8
286            || p.harmonics as f64 * pts * nvars > 1e9
287        {
288            return bad("asks for too many points or periods; refusing");
289        }
290        if let Some(node) = &p.osc_node {
291            let lc = node.to_ascii_lowercase();
292            if !engine.vars.names.contains(&lc) {
293                return Err(Error::Build(format!(".pss: no node `{node}` for oscnode")));
294            }
295            notes.push(format!(
296                "pss: oscnode `{lc}` is for autonomous circuits; the driven shooting does not use it"
297            ));
298        }
299        let ports = self.line_ports(&engine.vars.names);
300        let lines = !ports.is_empty();
301        // With lines, the last stabilization period is the first guess
302        // of the periodic past (only the rows kept change).
303        let stab = engine.tran(
304            opts,
305            &TranParams {
306                tstep,
307                tstop: t0,
308                tstart: if lines { t0 - period } else { t0 },
309                tmax: Some(max_step),
310                uic: p.uic,
311            },
312        )?;
313        let names = engine.vars.names.clone();
314        let kinds = engine.vars.kinds.clone();
315        let mut x = engine.sol.clone();
316        drop(engine);
317
318        let mut shooter = Shooter {
319            circuit: self,
320            opts,
321            tight: tightened(opts),
322            run,
323            kinds: &kinds,
324            adaptive: 0,
325            replays: 0,
326            krylov: Vec::new(),
327            past: if lines {
328                Some(
329                    Past::from_rows(&stab, None, t0 - period, period, max_step)
330                        .ok_or_else(|| Error::Build(".pss: no stabilization period".into()))?,
331                )
332            } else {
333                None
334            },
335            ports,
336            history: Vec::new(),
337            refresh: Vec::new(),
338        };
339        let mut residuals = Vec::new();
340        let converged = if lines {
341            shooter.lines_excited(&mut x, p, &mut residuals)?
342        } else {
343            // The last Newton step taken: (base point, step, base residual, fraction).
344            let mut last: Option<(Vec<f64>, Vec<f64>, f64, f64)> = None;
345            let min_frac = 0.5f64.powi(MAX_BACKTRACK as i32);
346            loop {
347                // A transient that fails from a Newton iterate (an
348                // overshoot into a region the step control cannot cross)
349                // counts as an infinite residual and is backtracked.
350                let (mut period_run, rnorm) = match shooter.adaptive(&x) {
351                    Ok(r) => {
352                        let n = r.rnorm;
353                        (Some(r), n)
354                    }
355                    Err(e) => match &last {
356                        Some((_, _, _, frac)) if *frac > min_frac => (None, f64::INFINITY),
357                        _ => return Err(e),
358                    },
359                };
360                residuals.push(rnorm);
361                if rnorm <= p.steady_coeff
362                    && let Some(r) = period_run.take()
363                {
364                    break r;
365                }
366                if residuals.len() >= p.max_iter {
367                    return Err(Error::PssConvergence {
368                        iterations: residuals.len(),
369                        residuals,
370                        period: None,
371                    });
372                }
373                // Backtrack a Newton step that did not reduce the residual.
374                if let Some((base, step, base_norm, frac)) = &mut last
375                    && rnorm > *base_norm
376                    && *frac > min_frac
377                {
378                    *frac *= 0.5;
379                    for i in 0..x.len() {
380                        x[i] = base[i] + *frac * step[i];
381                    }
382                    continue;
383                }
384                let Some(period_run) = period_run else {
385                    unreachable!("a failed run is always backtracked")
386                };
387                let step = shooter.newton_step(&x, &period_run)?;
388                let base = x.clone();
389                for i in 1..x.len() {
390                    x[i] += step[i];
391                }
392                last = Some((base, step, rnorm, 1.0));
393            }
394        };
395
396        notes.push(format!(
397            "pss: period {period:e} s (fundamental {freq:e} Hz{}); shooting over \
398             [{t0:e}, {:e}] s after {n_stab} stabilization period(s)",
399            if p.freq.is_some() {
400                String::new()
401            } else {
402                ", from the sources".to_string()
403            },
404            t0 + period
405        ));
406        notes.push(shooter.summary(&residuals));
407        notes.extend(shooter.lines_note());
408
409        let (time, spectrum) = outputs(&converged, &names, &kinds, p, t0, period);
410        let periods = vec![period; residuals.len()];
411        Ok(Pss {
412            time,
413            spectrum,
414            period,
415            t0,
416            residuals,
417            periods,
418            notes,
419        })
420    }
421
422    /// The port unknowns of the transmission lines (issue #20) — the
423    /// four terminals and the two port-current equations of every
424    /// T/O line, ground left out —, whose orbit must reproduce the
425    /// periodic past the lines read.
426    fn line_ports(&self, names: &[String]) -> Vec<usize> {
427        let find = |n: &str| names.iter().position(|v| v.eq_ignore_ascii_case(n));
428        let mut out = Vec::new();
429        for spec in &self.specs {
430            let (name, nodes) = match spec {
431                Spec::Ltra(l) => (&l.name, &l.nodes),
432                Spec::Tra(t) => (&t.name, &t.nodes),
433                _ => continue,
434            };
435            let branches = [format!("{name}#i1"), format!("{name}#i2")];
436            for n in nodes
437                .iter()
438                .map(String::as_str)
439                .chain(branches.iter().map(String::as_str))
440            {
441                if let Some(i) = find(n)
442                    && i != 0
443                    && !out.contains(&i)
444                {
445                    out.push(i);
446                }
447            }
448        }
449        out
450    }
451
452    /// The frequencies the independent sources repeat at: SIN and
453    /// SFFM carriers/modulations, PULSE `1/PER`. Waveforms that settle
454    /// to a constant (EXP, PWL, a PULSE without PER) set none. A SIN
455    /// or SFFM without an explicit frequency (its default is
456    /// `1/TSTOP`) or a damped SIN has no period and is an error.
457    fn source_frequencies(&self) -> Result<Vec<(String, f64)>, Error> {
458        let mut out = Vec::new();
459        for spec in &self.specs {
460            let (name, wave) = match spec {
461                Spec::Vsource(v) => (&v.name, &v.wave),
462                Spec::Isource(i) => (&i.name, &i.wave),
463                _ => continue,
464            };
465            let Some(wave) = wave else { continue };
466            let given = |c: &[f64], i: usize| c.get(i).copied().filter(|&v| v != 0.0);
467            let no_period = |what: &str| {
468                Err(Error::Build(format!(
469                    ".pss: source `{name}`: {what} has no fixed period"
470                )))
471            };
472            match wave {
473                Wave::Sin(c) => {
474                    if given(c, 1).is_none() {
475                        continue;
476                    }
477                    if given(c, 4).is_some() {
478                        return no_period("a damped SIN");
479                    }
480                    match given(c, 2) {
481                        Some(f) => out.push((name.clone(), f.abs())),
482                        None => return no_period("a SIN without a frequency"),
483                    }
484                }
485                Wave::Sffm(c) => {
486                    if given(c, 1).is_none() {
487                        continue;
488                    }
489                    match given(c, 2) {
490                        Some(f) => out.push((name.clone(), f.abs())),
491                        None => return no_period("an SFFM without a carrier frequency"),
492                    }
493                    if given(c, 3).is_some() {
494                        match given(c, 4) {
495                            Some(f) => out.push((name.clone(), f.abs())),
496                            None => return no_period("an SFFM without a signal frequency"),
497                        }
498                    }
499                }
500                Wave::Pulse(c) => {
501                    let v1 = c.first().copied().unwrap_or(0.0);
502                    let v2 = c.get(1).copied().unwrap_or(0.0);
503                    if v1 != v2
504                        && let Some(per) = given(c, 6)
505                    {
506                        out.push((name.clone(), 1.0 / per.abs()));
507                    }
508                }
509                Wave::Exp(_) | Wave::Pwl(_) => {}
510                // Refused by `Circuit::pss` before the harvest.
511                Wave::TrNoise(_) | Wave::TrRandom(_) => {}
512            }
513        }
514        Ok(out)
515    }
516}
517
518impl Circuit {
519    /// `.pss` of an autonomous oscillator (no periodic source): the
520    /// period is an unknown. Shooting–Newton on the augmented system
521    /// `[Φ_T(x₀) − x₀ = 0; x₀[osc] = v_ref]`, the phase condition
522    /// fixing `OSCNODE` at its value on a rising crossing of its
523    /// mid-range (see [`osc_start`]); the bordered Jacobian
524    /// `[I − Φ′, −∂Φ/∂T]` (osc column dropped) is solved by the same
525    /// matrix-free GMRES, `∂Φ/∂T` by a replay on the frozen grid
526    /// stretched to `T + δT`.
527    fn pss_autonomous(
528        &self,
529        opts: &Options,
530        p: &PssParams,
531        stab_method: Method,
532    ) -> Result<Pss, Error> {
533        let bad = |m: &str| Err(Error::Build(format!(".pss {m}")));
534        let guess = match p.freq {
535            Some(f) if !(f.is_finite() && f > 0.0) => return bad("frequency must be positive"),
536            Some(f) => Some(1.0 / f),
537            None => None,
538        };
539        let Some(node) = &p.osc_node else {
540            return bad(
541                "of an autonomous circuit (no periodic source) needs the oscillation node \
542                 OSCNODE",
543            );
544        };
545        if p.tstab <= 0.0 {
546            return bad(
547                "of an autonomous circuit needs a stabilization time TSTAB: the oscillation \
548                 must start and be measured before the shooting",
549            );
550        }
551        let built = self.build(opts)?;
552        let mut notes: Vec<String> = built.notes.clone();
553        let mut engine = Engine::new(built);
554        let lc = node.to_ascii_lowercase();
555        let Some(osc) = engine.vars.names.iter().position(|n| *n == lc) else {
556            return Err(Error::Build(format!(".pss: no node `{node}` for oscnode")));
557        };
558        if osc == 0 || engine.vars.kinds[osc] != VarKind::Node {
559            return Err(Error::Build(format!(
560                ".pss: oscnode `{node}` must be a circuit node"
561            )));
562        }
563        // Stabilization grid: with a guess, the driven one; without,
564        // TMAX = TSTAB/STAB_POINTS (the LTE control refines it).
565        let (tstep, max_step) = match guess {
566            Some(t) => (t / p.points as f64, f64::min(t / p.points as f64, t / 50.0)),
567            None => (p.tstab / STAB_POINTS, p.tstab / STAB_POINTS),
568        };
569        let nvars = engine.vars.len() as f64;
570        let pts = p.points as f64;
571        if (pts + 1.0) * nvars > MAX_OUTPUT_VALUES as f64
572            || p.tstab / tstep > 1e8
573            || p.harmonics as f64 * pts * nvars > 1e9
574        {
575            return bad("asks for too many points or periods; refusing");
576        }
577        // The start-up runs on its own method
578        // (`Options::pss_stab_method`): an oscillation has to grow
579        // out of the kick on a grid that does not know its period.
580        let stab_opts = Options {
581            method: Some(stab_method),
582            ..opts.clone()
583        };
584        let rows = engine.tran(
585            &stab_opts,
586            &TranParams {
587                tstep,
588                tstop: p.tstab,
589                tstart: p.tstab * (1.0 - STAB_WINDOW),
590                tmax: Some(max_step),
591                uic: p.uic,
592            },
593        )?;
594        let names = engine.vars.names.clone();
595        let kinds = engine.vars.kinds.clone();
596        let ports = self.line_ports(&names);
597        drop(engine);
598        // Lines: a continuation from the stabilization (the refine
599        // run, and the shooting) reads the last period before it as
600        // its periodic past.
601        let no_past = || {
602            Error::Build(
603                ".pss: the stabilization window holds less than one period before the \
604                 shooting start; lengthen TSTAB"
605                    .into(),
606            )
607        };
608        let past_before = |rows: &[(f64, Vec<f64>)], at: f64, t: f64, max_step: f64| {
609            if ports.is_empty() {
610                Ok(None)
611            } else {
612                Past::from_rows(rows, None, at - t, t, max_step)
613                    .map(Some)
614                    .ok_or_else(no_past)
615            }
616        };
617        let mut start = osc_start(&rows, osc, &lc, guess, opts)?;
618        let mut start_rows = rows;
619        if guess.is_none() {
620            // The stabilization grid knew no period: refine the
621            // estimate (and the start) on a short continuation at
622            // the shooting's own grid, T/POINTS, from where it ended.
623            let (t_end, x_end) = start_rows.last().expect("the stabilization has rows");
624            let t = start.period;
625            let tstep = t / p.points as f64;
626            let refine = PeriodRun {
627                t0: *t_end,
628                period: REFINE_PERIODS * t,
629                tstep,
630                max_step: f64::min(tstep, t / 50.0),
631            };
632            let past = past_before(&start_rows, *t_end, t, max_step)?;
633            let mut engine = Engine::new(self.build(opts)?);
634            engine.set_periodic_past(past.as_ref().map(|p| p.at(t)));
635            let fine = engine.tran_period(opts, &refine, x_end, &mut Vec::new())?;
636            notes.push(start.note);
637            start = osc_start(&fine, osc, &lc, None, opts)?;
638            start_rows = fine;
639            start.note = format!(
640                "{} (refined over {REFINE_PERIODS} periods continued from TSTAB at T/POINTS)",
641                start.note
642            );
643        }
644        notes.push(start.note.clone());
645        let t0 = start.t0;
646        let mut x = start.x0;
647        let mut period = start.period;
648
649        let run_for = |t: f64| PeriodRun {
650            t0,
651            period: t,
652            tstep: t / p.points as f64,
653            max_step: f64::min(t / p.points as f64, t / 50.0),
654        };
655        let first_past = past_before(&start_rows, t0, period, run_for(period).max_step)?;
656        drop(start_rows);
657        let mut shooter = Shooter {
658            circuit: self,
659            opts,
660            tight: tightened(opts),
661            run: run_for(period),
662            kinds: &kinds,
663            adaptive: 0,
664            replays: 0,
665            krylov: Vec::new(),
666            past: first_past,
667            ports: ports.clone(),
668            history: Vec::new(),
669            refresh: Vec::new(),
670        };
671        let mut residuals = Vec::new();
672        let mut periods = Vec::new();
673        let mut last: Option<OscStep> = None;
674        let min_frac = 0.5f64.powi(MAX_BACKTRACK as i32);
675        let fail = |residuals: Vec<f64>, period: f64| Error::PssConvergence {
676            iterations: residuals.len(),
677            residuals,
678            period: Some(period),
679        };
680        let converged = if shooter.past.is_some() {
681            let (r, dt) = shooter.lines_autonomous(
682                &mut x,
683                &mut period,
684                osc,
685                p,
686                &run_for,
687                &mut residuals,
688                &mut periods,
689            )?;
690            last = Some(OscStep {
691                x: Vec::new(),
692                t: period,
693                dx: Vec::new(),
694                dt,
695                base_norm: 0.0,
696                frac: 1.0,
697            });
698            r
699        } else {
700            loop {
701                shooter.run = run_for(period);
702                let (mut period_run, rnorm) = match shooter.adaptive(&x) {
703                    Ok(r) => {
704                        let n = r.rnorm;
705                        (Some(r), n)
706                    }
707                    Err(e) => match &last {
708                        Some(l) if l.frac > min_frac => (None, f64::INFINITY),
709                        Some(_) => {
710                            residuals.push(f64::INFINITY);
711                            periods.push(period);
712                            return Err(fail(residuals, period));
713                        }
714                        None => return Err(e),
715                    },
716                };
717                residuals.push(rnorm);
718                periods.push(period);
719                // At least one Newton step: an estimate already within
720                // the tolerance still gets its T corrected (quadratically)
721                // — the mismatch criterion alone admits a period error of
722                // about tol/|dx/dt| — and the step's δT is reported.
723                if rnorm <= p.steady_coeff
724                    && last.is_some()
725                    && let Some(r) = period_run.take()
726                {
727                    break r;
728                }
729                if residuals.len() >= p.max_iter {
730                    return Err(fail(residuals, period));
731                }
732                if let Some(l) = &mut last
733                    && rnorm > l.base_norm
734                    && l.frac > min_frac
735                {
736                    l.frac *= 0.5;
737                    for ((xi, bx), sx) in x.iter_mut().zip(&l.x).zip(&l.dx) {
738                        *xi = bx + l.frac * sx;
739                    }
740                    period = l.t + l.frac * l.dt;
741                    continue;
742                }
743                let Some(period_run) = period_run else {
744                    unreachable!("a failed run is always backtracked")
745                };
746                let (mut sx, mut st) = shooter.newton_step_osc(&x, period, osc, &period_run)?;
747                // A Newton step never moves T by more than MAX_DT of itself
748                // (far from the orbit the linearized phase drift is not
749                // to be trusted); the whole step is scaled.
750                if st.abs() > MAX_DT * period {
751                    let k = MAX_DT * period / st.abs();
752                    sx.iter_mut().for_each(|v| *v *= k);
753                    st *= k;
754                }
755                let base = x.clone();
756                for i in 1..x.len() {
757                    x[i] += sx[i];
758                }
759                let bt = period;
760                period += st;
761                last = Some(OscStep {
762                    x: base,
763                    t: bt,
764                    dx: sx,
765                    dt: st,
766                    base_norm: rnorm,
767                    frac: 1.0,
768                });
769            }
770        };
771
772        // The trivial periodic solution — the equilibrium — is
773        // periodic for every T; a converged "orbit" with no swing at
774        // OSCNODE is that, not an oscillation.
775        let (lo, hi) = converged
776            .rows
777            .iter()
778            .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), r| {
779                (a.min(r.1[osc]), b.max(r.1[osc]))
780            });
781        if hi - lo <= 10.0 * converged.tol[osc] {
782            return Err(Error::Build(format!(
783                ".pss: the shooting settled on the equilibrium (v({lc}) swings {:e} V); \
784                 the circuit does not oscillate",
785                hi - lo
786            )));
787        }
788        let hist: Vec<String> = residuals
789            .iter()
790            .zip(&periods)
791            .map(|(r, t)| format!("[{r:.3e}, {t:.9e}]"))
792            .collect();
793        let dt = last.as_ref().map_or(0.0, |l| l.dt * l.frac);
794        notes.push(format!(
795            "pss: autonomous oscillator: period {period:e} s (frequency {:e} Hz) is the \
796             shooting's result (last Newton correction {dt:.3e} s); [residual/tolerance, T] \
797             per iteration: {}; phase condition v({lc}) = {:e} V at t0 = {t0:e} s",
798            1.0 / period,
799            hist.join(", "),
800            x[osc]
801        ));
802        notes.push(shooter.summary(&residuals));
803        notes.extend(shooter.lines_note());
804        let (time, spectrum) = outputs(&converged, &names, &kinds, p, t0, period);
805        Ok(Pss {
806            time,
807            spectrum,
808            period,
809            t0,
810            residuals,
811            periods,
812            notes,
813        })
814    }
815}
816
817/// Default stabilization grid of an autonomous `.pss` without a
818/// frequency guess: TMAX = TSTAB / this.
819const STAB_POINTS: f64 = 1000.0;
820/// The fraction of TSTAB (its end) the stabilization keeps and
821/// measures the oscillation on.
822const STAB_WINDOW: f64 = 0.5;
823/// Rising crossings the period estimate averages over (at most).
824const EST_PERIODS: usize = 4;
825/// Length, in estimated periods, of the continuation that refines a
826/// period estimated on the stabilization grid.
827const REFINE_PERIODS: f64 = 5.5;
828/// Grids the shooting with transmission lines converges on: the
829/// first adaptive run's, then one refreshed from the converged orbit.
830const GRID_PASSES: usize = 2;
831/// Largest relative change of `T` in one Newton step.
832const MAX_DT: f64 = 0.2;
833
834/// The last Newton step of an autonomous shooting, for backtracking.
835struct OscStep {
836    /// The base point `(x, T)` …
837    x: Vec<f64>,
838    t: f64,
839    /// … the full step from it …
840    dx: Vec<f64>,
841    dt: f64,
842    /// … the residual at the base, and the fraction of the step taken.
843    base_norm: f64,
844    frac: f64,
845}
846
847/// Where the autonomous shooting starts.
848#[derive(Debug)]
849struct OscStart {
850    /// The rising mid-range crossing of OSCNODE the period starts at.
851    t0: f64,
852    /// The state there (cubic interpolation of the stabilization).
853    x0: Vec<f64>,
854    /// The initial period: the guess, or the crossing estimate.
855    period: f64,
856    note: String,
857}
858
859/// Rising crossings of `level` by unknown `k` over `rows`, linearly
860/// interpolated.
861fn rising_crossings(rows: &[(f64, Vec<f64>)], k: usize, level: f64) -> Vec<f64> {
862    let mut out = Vec::new();
863    for w in rows.windows(2) {
864        let (a, b) = (w[0].1[k], w[1].1[k]);
865        if a < level && b >= level {
866            out.push(w[0].0 + (w[1].0 - w[0].0) * (level - a) / (b - a));
867        }
868    }
869    out
870}
871
872/// The start of the autonomous shooting from the stabilization rows
873/// (the last `STAB_WINDOW` of TSTAB). The level is the mid-range
874/// `(max + min)/2` of OSCNODE — over the last `EST_PERIODS` periods
875/// once a period is known, so a still-growing amplitude does not bias
876/// it; the start `t0` is the last rising crossing of that level,
877/// where `|dv/dt|` is near its largest (the transversality the phase
878/// condition needs). Without a guess the period is the mean spacing
879/// of the last `EST_PERIODS` + 1 rising crossings — a frequency
880/// counter's estimate, exact for any waveform shape once the orbit is
881/// settled, and its spread is reported.
882fn osc_start(
883    rows: &[(f64, Vec<f64>)],
884    osc: usize,
885    name: &str,
886    guess: Option<f64>,
887    opts: &Options,
888) -> Result<OscStart, Error> {
889    let span = |from: f64| {
890        rows.iter()
891            .filter(|r| r.0 >= from)
892            .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), r| {
893                (a.min(r.1[osc]), b.max(r.1[osc]))
894            })
895    };
896    let (Some(first), Some(end)) = (rows.first(), rows.last()) else {
897        return Err(Error::Build(".pss: empty stabilization".into()));
898    };
899    let (t_first, t_end) = (first.0, end.0);
900    let (lo, hi) = span(t_first);
901    let floor = 10.0 * (opts.reltol * lo.abs().max(hi.abs()) + opts.vntol);
902    let quiet = || {
903        Err(Error::Build(format!(
904            ".pss: the circuit does not oscillate at the oscillation node within TSTAB \
905             ({:e} V of swing over the last {:.0}% of it); kick it out of its equilibrium \
906             (an initial condition with UIC, or a supply ramp by PWL/PULSE) or lengthen \
907             TSTAB",
908            hi - lo,
909            100.0 * STAB_WINDOW
910        )))
911    };
912    if (hi - lo).is_nan() || hi - lo <= floor {
913        return quiet();
914    }
915    // First pass: the mid-range of the whole window.
916    let c = rising_crossings(rows, osc, 0.5 * (lo + hi));
917    let estimate = |c: &[f64]| -> Option<(f64, f64)> {
918        if c.len() < 3 {
919            return None;
920        }
921        let k = EST_PERIODS.min(c.len() - 1);
922        let tail = &c[c.len() - 1 - k..];
923        let mean = (tail[k] - tail[0]) / k as f64;
924        let spread = tail
925            .windows(2)
926            .map(|w| ((w[1] - w[0]) / mean - 1.0).abs())
927            .fold(0.0, f64::max);
928        Some((mean, spread))
929    };
930    let rough = match guess {
931        Some(t) => t,
932        None => match estimate(&c) {
933            Some((t, _)) => t,
934            None => {
935                return Err(Error::Build(format!(
936                    ".pss: {} rising crossing(s) of the oscillation node's mid-range in the \
937                     last {:.0}% of TSTAB; estimating the period needs 3 — lengthen TSTAB or \
938                     give FREQ",
939                    c.len(),
940                    100.0 * STAB_WINDOW
941                )));
942            }
943        },
944    };
945    // Second pass: the mid-range of the last few periods.
946    let (lo2, hi2) = span(f64::max(t_first, t_end - EST_PERIODS as f64 * rough));
947    if (hi2 - lo2).is_nan() || hi2 - lo2 <= floor {
948        return quiet();
949    }
950    let level = 0.5 * (lo2 + hi2);
951    let c = rising_crossings(rows, osc, level);
952    let Some(&t0) = c.last() else {
953        return quiet();
954    };
955    // A measured period beats a guess: FREQ sets the stabilization
956    // grid, and seeds T only when the window has too few crossings.
957    let guessed = guess.map_or_else(String::new, |g| format!("; FREQ's guess was {g:e} s"));
958    let (period, how) = match (estimate(&c), guess) {
959        (Some((t, spread)), _) => {
960            let k = EST_PERIODS.min(c.len() - 1);
961            (
962                t,
963                format!(
964                    "estimate {t:e} s from the last {k} periods between rising crossings \
965                     (spread {spread:.1e}){guessed}"
966                ),
967            )
968        }
969        (None, Some(g)) => (
970            g,
971            format!("guess {g:e} s from FREQ (too few crossings to measure one)"),
972        ),
973        (None, None) => (rough, format!("estimate {rough:e} s (first pass)")),
974    };
975    let all = [0, rows.len() - 1];
976    let x0 = interp(rows, &all, t0);
977    Ok(OscStart {
978        t0,
979        x0,
980        period,
981        note: format!(
982            "pss: no periodic source — autonomous oscillator on v({}); initial period {how}; \
983             mid-range level {level:e} V, swing {:e} V; shooting starts at its last rising \
984             crossing, t0 = {t0:e} s",
985            name,
986            hi2 - lo2
987        ),
988    })
989}
990
991/// The fundamental shared by the source frequencies: their greatest
992/// common divisor, to 1e-9 relative, with at most a million
993/// harmonics up to the highest.
994fn harvest(sources: &[(String, f64)]) -> Result<f64, Error> {
995    let Some(fmax) = sources.iter().map(|s| s.1).reduce(f64::max) else {
996        return Err(Error::Build(
997            ".pss: no periodic source; give the fundamental frequency".into(),
998        ));
999    };
1000    let tol = 1e-9 * fmax;
1001    let gcd = |a: f64, b: f64| {
1002        let (mut a, mut b) = (a.max(b), a.min(b));
1003        while b > tol {
1004            let r = (a - b * (a / b).round()).abs();
1005            a = b;
1006            b = if r <= tol { 0.0 } else { r };
1007        }
1008        a
1009    };
1010    let f = sources.iter().map(|s| s.1).fold(0.0, gcd);
1011    if f.is_nan() || f <= 0.0 || fmax / f > 1e6 {
1012        return Err(Error::Build(
1013            ".pss: the source frequencies share no fundamental; give it".into(),
1014        ));
1015    }
1016    Ok(f)
1017}
1018
1019/// The Newton tolerances of the Jacobian replays.
1020fn tightened(opts: &Options) -> Options {
1021    let mut o = opts.clone();
1022    o.reltol *= REPLAY_TIGHTEN;
1023    o.vntol *= REPLAY_TIGHTEN;
1024    o.abstol *= REPLAY_TIGHTEN;
1025    o
1026}
1027
1028/// One adaptive period transient and what the shooting reads off it.
1029struct PeriodOut {
1030    rows: Vec<(f64, Vec<f64>)>,
1031    log: Vec<StepRec>,
1032    /// `x(t₀ + T) − x(t₀)`.
1033    resid: Vec<f64>,
1034    /// Per-unknown tolerance (index 0, ground, is 1).
1035    tol: Vec<f64>,
1036    /// The period mismatch `max_i |x_i(t₀+T) − x_i(t₀)| / tol_i`.
1037    rnorm: f64,
1038    /// Lines (issue #20): how far the orbit is from the periodic past
1039    /// it read, `max |x_i(t) − past_i(t)| / tol_i` over the lines' port
1040    /// unknowns (0 without lines).
1041    hnorm: f64,
1042    /// The period of the run.
1043    period: f64,
1044}
1045
1046impl PeriodOut {
1047    /// The residual reported per iteration: the period mismatch, and
1048    /// the lines' history mismatch when there are lines.
1049    fn total(&self) -> f64 {
1050        self.rnorm.max(self.hnorm)
1051    }
1052}
1053
1054/// Aitken's extrapolation of the autonomous line iteration. Each
1055/// pass of `lines_autonomous` is a fixed-point iteration on
1056/// `(x₀, T, orbit)` whose slowest mode is the nearly neutral phase
1057/// mode of the history (shifting the past in time): the residual
1058/// falls by a constant factor `ρ` per replay (0.85 on `osc_tline`).
1059/// Once three successive residuals show the same `ρ`, the iterate is
1060/// moved to the limit of that geometric sequence, `z + ρ/(1−ρ)·Δz`,
1061/// and the estimate starts over.
1062#[derive(Default)]
1063struct Aitken {
1064    /// The last iterates.
1065    seen: Vec<Iterate>,
1066}
1067
1068/// One iterate of the autonomous line iteration.
1069struct Iterate {
1070    residual: f64,
1071    x: Vec<f64>,
1072    period: f64,
1073    /// The orbit's values, row by row.
1074    orbit: Vec<Vec<f64>>,
1075}
1076
1077impl Aitken {
1078    /// Two successive ratios must agree this closely.
1079    const AGREE: f64 = 0.05;
1080
1081    /// Record the iterate just computed (`cur` is the orbit of
1082    /// `x`/`period` before the Newton step that produced them) and,
1083    /// when the residuals are geometric, extrapolate all three in
1084    /// place.
1085    fn push(&mut self, x: &mut [f64], period: &mut f64, cur: &mut PeriodOut, t0: f64) -> bool {
1086        let values: Vec<Vec<f64>> = cur.rows.iter().map(|(_, v)| v.clone()).collect();
1087        if self
1088            .seen
1089            .last()
1090            .is_some_and(|l| l.orbit.len() != values.len())
1091        {
1092            self.seen.clear();
1093        }
1094        self.seen.push(Iterate {
1095            residual: cur.total(),
1096            x: x.to_vec(),
1097            period: *period,
1098            orbit: values,
1099        });
1100        let n = self.seen.len();
1101        if n < 3 {
1102            return false;
1103        }
1104        let (a, b, c) = (&self.seen[n - 3], &self.seen[n - 2], &self.seen[n - 1]);
1105        let (r1, r2) = (b.residual / a.residual, c.residual / b.residual);
1106        if !(0.3..0.98).contains(&r2) || (r1 - r2).abs() > Self::AGREE * r2 {
1107            return false;
1108        }
1109        let f = r2 / (1.0 - r2);
1110        for (xi, (ci, bi)) in x.iter_mut().zip(c.x.iter().zip(&b.x)) {
1111            *xi += f * (ci - bi);
1112        }
1113        let old = cur.period;
1114        *period += f * (c.period - b.period);
1115        // The orbit moves with it; its times stretch to its period
1116        // the way the grid does.
1117        let new = old + f * (c.period - b.period);
1118        for ((t, v), (cv, bv)) in cur.rows.iter_mut().zip(c.orbit.iter().zip(&b.orbit)) {
1119            *t = t0 + (*t - t0) * new / old;
1120            for (vi, (ci, bi)) in v.iter_mut().zip(cv.iter().zip(bv)) {
1121                *vi += f * (ci - bi);
1122            }
1123        }
1124        for rec in &mut cur.log {
1125            rec.time = t0 + (rec.time - t0) * new / old;
1126        }
1127        cur.period = new;
1128        self.seen.clear();
1129        true
1130    }
1131}
1132
1133/// The periodic past of the transmission lines (issue #20) — the
1134/// device-facing orbit — and the breakpoint rows to interpolate it on
1135/// when checking the next orbit against it.
1136struct Past {
1137    orbit: PeriodicPast,
1138    /// Breakpoint indices into the closed period (0 = the start
1139    /// boundary, 1… the interior samples, `m + 1` the end boundary;
1140    /// only the ends for a past from the stabilization, which has no
1141    /// step log).
1142    breaks: Vec<usize>,
1143}
1144
1145impl Past {
1146    /// The past from `rows` over `[start, start + period]`: its
1147    /// interior samples (boundaries are the shooting state). Samples
1148    /// within `CKTminBreak` of a boundary are left out, as an accept
1149    /// there would be. `log` (a shooting run's) marks the breakpoint
1150    /// rows (`rows[j]` is `log[j − 1]`'s point). `None` when `rows` do
1151    /// not reach back to within a step (`max_step`) of `start`.
1152    fn from_rows(
1153        rows: &[(f64, Vec<f64>)],
1154        log: Option<&[StepRec]>,
1155        start: f64,
1156        period: f64,
1157        max_step: f64,
1158    ) -> Option<Past> {
1159        let mb = 5e-5 * max_step;
1160        // The first row may land up to a step after the boundary (the
1161        // boundary sample is the shooting state anyway).
1162        if rows.first().is_none_or(|r| r.0 > start + max_step + mb) {
1163            return None;
1164        }
1165        let mut phase = Vec::new();
1166        let mut kept = Vec::new();
1167        let mut breaks = vec![0usize];
1168        for (j, (t, x)) in rows.iter().enumerate() {
1169            if *t - start <= mb || start + period - *t <= mb {
1170                continue;
1171            }
1172            phase.push((*t - start) / period);
1173            kept.push(x.clone());
1174            if let Some(log) = log
1175                && j >= 1
1176                && log.get(j - 1).is_some_and(|r| r.brk)
1177            {
1178                breaks.push(phase.len());
1179            }
1180        }
1181        breaks.push(phase.len() + 1);
1182        Some(Past {
1183            orbit: PeriodicPast {
1184                period,
1185                phase,
1186                rows: kept,
1187            },
1188            breaks,
1189        })
1190    }
1191
1192    /// The device-facing past for a run of period `period`.
1193    fn at(&self, period: f64) -> PeriodicPast {
1194        PeriodicPast {
1195            period,
1196            ..self.orbit.clone()
1197        }
1198    }
1199
1200    /// `max_i |x_i(t) − past_i(t)| / tol_i` over the lines' `ports`
1201    /// at the interior rows of a run from `x` at `t0` on the past's own
1202    /// grid ([`interp`] reproduces a sample exactly at its time).
1203    fn mismatch(
1204        &self,
1205        rows: &[(f64, Vec<f64>)],
1206        x: &[f64],
1207        t0: f64,
1208        period: f64,
1209        ports: &[usize],
1210        tol: &[f64],
1211    ) -> f64 {
1212        let mut closed: Vec<(f64, Vec<f64>)> = Vec::with_capacity(self.orbit.phase.len() + 2);
1213        closed.push((0.0, x.to_vec()));
1214        for (u, r) in self.orbit.phase.iter().zip(&self.orbit.rows) {
1215            closed.push((u * period, r.clone()));
1216        }
1217        closed.push((period, x.to_vec()));
1218        let mut worst = 0.0f64;
1219        let n = rows.len();
1220        for (t, sol) in rows.iter().take(n.saturating_sub(1)).skip(1) {
1221            let v = interp(&closed, &self.breaks, t - t0);
1222            for &i in ports {
1223                worst = worst.max((sol[i] - v[i]).abs() / tol[i]);
1224            }
1225        }
1226        worst
1227    }
1228}
1229
1230/// A frozen step grid (an adaptive period run's log) and the run it
1231/// was taken on, replayable at any period by uniform stretching.
1232struct Grid {
1233    run: PeriodRun,
1234    log: Vec<StepRec>,
1235}
1236
1237impl Grid {
1238    fn of(run: PeriodRun, log: &[StepRec]) -> Grid {
1239        Grid {
1240            run,
1241            log: log.to_vec(),
1242        }
1243    }
1244
1245    /// The run and log at period `t`: every time from `t₀` scaled by
1246    /// `t/T` (TSTEP and TMAX too), the end pinned to `t₀ + t`.
1247    fn at(&self, t: f64) -> (PeriodRun, Vec<StepRec>) {
1248        stretch(&self.run, &self.log, t)
1249    }
1250}
1251
1252/// `run`/`log` stretched to period `t` (see [`Grid::at`]); unchanged
1253/// at the run's own period.
1254fn stretch(run: &PeriodRun, log: &[StepRec], t: f64) -> (PeriodRun, Vec<StepRec>) {
1255    if t == run.period {
1256        return (*run, log.to_vec());
1257    }
1258    let k = t / run.period;
1259    let stretched = PeriodRun {
1260        t0: run.t0,
1261        period: t,
1262        tstep: run.tstep * k,
1263        max_step: run.max_step * k,
1264    };
1265    let mut log: Vec<StepRec> = log
1266        .iter()
1267        .map(|r| StepRec {
1268            time: run.t0 + (r.time - run.t0) * k,
1269            ..*r
1270        })
1271        .collect();
1272    if let Some(end) = log.last_mut() {
1273        end.time = run.t0 + t;
1274    }
1275    (stretched, log)
1276}
1277
1278struct Shooter<'a> {
1279    circuit: &'a Circuit,
1280    opts: &'a Options,
1281    tight: Options,
1282    run: PeriodRun,
1283    kinds: &'a [VarKind],
1284    adaptive: usize,
1285    replays: usize,
1286    /// GMRES dimension of each Newton step.
1287    krylov: Vec<usize>,
1288    /// Transmission lines (issue #20): the periodic past they read
1289    /// (`None` without lines), their port unknowns, and the history
1290    /// mismatch of every run.
1291    past: Option<Past>,
1292    ports: Vec<usize>,
1293    history: Vec<f64>,
1294    /// The period mismatch of each grid-refresh run.
1295    refresh: Vec<f64>,
1296}
1297
1298/// A fresh engine for one shooting run, with the lines' periodic past
1299/// (if any) at the run's period.
1300fn period_engine(
1301    circuit: &Circuit,
1302    opts: &Options,
1303    past: Option<&Past>,
1304    period: f64,
1305) -> Result<Engine, Error> {
1306    let mut e = Engine::new(circuit.build(opts)?);
1307    e.set_periodic_past(past.map(|p| p.at(period)));
1308    Ok(e)
1309}
1310
1311impl Shooter<'_> {
1312    fn engine(&self) -> Result<Engine, Error> {
1313        period_engine(self.circuit, self.opts, self.past.as_ref(), self.run.period)
1314    }
1315
1316    /// The lines' note: the history mismatch per frozen-grid
1317    /// iteration and the period mismatch of the grid refresh.
1318    fn lines_note(&self) -> Option<String> {
1319        self.past.as_ref()?;
1320        let fmt = |v: &[f64]| -> String {
1321            if v.is_empty() {
1322                "-".to_string()
1323            } else {
1324                v.iter()
1325                    .map(|r| format!("{r:.3e}"))
1326                    .collect::<Vec<_>>()
1327                    .join(", ")
1328            }
1329        };
1330        Some(format!(
1331            "pss: transmission lines read the orbit as their history (x(t − τ) = \
1332             x(t − τ + kT)): Picard on it, Newton on x0, on a frozen grid (the first \
1333             guess is the stabilization's last period); history mismatch per replay over {} \
1334             line port unknown(s): {}; grid refreshed once from the converged orbit, the \
1335             refresh run's period mismatch (the cross-grid discretization of the lines, not \
1336             iterated on): {}",
1337            self.ports.len(),
1338            fmt(&self.history),
1339            fmt(&self.refresh)
1340        ))
1341    }
1342
1343    /// The adaptive transient over the period from `x`.
1344    fn adaptive(&mut self, x: &[f64]) -> Result<PeriodOut, Error> {
1345        self.adaptive += 1;
1346        let mut log = Vec::new();
1347        let rows = self
1348            .engine()?
1349            .tran_period(self.opts, &self.run, x, &mut log)?;
1350        Ok(self.measure(rows, log, x))
1351    }
1352
1353    /// What the shooting reads off a period run from `x`: the period
1354    /// mismatch and the per-unknown tolerances.
1355    fn measure(&self, rows: Vec<(f64, Vec<f64>)>, log: Vec<StepRec>, x: &[f64]) -> PeriodOut {
1356        let n = x.len();
1357        let last = &rows.last().expect("the period has its end point").1;
1358        let mut peak = vec![0.0f64; n];
1359        for (_, sol) in &rows {
1360            for i in 1..n {
1361                peak[i] = peak[i].max(sol[i].abs());
1362            }
1363        }
1364        let mut tol = vec![1.0; n];
1365        let mut resid = vec![0.0; n];
1366        let mut rnorm = 0.0f64;
1367        for i in 1..n {
1368            let floor = match self.kinds[i] {
1369                VarKind::Branch => self.opts.abstol,
1370                _ => self.opts.vntol,
1371            };
1372            tol[i] = self.opts.reltol * peak[i] + floor;
1373            resid[i] = last[i] - x[i];
1374            rnorm = rnorm.max(resid[i].abs() / tol[i]);
1375        }
1376        PeriodOut {
1377            rows,
1378            log,
1379            resid,
1380            tol,
1381            rnorm,
1382            hnorm: 0.0,
1383            period: self.run.period,
1384        }
1385    }
1386
1387    /// Lines (issue #20): one replay of the interval from `x` on the
1388    /// frozen `grid` (an adaptive run's step log, stretched to the
1389    /// current period), the lines reading the orbit `cur` as their
1390    /// periodic past — the Picard half. The replay's rows sit at the
1391    /// times of the past's samples, so the mismatch between the orbit
1392    /// and the history it read is measured exactly. That past stays
1393    /// the shooter's for the Newton half.
1394    fn replay_on(&mut self, x: &[f64], grid: &Grid, cur: &PeriodOut) -> Result<PeriodOut, Error> {
1395        let (t0, max_step) = (self.run.t0, self.run.max_step);
1396        let past = Past::from_rows(&cur.rows, Some(&cur.log), t0, cur.period, max_step)
1397            .expect("a period run starts at t0");
1398        let (run, log) = grid.at(self.run.period);
1399        self.replays += 1;
1400        let rows = period_engine(self.circuit, self.opts, Some(&past), run.period)?
1401            .tran_replay_rows(self.opts, &run, x, &log)?;
1402        let mut r = self.measure(rows, log, x);
1403        r.hnorm = past.mismatch(&r.rows, x, t0, run.period, &self.ports, &r.tol);
1404        self.history.push(r.hnorm);
1405        self.past = Some(past);
1406        Ok(r)
1407    }
1408
1409    /// The driven shooting with transmission lines (issue #20). The
1410    /// lines' state is their history, which in the periodic regime is
1411    /// the orbit itself, `x(t − τ) = x(t − τ + kT)`; the shooting
1412    /// unknown stays `x₀` and the history is a fixed point solved
1413    /// alongside. An adaptive run (its past: the stabilization's last
1414    /// period) fixes the grid; on that frozen grid each iteration
1415    /// replays the interval with the previous orbit as the past
1416    /// (Picard) and, while the period mismatch exceeds the tolerance,
1417    /// takes a Newton step on `x₀` with that past frozen (the excited
1418    /// shooting's GMRES). Converged on the grid when both the period
1419    /// mismatch and the history mismatch are within tolerance. The
1420    /// grid is then refreshed once — an adaptive run from the result
1421    /// picks one for the converged orbit — and the iteration converges
1422    /// again on it; that orbit is the result.
1423    ///
1424    /// The frozen grid is what lets the history converge at all.
1425    /// Across grids the lines are not reproducible to the SPICE
1426    /// tolerance: the slope rule plants echo breakpoints on the *sign*
1427    /// of a slope (so two runs place a delayed corner up to a step
1428    /// apart — tens of tolerances at the corner), and the LTRA
1429    /// convolution's error is set by its grid, not by RELTOL (it has no
1430    /// LTE control). The refresh run's period mismatch is that
1431    /// cross-grid discretization, and is reported, not iterated on.
1432    fn lines_excited(
1433        &mut self,
1434        x: &mut [f64],
1435        p: &PssParams,
1436        residuals: &mut Vec<f64>,
1437    ) -> Result<PeriodOut, Error> {
1438        let fail = |residuals: &[f64]| Error::PssConvergence {
1439            iterations: residuals.len(),
1440            residuals: residuals.to_vec(),
1441            period: None,
1442        };
1443        let mut seed = self.adaptive(x)?;
1444        residuals.push(seed.rnorm);
1445        for pass in 0..GRID_PASSES {
1446            let grid = Grid::of(self.run, &seed.log);
1447            let mut cur = seed;
1448            let done = loop {
1449                if residuals.len() >= p.max_iter {
1450                    return Err(fail(residuals));
1451                }
1452                let r = self.replay_on(x, &grid, &cur)?;
1453                residuals.push(r.total());
1454                if r.total() <= p.steady_coeff {
1455                    break r;
1456                }
1457                if r.rnorm > p.steady_coeff {
1458                    let step = self.newton_step(x, &r)?;
1459                    for i in 1..x.len() {
1460                        x[i] += step[i];
1461                    }
1462                }
1463                cur = r;
1464            };
1465            if pass + 1 == GRID_PASSES {
1466                return Ok(done);
1467            }
1468            self.past = Some(self.past_of(&done));
1469            seed = self.adaptive(x)?;
1470            residuals.push(seed.rnorm);
1471            self.refresh.push(seed.rnorm);
1472        }
1473        unreachable!("the last pass returns")
1474    }
1475
1476    /// The autonomous shooting with transmission lines: the scheme of
1477    /// [`Shooter::lines_excited`] with the Newton step of
1478    /// [`Shooter::newton_step_osc`] (`x₀` and `T`; the frozen grid and
1479    /// the past stretch with `T`, both being functions of phase), at
1480    /// least one Newton step (D-0035). Returns the orbit and the last
1481    /// period correction.
1482    #[allow(clippy::too_many_arguments)]
1483    fn lines_autonomous(
1484        &mut self,
1485        x: &mut [f64],
1486        period: &mut f64,
1487        osc: usize,
1488        p: &PssParams,
1489        run_for: &dyn Fn(f64) -> PeriodRun,
1490        residuals: &mut Vec<f64>,
1491        periods: &mut Vec<f64>,
1492    ) -> Result<(PeriodOut, f64), Error> {
1493        let fail = |residuals: &[f64], period: f64| Error::PssConvergence {
1494            iterations: residuals.len(),
1495            residuals: residuals.to_vec(),
1496            period: Some(period),
1497        };
1498        self.run = run_for(*period);
1499        let mut seed = self.adaptive(x)?;
1500        residuals.push(seed.rnorm);
1501        periods.push(*period);
1502        let mut dt = 0.0;
1503        let mut stepped = false;
1504        for pass in 0..GRID_PASSES {
1505            let grid = Grid::of(self.run, &seed.log);
1506            let mut cur = seed;
1507            let mut aitken = Aitken::default();
1508            // The replay after an extrapolation only measures it.
1509            let mut settle = false;
1510            let done = loop {
1511                if residuals.len() >= p.max_iter {
1512                    return Err(fail(residuals, *period));
1513                }
1514                self.run = run_for(*period);
1515                let r = self.replay_on(x, &grid, &cur)?;
1516                residuals.push(r.total());
1517                periods.push(*period);
1518                if r.total() <= p.steady_coeff && stepped {
1519                    break r;
1520                }
1521                if (r.rnorm > p.steady_coeff && !settle) || !stepped {
1522                    let (mut sx, mut st) = self.newton_step_osc(x, *period, osc, &r)?;
1523                    if st.abs() > MAX_DT * *period {
1524                        let k = MAX_DT * *period / st.abs();
1525                        sx.iter_mut().for_each(|v| *v *= k);
1526                        st *= k;
1527                    }
1528                    for i in 1..x.len() {
1529                        x[i] += sx[i];
1530                    }
1531                    *period += st;
1532                    dt = st;
1533                    stepped = true;
1534                }
1535                cur = r;
1536                settle = aitken.push(x, period, &mut cur, self.run.t0);
1537            };
1538            if pass + 1 == GRID_PASSES {
1539                return Ok((done, dt));
1540            }
1541            self.run = run_for(*period);
1542            self.past = Some(self.past_of(&done));
1543            seed = self.adaptive(x)?;
1544            residuals.push(seed.rnorm);
1545            periods.push(*period);
1546            self.refresh.push(seed.rnorm);
1547        }
1548        unreachable!("the last pass returns")
1549    }
1550
1551    /// The periodic past a converged orbit leaves for the next run.
1552    fn past_of(&self, done: &PeriodOut) -> Past {
1553        Past::from_rows(
1554            &done.rows,
1555            Some(&done.log),
1556            self.run.t0,
1557            done.period,
1558            self.run.max_step,
1559        )
1560        .expect("a period run starts at t0")
1561    }
1562
1563    /// The Newton step `δ` of `(I − Φ′) δ = Φ(x) − x`, by GMRES on the
1564    /// scaled unknowns `x_i / tol_i` with `Φ′ v` from frozen-grid
1565    /// replays. The replays first run at tightened Newton tolerances;
1566    /// if one of them fails, the step is redone at the deck's.
1567    fn newton_step(&mut self, x: &[f64], at: &PeriodOut) -> Result<Vec<f64>, Error> {
1568        match self.newton_step_with(x, at, true) {
1569            Ok(s) => Ok(s),
1570            Err(Error::IterLim) => self.newton_step_with(x, at, false),
1571            Err(e) => Err(e),
1572        }
1573    }
1574
1575    fn newton_step_with(
1576        &mut self,
1577        x: &[f64],
1578        at: &PeriodOut,
1579        tight: bool,
1580    ) -> Result<Vec<f64>, Error> {
1581        let opts = if tight { &self.tight } else { self.opts };
1582        let (circuit, build_opts, run) = (self.circuit, self.opts, self.run);
1583        let past = self.past.as_ref();
1584        let n = x.len();
1585        let d = &at.tol;
1586        let replays = std::cell::Cell::new(0usize);
1587        let replay = |x0: &[f64]| -> Result<Vec<f64>, Error> {
1588            replays.set(replays.get() + 1);
1589            period_engine(circuit, build_opts, past, run.period)?
1590                .tran_replay(opts, &run, x0, &at.log)
1591        };
1592        let base = replay(x)?;
1593        // Scaled residual, unknowns 1..n.
1594        let b: Vec<f64> = (1..n).map(|i| at.resid[i] / d[i]).collect();
1595        let bnorm = norm(&b);
1596        let lin_tol = f64::min(1e-2, 1e-4 * bnorm);
1597        let mut apply = |v: &[f64]| -> Result<Vec<f64>, Error> {
1598            let mut xp = x.to_vec();
1599            for i in 1..n {
1600                xp[i] += FD_EPS * d[i] * v[i - 1];
1601            }
1602            let y = replay(&xp)?;
1603            Ok((1..n)
1604                .map(|i| v[i - 1] - (y[i] - base[i]) / (FD_EPS * d[i]))
1605                .collect())
1606        };
1607        let solved = gmres(&b, n - 1, lin_tol, &mut apply);
1608        self.replays += replays.get();
1609        let (ds, dim) = solved?;
1610        self.krylov.push(dim);
1611        let mut step = vec![0.0; n];
1612        for i in 1..n {
1613            step[i] = ds[i - 1] * d[i];
1614        }
1615        Ok(step)
1616    }
1617
1618    /// The Newton step `(δx, δT)` of an autonomous oscillator: the
1619    /// bordered system `(I − Φ′) δx − (∂Φ/∂T) δT = Φ_T(x) − x` with
1620    /// `δx[osc] = 0` (the phase condition fixes OSCNODE), square
1621    /// again because `T` takes the place of `x[osc]` among the
1622    /// unknowns. GMRES on the scaled unknowns (`x_i / tol_i`,
1623    /// `T / (RELTOL·T)`); one replay per product perturbs `x` and `T`
1624    /// together, `T` by stretching the frozen grid uniformly — the
1625    /// replay is then smooth in `T` as it is in `x` (every step, and
1626    /// every coefficient of the integration formulas, scales with it).
1627    fn newton_step_osc(
1628        &mut self,
1629        x: &[f64],
1630        period: f64,
1631        osc: usize,
1632        at: &PeriodOut,
1633    ) -> Result<(Vec<f64>, f64), Error> {
1634        match self.newton_step_osc_with(x, period, osc, at, true) {
1635            Ok(s) => Ok(s),
1636            Err(Error::IterLim) => self.newton_step_osc_with(x, period, osc, at, false),
1637            Err(e) => Err(e),
1638        }
1639    }
1640
1641    fn newton_step_osc_with(
1642        &mut self,
1643        x: &[f64],
1644        period: f64,
1645        osc: usize,
1646        at: &PeriodOut,
1647        tight: bool,
1648    ) -> Result<(Vec<f64>, f64), Error> {
1649        let opts = if tight { &self.tight } else { self.opts };
1650        let (circuit, build_opts, run) = (self.circuit, self.opts, self.run);
1651        let n = x.len();
1652        let d = &at.tol;
1653        let s_t = self.opts.reltol * period;
1654        // Unknown i sits in slot i − 1 and T in OSCNODE's slot, so
1655        // the operator stays identity plus low rank (the identity's
1656        // osc entry is replaced by the −∂Φ/∂T column), which is what
1657        // makes GMRES converge in a few products.
1658        let slot = |i: usize| i - 1;
1659        let t_slot = osc - 1;
1660        let replays = std::cell::Cell::new(0usize);
1661        let past = self.past.as_ref();
1662        let replay = |x0: &[f64], t: f64| -> Result<Vec<f64>, Error> {
1663            replays.set(replays.get() + 1);
1664            // Lines: the past stretches with the period (it is the
1665            // orbit as a function of phase).
1666            let mut engine = period_engine(circuit, build_opts, past, t)?;
1667            if t == period {
1668                return engine.tran_replay(opts, &run, x0, &at.log);
1669            }
1670            let k = t / period;
1671            let stretched = PeriodRun {
1672                t0: run.t0,
1673                period: t,
1674                tstep: run.tstep * k,
1675                max_step: run.max_step * k,
1676            };
1677            let mut log: Vec<StepRec> = at
1678                .log
1679                .iter()
1680                .map(|r| StepRec {
1681                    time: run.t0 + (r.time - run.t0) * k,
1682                    ..*r
1683                })
1684                .collect();
1685            if let Some(end) = log.last_mut() {
1686                end.time = run.t0 + t;
1687            }
1688            engine.tran_replay(opts, &stretched, x0, &log)
1689        };
1690        let base = replay(x, period)?;
1691        let b: Vec<f64> = (1..n).map(|i| at.resid[i] / d[i]).collect();
1692        let bnorm = norm(&b);
1693        let lin_tol = f64::min(1e-2, 1e-4 * bnorm);
1694        let mut apply = |u: &[f64]| -> Result<Vec<f64>, Error> {
1695            let mut xp = x.to_vec();
1696            for i in 1..n {
1697                if i != osc {
1698                    xp[i] += FD_EPS * d[i] * u[slot(i)];
1699                }
1700            }
1701            let y = replay(&xp, period + FD_EPS * s_t * u[t_slot])?;
1702            Ok((1..n)
1703                .map(|i| {
1704                    let ui = if i == osc { 0.0 } else { u[slot(i)] };
1705                    ui - (y[i] - base[i]) / (FD_EPS * d[i])
1706                })
1707                .collect())
1708        };
1709        let solved = gmres(&b, n - 1, lin_tol, &mut apply);
1710        self.replays += replays.get();
1711        let (us, dim) = solved?;
1712        self.krylov.push(dim);
1713        let mut step = vec![0.0; n];
1714        for i in 1..n {
1715            if i != osc {
1716                step[i] = us[slot(i)] * d[i];
1717            }
1718        }
1719        Ok((step, us[t_slot] * s_t))
1720    }
1721
1722    fn summary(&self, residuals: &[f64]) -> String {
1723        let hist: Vec<String> = residuals.iter().map(|r| format!("{r:.3e}")).collect();
1724        format!(
1725            "pss: {} shooting iteration(s), residual/tolerance per iteration: {}; \
1726             {} adaptive and {} replayed period transients (GMRES dimensions {:?})",
1727            residuals.len(),
1728            hist.join(", "),
1729            self.adaptive,
1730            self.replays,
1731            self.krylov
1732        )
1733    }
1734}
1735
1736/// A matrix-free operator `v ↦ A v` (one product may run transients).
1737type LinearMap<'a> = dyn FnMut(&[f64]) -> Result<Vec<f64>, Error> + 'a;
1738
1739fn norm(v: &[f64]) -> f64 {
1740    v.iter().map(|x| x * x).sum::<f64>().sqrt()
1741}
1742
1743/// Restart-free GMRES (Saad–Schultz, modified Gram–Schmidt with one
1744/// reorthogonalization, Givens rotations) for `A y = b` from `y = 0`,
1745/// at most `m` iterations, stopping at residual `≤ tol`. Returns the
1746/// solution and the Krylov dimension used.
1747fn gmres(b: &[f64], m: usize, tol: f64, apply: &mut LinearMap) -> Result<(Vec<f64>, usize), Error> {
1748    let n = b.len();
1749    let beta = norm(b);
1750    if beta == 0.0 || m == 0 {
1751        return Ok((vec![0.0; n], 0));
1752    }
1753    let mut v: Vec<Vec<f64>> = vec![b.iter().map(|x| x / beta).collect()];
1754    let mut h: Vec<Vec<f64>> = Vec::new(); // columns, each of length j + 2
1755    let mut cs: Vec<f64> = Vec::new();
1756    let mut sn: Vec<f64> = Vec::new();
1757    let mut g = vec![beta];
1758    let mut k = 0;
1759    while k < m {
1760        let mut w = apply(&v[k])?;
1761        let mut col = vec![0.0; k + 2];
1762        for _pass in 0..2 {
1763            for (i, vi) in v.iter().enumerate() {
1764                let c: f64 = w.iter().zip(vi).map(|(a, b)| a * b).sum();
1765                col[i] += c;
1766                for (wj, vj) in w.iter_mut().zip(vi) {
1767                    *wj -= c * vj;
1768                }
1769            }
1770        }
1771        let hn = norm(&w);
1772        col[k + 1] = hn;
1773        for i in 0..k {
1774            let t = cs[i] * col[i] + sn[i] * col[i + 1];
1775            col[i + 1] = -sn[i] * col[i] + cs[i] * col[i + 1];
1776            col[i] = t;
1777        }
1778        let r = col[k].hypot(col[k + 1]);
1779        let (c, s) = if r == 0.0 {
1780            (1.0, 0.0)
1781        } else {
1782            (col[k] / r, col[k + 1] / r)
1783        };
1784        col[k] = r;
1785        col[k + 1] = 0.0;
1786        cs.push(c);
1787        sn.push(s);
1788        g.push(-s * g[k]);
1789        g[k] *= c;
1790        h.push(col);
1791        k += 1;
1792        let breakdown = hn <= 1e-14 * beta;
1793        if g[k].abs() <= tol || breakdown {
1794            break;
1795        }
1796        v.push(w.iter().map(|x| x / hn).collect());
1797    }
1798    // Back substitution on the k×k triangle.
1799    let mut y = vec![0.0; k];
1800    for i in (0..k).rev() {
1801        let mut s = g[i];
1802        for j in i + 1..k {
1803            s -= h[j][i] * y[j];
1804        }
1805        y[i] = if h[i][i] != 0.0 { s / h[i][i] } else { 0.0 };
1806    }
1807    let mut x = vec![0.0; n];
1808    for (j, yj) in y.iter().enumerate() {
1809        for (xi, vi) in x.iter_mut().zip(&v[j]) {
1810            *xi += yj * vi;
1811        }
1812    }
1813    Ok((x, k))
1814}
1815
1816/// The time-domain and frequency-domain plots of the converged period.
1817fn outputs(
1818    per: &PeriodOut,
1819    names: &[String],
1820    kinds: &[VarKind],
1821    p: &PssParams,
1822    t0: f64,
1823    period: f64,
1824) -> (Solution, Solution) {
1825    let n_pts = p.points;
1826    let times: Vec<f64> = (0..=n_pts)
1827        .map(|k| t0 + period * k as f64 / n_pts as f64)
1828        .collect();
1829    // The rows at breakpoints (and both ends): a stencil never
1830    // straddles a source corner.
1831    let rows = &per.rows;
1832    let mut breaks = vec![0usize];
1833    breaks.extend((1..rows.len()).filter(|&j| per.log[j - 1].brk));
1834    if *breaks.last().unwrap_or(&0) != rows.len() - 1 {
1835        breaks.push(rows.len() - 1);
1836    }
1837
1838    let mut variables = vec![("time".to_string(), VarType::Time)];
1839    let mut sel: Vec<usize> = Vec::new();
1840    for i in 1..names.len() {
1841        match kinds[i] {
1842            VarKind::Node if saved_node(&names[i]) => {
1843                variables.push((format!("v({})", names[i]), VarType::Voltage));
1844                sel.push(i);
1845            }
1846            VarKind::Branch => {
1847                variables.push((format!("i({})", names[i]), VarType::Current));
1848                sel.push(i);
1849            }
1850            _ => {}
1851        }
1852    }
1853    let mut cols: Vec<Vec<f64>> = vec![times.clone()];
1854    cols.extend(sel.iter().map(|_| Vec::with_capacity(times.len())));
1855    for (k, &t) in times.iter().enumerate() {
1856        let vals: Vec<f64> = if k == n_pts {
1857            rows[rows.len() - 1].1.clone()
1858        } else if k == 0 {
1859            rows[0].1.clone()
1860        } else {
1861            interp(rows, &breaks, t)
1862        };
1863        for (c, &i) in sel.iter().enumerate() {
1864            cols[c + 1].push(vals[i]);
1865        }
1866    }
1867
1868    // DFT of the first `points` samples (the last one closes the period).
1869    let h = p.harmonics;
1870    let nn = n_pts as f64;
1871    let mut fvars = vec![("frequency".to_string(), VarType::Frequency)];
1872    fvars.extend(variables[1..].iter().cloned());
1873    let mut fcols: Vec<Vec<Complex64>> = vec![
1874        (0..h)
1875            .map(|k| Complex64::new(k as f64 / period, 0.0))
1876            .collect(),
1877    ];
1878    for col in &cols[1..] {
1879        let samples = &col[..n_pts];
1880        let mut out = Vec::with_capacity(h);
1881        for k in 0..h {
1882            let mut acc = Complex64::new(0.0, 0.0);
1883            for (m, &s) in samples.iter().enumerate() {
1884                // Reduce k·m mod N before the angle: exact arguments.
1885                let km = (k * m) % n_pts;
1886                let ang = -2.0 * std::f64::consts::PI * km as f64 / nn;
1887                acc += s * Complex64::new(ang.cos(), ang.sin());
1888            }
1889            out.push(if k == 0 { acc / nn } else { acc * (2.0 / nn) });
1890        }
1891        fcols.push(out);
1892    }
1893    (
1894        Solution {
1895            plotname: PSS_TIME_PLOT.to_string(),
1896            variables,
1897            data: Data::Real(cols),
1898            scale: Some(0),
1899        },
1900        Solution {
1901            plotname: PSS_FREQ_PLOT.to_string(),
1902            variables: fvars,
1903            data: Data::Complex(fcols),
1904            scale: Some(0),
1905        },
1906    )
1907}
1908
1909/// The solution at `t` by cubic Lagrange interpolation over the four
1910/// accepted points around it, kept inside the breakpoint segment
1911/// (lower order when the segment has fewer points; `breaks` are the
1912/// sorted breakpoint rows, both ends included).
1913fn interp(rows: &[(f64, Vec<f64>)], breaks: &[usize], t: f64) -> Vec<f64> {
1914    // Rows j and j+1 bracket t.
1915    let j = rows.partition_point(|r| r.0 <= t).clamp(1, rows.len() - 1) - 1;
1916    let b = breaks.partition_point(|&b| b <= j);
1917    let lo = breaks[b - 1];
1918    let hi = breaks.get(b).copied().unwrap_or(rows.len() - 1).max(j + 1);
1919    let want = 4usize.min(hi - lo + 1);
1920    let mut a = j.saturating_sub(1).max(lo);
1921    if a + want - 1 > hi {
1922        a = hi + 1 - want;
1923    }
1924    let n = rows[0].1.len();
1925    let mut out = vec![0.0; n];
1926    for p in a..a + want {
1927        let mut w = 1.0;
1928        for q in a..a + want {
1929            if q != p {
1930                w *= (t - rows[q].0) / (rows[p].0 - rows[q].0);
1931            }
1932        }
1933        for (o, v) in out.iter_mut().zip(&rows[p].1) {
1934            *o += w * v;
1935        }
1936    }
1937    out
1938}
1939
1940#[cfg(test)]
1941mod tests {
1942    use super::*;
1943
1944    #[test]
1945    fn harvest_takes_the_common_fundamental() {
1946        let s = |f: &[f64]| -> Vec<(String, f64)> { f.iter().map(|&f| ("v".into(), f)).collect() };
1947        assert!((harvest(&s(&[1e3])).unwrap() - 1e3).abs() < 1e-9);
1948        assert!((harvest(&s(&[1e3, 3e3])).unwrap() - 1e3).abs() < 1e-6);
1949        assert!((harvest(&s(&[2e3, 3e3])).unwrap() - 1e3).abs() < 1e-6);
1950        assert!((harvest(&s(&[50e3, 1.0 / 20e-6])).unwrap() - 50e3).abs() < 1e-3);
1951        assert!(harvest(&s(&[1.0, std::f64::consts::PI])).is_err());
1952        assert!(harvest(&[]).is_err());
1953    }
1954
1955    #[test]
1956    fn gmres_solves_a_small_system() {
1957        // A = I - u wᵀ (a rank-one monodromy): converges in 2 steps.
1958        let u = [0.5, -0.2, 0.1];
1959        let w = [0.3, 0.4, -0.6];
1960        let mut apply = |v: &[f64]| -> Result<Vec<f64>, Error> {
1961            let d: f64 = w.iter().zip(v).map(|(a, b)| a * b).sum();
1962            Ok(v.iter().zip(&u).map(|(vi, ui)| vi - ui * d).collect())
1963        };
1964        let b = [1.0, 2.0, 3.0];
1965        let (y, k) = gmres(&b, 3, 1e-13, &mut apply).unwrap();
1966        assert!(k <= 2);
1967        let ay = apply(&y).unwrap();
1968        for (a, b) in ay.iter().zip(&b) {
1969            assert!((a - b).abs() < 1e-12);
1970        }
1971    }
1972}