Skip to main content

dune_core/
ltra.rs

1//! Lossy transmission line, Roychowdhury's convolution model (the 3f5
2//! `LTRA` device: `dev/ltra/ltraset.c`, `ltratemp.c`, `ltraload.c`,
3//! `ltraacct.c`, `ltratrun.c`, `ltraacld.c`, `ltramisc.c`).
4//!
5//! Each port obeys a branch equation (`LTRAbrEq1/2`, created with
6//! `CKTmkVolt`, so the front end and the convergence test treat them
7//! as *voltage* unknowns even though they carry the port currents):
8//!
9//! ```text
10//! Y0·v1(t) − i1(t) = Y0·(h1' ∗ v1) + (h2 ∗ i2)(t−T) + Y0·(h3' ∗ v2)(t−T)
11//!                    + e^{−βT}·(Y0·v2(t−T) + i2(t−T))
12//! ```
13//!
14//! (and symmetrically for port 2), with `T = l·sqrt(LC)` the delay,
15//! `Y0 = sqrt(C/L)`, `α = β = R/2L` (G = 0). The impulse responses
16//! (`ltramisc.c`, tilde-functions of the paper):
17//!
18//! - `h1'(t) = α·e^{−βt}·(I1(αt) − I0(αt))`
19//! - `h2(t)  = α²T·e^{−βt}·I1(α·√(t²−T²))/(α·√(t²−T²))`, `t ≥ T`
20//! - `h3'(t) = α·e^{−βt}·(t/√(t²−T²)·I1(α√(t²−T²)) − I0(α√(t²−T²)))`
21//!
22//! are convolved with the piecewise-linear port histories: the
23//! coefficient lists (`LTRArlcCoeffsSetup`, `LTRArcCoeffsSetup`) are
24//! second differences of the analytic twice-integrated responses on
25//! the accepted-timepoint grid (`CKTtimePoints`), recomputed on the
26//! first Newton iteration of every timepoint. Delayed values come
27//! from quadratic/linear interpolation in that grid. Four regimes:
28//! LC (lossless), RLC, RC (`L = G = 0`, erfc kernels) and RG (a
29//! frequency-independent attenuator stamped like DC).
30//!
31//! The line keeps its own copy of the accepted-timepoint list (the
32//! C's global `CKTtimePoints`, which `DCtran` only maintains when an
33//! LTRA instance exists); each instance stores its port histories.
34//! `.options trytocompact` (`ltraacct.c`) is ported: after an accepted
35//! point where the last three samples of *every* line's port
36//! variables are straight (`LtraI::straight_tail`), the engine drops
37//! the middle sample of every line (`LtraI::drop_middle`) — the C's
38//! shared `CKTtimePoints` list, kept per instance here in lockstep.
39//! Not ported: `LTRAlteCalculate`: its LTE control needs
40//! `fullcontrol`/`halfcontrol`, which the 3f5 (and ngspice) parameter
41//! tables never accept, so it is unreachable.
42//!
43//! Dune's opt-in recursive mode (`Options::ltra_fit`, issue #8)
44//! replaces the RLC/RC coefficient walk with the exponential sums of
45//! [`crate::ltrafit`], advanced once per accepted point; off, every
46//! line of the faithful path runs unchanged.
47
48use crate::devices::Device;
49use crate::disto::DistoSetupCtx;
50use crate::integ::TerrCtx;
51use crate::ltrafit::{
52    ExpFit, RC_WINDOW_FRACTION, fit_rc, fit_rlc, i0e, i1e, i1e_over_x, rc_integrals, segment,
53};
54use crate::matrix::{
55    AcCtx, AcceptCtx, InitMode, LoadCtx, MatrixBuilder, PeriodicPast, Slot, TRASH, Var,
56};
57
58// Every transcendental in this file goes through the pure-Rust `libm`
59// crate (fdlibm lineage — what `f64::exp` already lowers to on wasm32),
60// not `f64::exp`/`cos`/…, which call the platform C library (Apple's
61// libm and glibc disagree in the last ulp). The line's own kernels
62// (`e^{−βt}·I0/I1`, erfc) then compute the same bits on every target;
63// `sqrt` and the arithmetic are IEEE-exact already. This does NOT make
64// the ringing decks (ltra_1/ltra_3) platform-independent: their
65// trajectory forks on ulps from anywhere (diode/BJT `exp`, the LU), and
66// once the dioload string rings period-2, `accept`'s slope test plants
67// a breakpoint at `t+T` on nearly every step, which forces tiny steps
68// at `t+T`, which ring and plant more — a cascade faithful to
69// ltraacct.c that never ends inside the output guard.
70use libm::{atan, cos, exp, expm1, sin};
71
72/// How the delayed port values `v(t−T)`, `i(t−T)` are interpolated
73/// from the accepted timepoints (`LININTERP`/`QUADINTERP`/
74/// `MIXEDINTERP`).
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum LtraInterp {
77    /// `LININTERP`: linear between the two bracketing points.
78    Linear,
79    /// `QUADINTERP` (the default): quadratic through three points.
80    Quadratic,
81    /// `MIXEDINTERP`: quadratic, falling back to linear when the
82    /// quadratic value leaves the range of the three samples.
83    Mixed,
84}
85
86/// `.model … ltra(…)` parameters, 3f5 names (`ltra.c` model table,
87/// `ltrampar.c`; `spice.txt` §Lossy Transmission Lines). Per-unit-
88/// length quantities are multiplied by `LEN`.
89#[derive(Clone, Debug)]
90pub struct LtraModel {
91    /// `R`: resistance per unit length (Ω); unset = 0 with a warning.
92    pub r: Option<f64>,
93    /// `L`: inductance per unit length (H); unset = 0 with a warning.
94    pub l: Option<f64>,
95    /// `G`: conductance per unit length (S); unset = 0.
96    pub g: Option<f64>,
97    /// `C`: capacitance per unit length (F); unset = 0 with a
98    /// warning.
99    pub c: Option<f64>,
100    /// `LEN`: length of the line; required.
101    pub len: Option<f64>,
102    /// `REL`: relative rate of change of the derivative for breakpoint
103    /// setting, default 1. (An output-only parameter in the 3f5
104    /// table; ngspice accepts it as input — corpus/KNOWN_DIFFS.md.)
105    pub rel: Option<f64>,
106    /// `ABS`: absolute rate of change of the derivative for
107    /// breakpoint setting, default 1 (same 3f5/ngspice note).
108    pub abs: Option<f64>,
109    /// `STEPLIMIT` (true, the default) / `NOSTEPLIMIT` (false): limit
110    /// the timestep to the line delay `T`. The last flag wins.
111    pub steplimit: bool,
112    /// `LININTERP`/`QUADINTERP`/`MIXEDINTERP`; `None` = quadratic
113    /// (linear under `.options trytocompact`).
114    pub interp: Option<LtraInterp>,
115    /// `TRUNCNR`: Newton iterations for the LTE step estimate (only
116    /// reachable with LTE control, which the parameter table never
117    /// enables; kept for completeness).
118    pub truncnr: bool,
119    /// `TRUNCDONTCUT`: do not limit the step by the impulse-response
120    /// safe step (`LTRAmaxSafeStep`) of RLC lines.
121    pub truncdontcut: bool,
122    /// `COMPACTREL`: reltol for the straight-line checks; 0 = the
123    /// circuit `RELTOL`.
124    pub compactrel: f64,
125    /// `COMPACTABS`: abstol for the straight-line checks; 0 = the
126    /// circuit `ABSTOL`.
127    pub compactabs: f64,
128}
129
130impl Default for LtraModel {
131    fn default() -> Self {
132        LtraModel {
133            r: None,
134            l: None,
135            g: None,
136            c: None,
137            len: None,
138            rel: None,
139            abs: None,
140            steplimit: true,
141            interp: None,
142            truncnr: false,
143            truncdontcut: false,
144            compactrel: 0.0,
145            compactabs: 0.0,
146        }
147    }
148}
149
150/// `LTRAspecialCase`.
151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
152pub(crate) enum LtraCase {
153    Lc,
154    Rlc,
155    Rc,
156    Rg,
157}
158
159/// Model quantities after `LTRAsetup` + `LTRAtemp` (and the RG
160/// constants `LTRAload` computes in DC mode, which depend on nothing
161/// else).
162#[derive(Clone, Debug)]
163pub(crate) struct LtraParams {
164    /// The instance name, for notes.
165    pub name: String,
166    pub case: LtraCase,
167    resist: f64,
168    induct: f64,
169    capac: f64,
170    length: f64,
171    /// `LTRAtd`, the propagation delay.
172    pub td: f64,
173    imped: f64,
174    admit: f64,
175    alpha: f64,
176    beta: f64,
177    /// `e^{−βT}`; 1 for LC, unset (0) for RC/RG like the C.
178    attenuation: f64,
179    cbyr: f64,
180    rclsqr: f64,
181    int_h1dash: f64,
182    int_h2: f64,
183    int_h3dash: f64,
184    cosh_lroot_gr: f64,
185    r_rs_lr_gr_or_g: f64,
186    r_gs_lr_gr_or_r: f64,
187    reltol: f64,
188    abstol: f64,
189    /// `LTRAchopReltol`: never settable (`choprel` is `#ifdef
190    /// notdef` in the table), so always 0.
191    chop_reltol: f64,
192    step_limit: bool,
193    interp: LtraInterp,
194    trunc_dont_cut: bool,
195    max_safe_step: f64,
196    /// `LTRAstLineReltol`/`LTRAstLineAbstol` (`COMPACTREL`/
197    /// `COMPACTABS`, the circuit tolerances when 0): the straight-line
198    /// checks of the safe step and of `trytocompact`.
199    st_line_reltol: f64,
200    st_line_abstol: f64,
201    /// Whether the model card gave an interpolation flag (`LTRAsetup`
202    /// only picks the default when none was given).
203    interp_given: bool,
204    /// Setup warnings (`IFerror(ERR_WARNING, …)`).
205    pub notes: Vec<String>,
206}
207
208impl LtraParams {
209    /// `LTRAsetup` (validation, defaults, case classification) and
210    /// `LTRAtemp` (derived constants). `reltol`/`abstol` are the
211    /// circuit tolerances the straight-line checks default to.
212    pub fn new(name: &str, m: &LtraModel, reltol: f64, abstol: f64) -> Result<Self, String> {
213        let mut notes = Vec::new();
214        for (key, v) in [
215            ("r", m.r),
216            ("l", m.l),
217            ("g", m.g),
218            ("c", m.c),
219            ("len", m.len),
220            ("rel", m.rel),
221            ("abs", m.abs),
222        ] {
223            if let Some(v) = v
224                && !v.is_finite()
225            {
226                return Err(format!(
227                    "`{name}`: lossy line parameter {key} is not finite"
228                ));
229            }
230        }
231        let resist = match m.r {
232            Some(v) => v,
233            None => {
234                notes.push(format!(
235                    "`{name}`: lossy line series resistance not given, assumed zero"
236                ));
237                0.0
238            }
239        };
240        let st_line_reltol = if m.compactrel == 0.0 {
241            reltol
242        } else {
243            m.compactrel
244        };
245        let st_line_abstol = if m.compactabs == 0.0 {
246            abstol
247        } else {
248            m.compactabs
249        };
250        let interp = m.interp.unwrap_or(LtraInterp::Quadratic);
251        let conduct = m.g.unwrap_or(0.0);
252        let induct = match m.l {
253            Some(v) => v,
254            None => {
255                notes.push(format!(
256                    "`{name}`: lossy line series inductance not given, assumed zero"
257                ));
258                0.0
259            }
260        };
261        // The C reports a missing C as ERR_FATAL but carries on with
262        // zero (no `return`), so it is a warning in effect.
263        let capac = match m.c {
264            Some(v) => v,
265            None => {
266                notes.push(format!(
267                    "`{name}`: lossy line parallel capacitance not given, assumed zero"
268                ));
269                0.0
270            }
271        };
272        let Some(length) = m.len else {
273            return Err(format!("`{name}`: lossy line length must be given"));
274        };
275
276        // Classification, in the C's order (later tests win, errors
277        // return at once).
278        let (r, g, l, c) = (resist, conduct, induct, capac);
279        let mut case = None;
280        if r == 0.0 && g == 0.0 && c != 0.0 && l != 0.0 {
281            case = Some(LtraCase::Lc);
282        }
283        if r != 0.0 && g == 0.0 && c != 0.0 && l != 0.0 {
284            case = Some(LtraCase::Rlc);
285        }
286        if r != 0.0 && g == 0.0 && c != 0.0 && l == 0.0 {
287            case = Some(LtraCase::Rc);
288        }
289        if r != 0.0 && g == 0.0 && c == 0.0 && l != 0.0 {
290            return Err(format!("`{name}`: RL line not supported yet"));
291        }
292        if r != 0.0 && g != 0.0 && c == 0.0 && l == 0.0 {
293            case = Some(LtraCase::Rg);
294        }
295        if g != 0.0 && (c != 0.0 || l != 0.0) {
296            return Err(format!(
297                "`{name}`: Nonzero G (except RG) line not supported yet"
298            ));
299        }
300        let nonzero = [r, g, l, c].iter().filter(|v| **v != 0.0).count();
301        let Some(case) = case.filter(|_| nonzero > 1) else {
302            return Err(format!(
303                "`{name}`: At least two of R,L,G,C must be specified and nonzero"
304            ));
305        };
306
307        let mut p = LtraParams {
308            name: name.to_string(),
309            case,
310            resist,
311            induct,
312            capac,
313            length,
314            td: 0.0,
315            imped: 0.0,
316            admit: 0.0,
317            alpha: 0.0,
318            beta: 0.0,
319            attenuation: 0.0,
320            cbyr: 0.0,
321            rclsqr: 0.0,
322            int_h1dash: 0.0,
323            int_h2: 0.0,
324            int_h3dash: 0.0,
325            cosh_lroot_gr: 0.0,
326            r_rs_lr_gr_or_g: 0.0,
327            r_gs_lr_gr_or_r: 0.0,
328            reltol: m.rel.unwrap_or(1.0),
329            abstol: m.abs.unwrap_or(1.0),
330            chop_reltol: 0.0,
331            step_limit: m.steplimit,
332            interp,
333            trunc_dont_cut: m.truncdontcut,
334            max_safe_step: 0.0,
335            st_line_reltol,
336            st_line_abstol,
337            interp_given: m.interp.is_some(),
338            notes,
339        };
340
341        // `LTRAtemp`.
342        match case {
343            LtraCase::Lc => {
344                p.imped = (l / c).sqrt();
345                p.admit = 1.0 / p.imped;
346                p.td = (l * c).sqrt() * length;
347                p.attenuation = 1.0;
348            }
349            LtraCase::Rlc => {
350                p.imped = (l / c).sqrt();
351                p.admit = 1.0 / p.imped;
352                p.td = (l * c).sqrt() * length;
353                p.alpha = 0.5 * (r / l);
354                p.beta = p.alpha;
355                p.attenuation = exp(-p.beta * p.td);
356                if p.alpha > 0.0 {
357                    p.int_h1dash = -1.0;
358                    p.int_h2 = 1.0 - p.attenuation;
359                    p.int_h3dash = -p.attenuation;
360                }
361                // alpha == 0: all zero; alpha < 0: the C leaves them
362                // at their calloc'd zero too.
363                if !p.trunc_dont_cut {
364                    p.max_safe_step =
365                        max_safe_step(p.td, p.alpha, p.beta, st_line_reltol, st_line_abstol);
366                }
367            }
368            LtraCase::Rc => {
369                p.cbyr = c / r;
370                p.rclsqr = r * c * length * length;
371                p.int_h1dash = 0.0;
372                p.int_h2 = 1.0;
373                p.int_h3dash = 0.0;
374            }
375            LtraCase::Rg => {
376                // `LTRAload` under MODEDC.
377                let x = length * (r * g).sqrt();
378                let em = exp(-x);
379                let ep = exp(x); // "LTRA warning: may overflow!"
380                p.cosh_lroot_gr = 0.5 * (ep + em);
381                p.r_rs_lr_gr_or_g = if g <= 1.0e-10 {
382                    // hack!
383                    length * r
384                } else {
385                    0.5 * (ep - em) * (r / g).sqrt()
386                };
387                p.r_gs_lr_gr_or_r = if r <= 1.0e-10 {
388                    // hack!
389                    length * g
390                } else {
391                    0.5 * (ep - em) * (g / r).sqrt()
392                };
393            }
394        }
395        Ok(p)
396    }
397
398    /// The Dune profile's interpolation (D-0052): a model without an
399    /// interpolation flag takes linear interpolation instead of the
400    /// 3f5's quadratic. Through a corner on the uneven grid the step
401    /// control leaves there, the parabola overshoots the delayed
402    /// wave; measured against refined runs at the decks' own step,
403    /// `ltra_1` is 5.1 rulers off with it and 2.4 with the linear
404    /// rule, `ltra_3` 7.3 and 2.9. A flag on the model card wins.
405    pub fn default_linear(&mut self) {
406        if !self.interp_given {
407            self.interp = LtraInterp::Linear;
408        }
409    }
410
411    /// `.options trytocompact` in `LTRAsetup` (`ltraset.c:55-72`, the
412    /// same in the 3f5): a model without an interpolation flag takes
413    /// linear interpolation instead of quadratic, with the C's warning.
414    pub fn try_to_compact(&mut self) {
415        if !self.interp_given {
416            self.interp = LtraInterp::Linear;
417            self.notes.push(format!(
418                "{}: using linear interpolation because trytocompact option specified",
419                self.name
420            ));
421        }
422    }
423}
424
425/// The `LTRAmaxSafeStep` bisection of `LTRAtemp`: shrink
426/// `[T, 10T]` towards `T` until `h2` looks straight over it. The C
427/// evaluates `h3'` alongside but passes the `h2` triple to
428/// `LTRAstraightLineCheck` twice (so only `h2` decides); ported as
429/// is. The function evaluations are pure, so the unused `h3'` values
430/// are not computed here.
431fn max_safe_step(td: f64, alpha: f64, beta: f64, reltol: f64, abstol: f64) -> f64 {
432    let mut xbig = td + 9.0 * td; // "hack! ckt is not yet initialised..."
433    let xsmall = td;
434    let mut xmid = 0.5 * (xbig + xsmall);
435    let y1small = rlc_h2(xsmall, td, alpha, beta);
436    let maxiter = 50;
437    let mut iters = 0;
438    loop {
439        iters += 1;
440        let y1big = rlc_h2(xbig, td, alpha, beta);
441        let y1mid = rlc_h2(xmid, td, alpha, beta);
442        let one = straight_line_check(xbig, y1big, xmid, y1mid, xsmall, y1small, reltol, abstol);
443        let done = one as u32 + one as u32;
444        if done == 2 || iters > maxiter {
445            break;
446        }
447        xbig = xmid;
448        xmid = 0.5 * (xbig + xsmall);
449    }
450    xbig - td
451}
452
453// ---------------------------------------------------------------------
454// ltramisc.c
455// ---------------------------------------------------------------------
456
457/// `LTRAquadInterp`: coefficients of the quadratic through
458/// `(t1, t2, t3)` evaluated at `t`; `None` when two abscissae
459/// coincide (the C returns 1 and leaves them unset).
460fn quad_interp(t: f64, t1: f64, t2: f64, t3: f64) -> Option<(f64, f64, f64)> {
461    if t == t1 {
462        return Some((1.0, 0.0, 0.0));
463    }
464    if t == t2 {
465        return Some((0.0, 1.0, 0.0));
466    }
467    if t == t3 {
468        return Some((0.0, 0.0, 1.0));
469    }
470    if (t2 - t1) == 0.0 || (t3 - t2) == 0.0 || (t1 - t3) == 0.0 {
471        return None;
472    }
473    let mut f1 = (t - t2) * (t - t3);
474    let mut f2 = (t - t1) * (t - t3);
475    let mut f3 = (t - t1) * (t - t2);
476    f1 /= t1 - t2;
477    f2 /= t2 - t1;
478    f2 /= t2 - t3;
479    f3 /= t2 - t3;
480    f1 /= t1 - t3;
481    f3 /= t1 - t3;
482    Some((f1, f2, f3))
483}
484
485/// `LTRAlinInterp`.
486fn lin_interp(t: f64, t1: f64, t2: f64) -> Option<(f64, f64)> {
487    if t1 == t2 {
488        return None;
489    }
490    if t == t1 {
491        return Some((1.0, 0.0));
492    }
493    if t == t2 {
494        return Some((0.0, 1.0));
495    }
496    let temp = (t - t1) / (t2 - t1);
497    Some((1.0 - temp, temp))
498}
499
500/// `intlinfunc`: `∫_lo^hi h(τ)dτ` for `h` linear through
501/// `(t1, lovalue)`, `(t2, hivalue)`.
502fn intlinfunc(lolimit: f64, hilimit: f64, lovalue: f64, hivalue: f64, t1: f64, t2: f64) -> f64 {
503    let width = t2 - t1;
504    if width == 0.0 {
505        return 0.0;
506    }
507    let m = (hivalue - lovalue) / width;
508    (hilimit - lolimit) * lovalue
509        + 0.5 * m * ((hilimit - t1) * (hilimit - t1) - (lolimit - t1) * (lolimit - t1))
510}
511
512/// `twiceintlinfunc`: `∫_lo^hi ∫_otherlo^τ h(τ')dτ'dτ`, `h` linear as
513/// in [`intlinfunc`].
514fn twiceintlinfunc(
515    lolimit: f64,
516    hilimit: f64,
517    otherlolimit: f64,
518    lovalue: f64,
519    hivalue: f64,
520    t1: f64,
521    t2: f64,
522) -> f64 {
523    let width = t2 - t1;
524    if width == 0.0 {
525        return 0.0;
526    }
527    let m = (hivalue - lovalue) / width;
528    let temp1 = hilimit - t1;
529    let temp2 = lolimit - t1;
530    let temp3 = otherlolimit - t1;
531    let mut dummy = lovalue
532        * ((hilimit - otherlolimit) * (hilimit - otherlolimit)
533            - (lolimit - otherlolimit) * (lolimit - otherlolimit));
534    dummy += m
535        * ((temp1 * temp1 * temp1 - temp2 * temp2 * temp2) / 3.0
536            - temp3 * temp3 * (hilimit - lolimit));
537    dummy * 0.5
538}
539
540/// `bessI0` (Numerical Recipes polynomial fits).
541fn bess_i0(x: f64) -> f64 {
542    let ax = x.abs();
543    if ax < 3.75 {
544        let y = (x / 3.75) * (x / 3.75);
545        1.0 + y
546            * (3.5156229
547                + y * (3.0899424
548                    + y * (1.2067492 + y * (0.2659732 + y * (0.360768e-1 + y * 0.45813e-2)))))
549    } else {
550        let y = 3.75 / ax;
551        (exp(ax) / ax.sqrt())
552            * (0.39894228
553                + y * (0.1328592e-1
554                    + y * (0.225319e-2
555                        + y * (-0.157565e-2
556                            + y * (0.916281e-2
557                                + y * (-0.2057706e-1
558                                    + y * (0.2635537e-1
559                                        + y * (-0.1647633e-1 + y * 0.392377e-2))))))))
560    }
561}
562
563/// `bessI1` (Numerical Recipes).
564fn bess_i1(x: f64) -> f64 {
565    let ax = x.abs();
566    let ans = if ax < 3.75 {
567        let y = (x / 3.75) * (x / 3.75);
568        ax * (0.5
569            + y * (0.87890594
570                + y * (0.51498869
571                    + y * (0.15084934 + y * (0.2658733e-1 + y * (0.301532e-2 + y * 0.32411e-3))))))
572    } else {
573        let y = 3.75 / ax;
574        let a = 0.2282967e-1 + y * (-0.2895312e-1 + y * (0.1787654e-1 - y * 0.420059e-2));
575        let a = 0.39894228
576            + y * (-0.3988024e-1
577                + y * (-0.362018e-2 + y * (0.163801e-2 + y * (-0.1031555e-1 + y * a))));
578        a * (exp(ax) / ax.sqrt())
579    };
580    if x < 0.0 { -ans } else { ans }
581}
582
583/// `bessI1xOverX`: `I1(x)/x`, finite at 0.
584fn bess_i1_x_over_x(x: f64) -> f64 {
585    let ax = x.abs();
586    if ax < 3.75 {
587        let y = (x / 3.75) * (x / 3.75);
588        0.5 + y
589            * (0.87890594
590                + y * (0.51498869
591                    + y * (0.15084934 + y * (0.2658733e-1 + y * (0.301532e-2 + y * 0.32411e-3)))))
592    } else {
593        let y = 3.75 / ax;
594        let a = 0.2282967e-1 + y * (-0.2895312e-1 + y * (0.1787654e-1 - y * 0.420059e-2));
595        let a = 0.39894228
596            + y * (-0.3988024e-1
597                + y * (-0.362018e-2 + y * (0.163801e-2 + y * (-0.1031555e-1 + y * a))));
598        a * (exp(ax) / (ax * ax.sqrt()))
599    }
600}
601
602/// `LTRArlcH2Func`: `h2(t) = α²T·e^{−βt}·I1(α√(t²−T²))/(α√(t²−T²))`
603/// for `t ≥ T`, else 0.
604fn rlc_h2(time: f64, t: f64, alpha: f64, beta: f64) -> f64 {
605    if alpha == 0.0 {
606        return 0.0;
607    }
608    if time < t {
609        return 0.0;
610    }
611    let besselarg = if time != t {
612        alpha * (time * time - t * t).sqrt()
613    } else {
614        0.0
615    };
616    let exparg = -beta * time;
617    alpha * alpha * t * exp(exparg) * bess_i1_x_over_x(besselarg)
618}
619
620/// `LTRAstraightLineCheck`: do the three points lie acceptably close
621/// to a straight line? Compares the triangle they enclose with the
622/// trapezoid areas under them (an integral-flavoured criterion).
623#[allow(clippy::too_many_arguments)]
624fn straight_line_check(
625    x1: f64,
626    y1: f64,
627    x2: f64,
628    y2: f64,
629    x3: f64,
630    y3: f64,
631    reltol: f64,
632    abstol: f64,
633) -> bool {
634    let quad1 = (y2.abs() + y1.abs()) * 0.5 * (x2 - x1).abs();
635    let quad2 = (y3.abs() + y2.abs()) * 0.5 * (x3 - x2).abs();
636    let quad3 = (y3.abs() + y1.abs()) * 0.5 * (x3 - x1).abs();
637    let tr_area = (quad3 - quad1 - quad2).abs();
638    let area = quad1 + quad2;
639    area * reltol + abstol > tr_area
640}
641
642/// First coefficients and convolution lists for one timepoint
643/// (`LTRAh*FirstCoeff`, `LTRAh*Coeffs`, `LTRAauxIndex`).
644#[derive(Clone, Debug, Default)]
645struct Coeffs {
646    h1_first: f64,
647    h2_first: f64,
648    h3_first: f64,
649    h1: Vec<f64>,
650    h2: Vec<f64>,
651    h3: Vec<f64>,
652    aux_index: usize,
653}
654
655impl Coeffs {
656    fn ensure(&mut self, n: usize) {
657        if self.h1.len() < n {
658            self.h1.resize(n, 0.0);
659            self.h2.resize(n, 0.0);
660            self.h3.resize(n, 0.0);
661        }
662    }
663}
664
665/// `LTRArlcCoeffsSetup`: the RLC coefficient lists for `curtime`,
666/// assuming piecewise-linear port waveforms between the accepted
667/// `timelist[0..=timeindex]`. `h1'` uses its closed-form twice
668/// integral; `h2` and `h3'` (which start at `T`) integrate a linear
669/// interpolation of the function between grid-aligned lags. The
670/// chop tests use `<=` (the RC version uses `<`); with the always-0
671/// chop reltol they only stop on an exactly zero coefficient.
672///
673/// The `i == auxindex` correction of the previous interval's
674/// `[lolimit2, hilimit2]` is commented out in the C (`/* if (i ==
675/// auxindex) { lolimit2 = T; … } */`); ported as is.
676#[allow(clippy::too_many_arguments)]
677fn rlc_coeffs(
678    co: &mut Coeffs,
679    t: f64,
680    alpha: f64,
681    beta: f64,
682    curtime: f64,
683    timelist: &[f64],
684    timeindex: usize,
685    reltol: f64,
686) {
687    co.ensure(timeindex + 1);
688
689    let auxindex: usize = if t == 0.0 {
690        timeindex
691    } else if curtime - t <= 0.0 {
692        0
693    } else {
694        let mut exact = false;
695        let mut found: isize = -1;
696        for i in (0..=timeindex).rev() {
697            if curtime - timelist[i] == t {
698                exact = true;
699                found = i as isize;
700                break;
701            }
702            if curtime - timelist[i] > t {
703                found = i as isize;
704                break;
705            }
706        }
707        // "LTRAcoeffSetup: i <= 0: some mistake!" — cannot happen
708        // (timelist[0] = 0 and curtime > T); clamp instead of
709        // indexing before the list like the C would.
710        let a = if exact { found - 1 } else { found };
711        a.max(0) as usize
712    };
713
714    let alphasq_t = alpha * alpha * t;
715    let expbeta_t = exp(-beta * t);
716
717    let (mut h2lovalue1, mut h2hivalue1, mut h2dummy1, mut h2relval) = (0.0, 0.0, 0.0, 0.0);
718    let (mut h3hivalue1, mut h3dummy1, mut h3relval) = (0.0, 0.0, 0.0);
719
720    // The first coefficient (the lag `curtime − T`).
721    if auxindex != 0 {
722        let lolimit1 = t;
723        let hilimit1 = curtime - timelist[auxindex];
724        let delta1 = hilimit1 - lolimit1;
725
726        h2lovalue1 = rlc_h2(t, t, alpha, beta);
727        let besselarg = if hilimit1 > t {
728            alpha * (hilimit1 * hilimit1 - t * t).sqrt()
729        } else {
730            0.0
731        };
732        let expterm = exp(-beta * hilimit1);
733        let bessi1overxterm = bess_i1_x_over_x(besselarg);
734        h2hivalue1 = if alpha == 0.0 || hilimit1 < t {
735            0.0
736        } else {
737            alphasq_t * expterm * bessi1overxterm
738        };
739        h2dummy1 = twiceintlinfunc(
740            lolimit1, hilimit1, lolimit1, h2lovalue1, h2hivalue1, lolimit1, hilimit1,
741        ) / delta1;
742        co.h2_first = h2dummy1;
743        h2relval = (reltol * h2dummy1).abs();
744
745        let h3lovalue1 = 0.0; // E3dash should be consistent with this
746        let bessi0term = bess_i0(besselarg);
747        h3hivalue1 = if hilimit1 <= t || beta == 0.0 {
748            0.0
749        } else {
750            expterm * bessi0term - expbeta_t
751        };
752        h3dummy1 = intlinfunc(
753            lolimit1, hilimit1, h3lovalue1, h3hivalue1, lolimit1, hilimit1,
754        ) / delta1;
755        co.h3_first = h3dummy1;
756        h3relval = (h3dummy1 * reltol).abs();
757    } else {
758        co.h2_first = 0.0;
759        co.h3_first = 0.0;
760    }
761
762    let mut lolimit1 = 0.0;
763    let mut hilimit1 = curtime - timelist[timeindex];
764    let mut delta1 = hilimit1 - lolimit1;
765    let mut exparg = -beta * hilimit1;
766    let mut expterm = exp(exparg);
767
768    let h1twice = |hilimit1: f64, exparg: f64, expterm: f64| -> f64 {
769        // LTRArlcH1dashTwiceIntFunc(hilimit1, beta), inlined.
770        if beta == 0.0 {
771            hilimit1
772        } else if hilimit1 == 0.0 {
773            0.0
774        } else {
775            (bess_i1(-exparg) + bess_i0(-exparg)) * hilimit1 * expterm - hilimit1
776        }
777    };
778    let mut h1hivalue1 = h1twice(hilimit1, exparg, expterm);
779    let mut h1dummy1 = h1hivalue1 / delta1;
780    co.h1_first = h1dummy1;
781    let h1relval = (h1dummy1 * reltol).abs();
782
783    let (mut doh1, mut doh2, mut doh3) = (true, true, true);
784    let (mut lolimit2, mut hilimit2) = (0.0, 0.0);
785
786    for i in (1..=timeindex).rev() {
787        if doh1 || doh2 || doh3 {
788            lolimit2 = lolimit1;
789            hilimit2 = hilimit1;
790            lolimit1 = hilimit2;
791            hilimit1 = curtime - timelist[i - 1];
792            delta1 = timelist[i] - timelist[i - 1];
793            exparg = -beta * hilimit1;
794            expterm = exp(exparg);
795        }
796
797        if doh1 {
798            let h1hivalue2 = h1hivalue1;
799            let h1dummy2 = h1dummy1;
800            let h1lovalue1 = h1hivalue2;
801            h1hivalue1 = h1twice(hilimit1, exparg, expterm);
802            h1dummy1 = (h1hivalue1 - h1lovalue1) / delta1;
803            co.h1[i] = h1dummy1 - h1dummy2;
804            if co.h1[i].abs() <= h1relval {
805                doh1 = false;
806            }
807        } else {
808            co.h1[i] = 0.0;
809        }
810
811        if i <= auxindex {
812            let mut besselarg = 0.0;
813            if doh2 || doh3 {
814                besselarg = if hilimit1 > t {
815                    alpha * (hilimit1 * hilimit1 - t * t).sqrt()
816                } else {
817                    0.0
818                };
819            }
820
821            if doh2 {
822                let h2lovalue2 = h2lovalue1;
823                let h2hivalue2 = h2hivalue1;
824                let h2dummy2 = h2dummy1;
825                h2lovalue1 = h2hivalue2;
826                let bessi1overxterm = bess_i1_x_over_x(besselarg);
827                h2hivalue1 = if alpha == 0.0 || hilimit1 < t {
828                    0.0
829                } else {
830                    alphasq_t * expterm * bessi1overxterm
831                };
832                h2dummy1 = twiceintlinfunc(
833                    lolimit1, hilimit1, lolimit1, h2lovalue1, h2hivalue1, lolimit1, hilimit1,
834                ) / delta1;
835                co.h2[i] = h2dummy1 - h2dummy2
836                    + intlinfunc(
837                        lolimit2, hilimit2, h2lovalue2, h2hivalue2, lolimit2, hilimit2,
838                    );
839                if co.h2[i].abs() <= h2relval {
840                    doh2 = false;
841                }
842            } else {
843                co.h2[i] = 0.0;
844            }
845
846            if doh3 {
847                let h3hivalue2 = h3hivalue1;
848                let h3dummy2 = h3dummy1;
849                let h3lovalue1 = h3hivalue2;
850                let bessi0term = bess_i0(besselarg);
851                h3hivalue1 = if hilimit1 <= t || beta == 0.0 {
852                    0.0
853                } else {
854                    expterm * bessi0term - expbeta_t
855                };
856                h3dummy1 = intlinfunc(
857                    lolimit1, hilimit1, h3lovalue1, h3hivalue1, lolimit1, hilimit1,
858                ) / delta1;
859                co.h3[i] = h3dummy1 - h3dummy2;
860                if co.h3[i].abs() <= h3relval {
861                    doh3 = false;
862                }
863            } else {
864                co.h3[i] = 0.0;
865            }
866        }
867    }
868    co.aux_index = auxindex;
869}
870
871/// `erfc`, the C library's (fdlibm lineage, via `libm` like every
872/// transcendental here — see the note at the top of the file).
873fn erfc(x: f64) -> f64 {
874    libm::erfc(x)
875}
876
877/// `LTRArcCoeffsSetup`: the RC (`L = G = 0`) coefficient lists, from
878/// the closed-form twice-integrated responses
879/// `H1''(t) = √(4·(C/R)·t/π)`,
880/// `H2''(t) = (t + RCl²/2)·erfc(√(RCl²/4t)) − √(t·RCl²/π)·e^{−RCl²/4t}`,
881/// `H3''(t) = √(C/R)·(2√(t/π)·e^{−RCl²/4t} − √(RCl²)·erfc(√(RCl²/4t)))`.
882/// Chop tests use `<` (never true with the 0 chop reltol).
883fn rc_coeffs(
884    co: &mut Coeffs,
885    cbyr: f64,
886    rclsqr: f64,
887    curtime: f64,
888    timelist: &[f64],
889    timeindex: usize,
890    reltol: f64,
891) {
892    use std::f64::consts::PI;
893    co.ensure(timeindex + 1);
894    let auxindex = timeindex;
895
896    let mut delta1 = curtime - timelist[auxindex];
897    let mut hilimit1 = delta1;
898
899    let mut h1hivalue1 = (4.0 * cbyr * hilimit1 / PI).sqrt();
900    let mut h1dummy1 = h1hivalue1 / delta1;
901    co.h1_first = h1dummy1;
902    let h1relval = (h1dummy1 * reltol).abs();
903
904    let mut temp = rclsqr / (4.0 * hilimit1);
905    let mut temp2 = if temp >= 100.0 {
906        0.0
907    } else {
908        erfc(temp.sqrt())
909    };
910    let mut temp3 = exp(-temp);
911    let temp4 = rclsqr.sqrt();
912    let temp5 = cbyr.sqrt();
913
914    let mut h2hivalue1 = if hilimit1 != 0.0 {
915        (hilimit1 + rclsqr * 0.5) * temp2 - (hilimit1 * rclsqr / PI).sqrt() * temp3
916    } else {
917        0.0
918    };
919    let mut h2dummy1 = h2hivalue1 / delta1;
920    co.h2_first = h2dummy1;
921    let h2relval = (h2dummy1 * reltol).abs();
922
923    let h3of = |hilimit1: f64, temp2: f64, temp3: f64| -> f64 {
924        if hilimit1 != 0.0 {
925            let temp = 2.0 * (hilimit1 / PI).sqrt() * temp3 - temp4 * temp2;
926            temp5 * temp
927        } else {
928            0.0
929        }
930    };
931    let mut h3hivalue1 = h3of(hilimit1, temp2, temp3);
932    let mut h3dummy1 = h3hivalue1 / delta1;
933    co.h3_first = h3dummy1;
934    let h3relval = (h3dummy1 * reltol).abs();
935
936    let (mut doh1, mut doh2, mut doh3) = (true, true, true);
937    for i in (1..=auxindex).rev() {
938        delta1 = timelist[i] - timelist[i - 1];
939        hilimit1 = curtime - timelist[i - 1];
940
941        if doh1 {
942            let h1hivalue2 = h1hivalue1;
943            let h1dummy2 = h1dummy1;
944            let h1lovalue1 = h1hivalue2;
945            h1hivalue1 = (4.0 * cbyr * hilimit1 / PI).sqrt();
946            h1dummy1 = (h1hivalue1 - h1lovalue1) / delta1;
947            co.h1[i] = h1dummy1 - h1dummy2;
948            if co.h1[i].abs() < h1relval {
949                doh1 = false;
950            }
951        } else {
952            co.h1[i] = 0.0;
953        }
954
955        if doh2 || doh3 {
956            temp = rclsqr / (4.0 * hilimit1);
957            temp2 = if temp >= 100.0 {
958                0.0
959            } else {
960                erfc(temp.sqrt())
961            };
962            temp3 = exp(-temp);
963        }
964
965        if doh2 {
966            let h2hivalue2 = h2hivalue1;
967            let h2dummy2 = h2dummy1;
968            let h2lovalue1 = h2hivalue2;
969            h2hivalue1 = if hilimit1 != 0.0 {
970                (hilimit1 + rclsqr * 0.5) * temp2 - (hilimit1 * rclsqr / PI).sqrt() * temp3
971            } else {
972                0.0
973            };
974            h2dummy1 = (h2hivalue1 - h2lovalue1) / delta1;
975            co.h2[i] = h2dummy1 - h2dummy2;
976            if co.h2[i].abs() < h2relval {
977                doh2 = false;
978            }
979        } else {
980            co.h2[i] = 0.0;
981        }
982
983        if doh3 {
984            let h3hivalue2 = h3hivalue1;
985            let h3dummy2 = h3dummy1;
986            let h3lovalue1 = h3hivalue2;
987            h3hivalue1 = h3of(hilimit1, temp2, temp3);
988            h3dummy1 = (h3hivalue1 - h3lovalue1) / delta1;
989            co.h3[i] = h3dummy1 - h3dummy2;
990            if co.h3[i].abs() < h3relval {
991                doh3 = false;
992            }
993        } else {
994            co.h3[i] = 0.0;
995        }
996    }
997    co.aux_index = auxindex;
998}
999
1000// ---------------------------------------------------------------------
1001// The instance
1002// ---------------------------------------------------------------------
1003
1004/// Matrix positions of `LTRAsetup` (`TSTALLOC` list, in order).
1005#[derive(Clone, Copy)]
1006struct Slots {
1007    ibr1_pos1: Slot,
1008    ibr1_neg1: Slot,
1009    ibr1_pos2: Slot,
1010    ibr1_neg2: Slot,
1011    ibr1_ibr1: Slot,
1012    ibr1_ibr2: Slot,
1013    ibr2_pos1: Slot,
1014    ibr2_neg1: Slot,
1015    ibr2_pos2: Slot,
1016    ibr2_neg2: Slot,
1017    ibr2_ibr1: Slot,
1018    ibr2_ibr2: Slot,
1019    pos1_ibr1: Slot,
1020    neg1_ibr1: Slot,
1021    pos2_ibr2: Slot,
1022    neg2_ibr2: Slot,
1023}
1024
1025const NO_SLOTS: Slots = Slots {
1026    ibr1_pos1: TRASH,
1027    ibr1_neg1: TRASH,
1028    ibr1_pos2: TRASH,
1029    ibr1_neg2: TRASH,
1030    ibr1_ibr1: TRASH,
1031    ibr1_ibr2: TRASH,
1032    ibr2_pos1: TRASH,
1033    ibr2_neg1: TRASH,
1034    ibr2_pos2: TRASH,
1035    ibr2_neg2: TRASH,
1036    ibr2_ibr1: TRASH,
1037    ibr2_ibr2: TRASH,
1038    pos1_ibr1: TRASH,
1039    neg1_ibr1: TRASH,
1040    pos2_ibr2: TRASH,
1041    neg2_ibr2: TRASH,
1042};
1043
1044/// Delayed port values `v1(t−T)`, `i1(t−T)`, `v2(t−T)`, `i2(t−T)`.
1045#[derive(Clone, Copy, Default)]
1046struct Delayed {
1047    v1: f64,
1048    i1: f64,
1049    v2: f64,
1050    i2: f64,
1051}
1052
1053/// One `O` line.
1054pub(crate) struct LtraI {
1055    p: LtraParams,
1056    pos1: Var,
1057    neg1: Var,
1058    pos2: Var,
1059    neg2: Var,
1060    br1: Var,
1061    br2: Var,
1062    slots: Slots,
1063    /// `LTRAinput1/2`: the history part of each branch equation.
1064    input1: f64,
1065    input2: f64,
1066    /// `LTRAinitVolt1/2`, `LTRAinitCur1/2`: the operating point.
1067    init_v1: f64,
1068    init_i1: f64,
1069    init_v2: f64,
1070    init_i2: f64,
1071    /// `CKTtimePoints[0..=CKTtimeIndex]` and the port histories
1072    /// `LTRAv1/i1/v2/i2` on it.
1073    times: Vec<f64>,
1074    v1: Vec<f64>,
1075    i1: Vec<f64>,
1076    v2: Vec<f64>,
1077    i2: Vec<f64>,
1078    co: Coeffs,
1079    /// `CKTgmin` of the last DC load, for the RG stamps in `.ac`.
1080    gmin: f64,
1081    /// Dune's opt-in recursive convolution (`Options::ltra_fit`,
1082    /// issue #8); off = the C's coefficient walk, untouched.
1083    fit_on: bool,
1084    /// Dune's floor on the slope rule (D-0052; with the breakpoint
1085    /// hygiene): a slope change plants a breakpoint only when the
1086    /// wave moved by more than `RELTOL` of the largest either wave
1087    /// has been.
1088    slope_floor: bool,
1089    /// Dune's step limit from the interpolation of the delayed waves
1090    /// ([`LtraI::interp_error`], D-0055).
1091    interp_control: bool,
1092    /// Dune's compaction (D-0056): the samples dropped since the last
1093    /// one kept — `(time, [v1, v2, i1, i2])` —, which the chord from
1094    /// that sample to the newest must still pass by.
1095    dropped: Vec<(f64, [f64; 4])>,
1096    /// Whether the last accepted point ended in a compaction.
1097    compacted: bool,
1098    /// Whether the slope rule fired at the last accepted point: the
1099    /// point before it is a corner of an incident wave.
1100    corner: bool,
1101    /// The largest magnitude each incident wave has had (the scale
1102    /// of [`LtraI::slope_floor`]).
1103    wave_peak: [f64; 2],
1104    /// The recursive-mode model and states of the current run (built
1105    /// at the operating-point accept, when TSTOP is known).
1106    fit: Option<FitState>,
1107    /// Why the recursive mode does not apply to this line, if it was
1108    /// asked for (LC/RG have no history convolution).
1109    fit_skip: Option<&'static str>,
1110    /// `.pss` (issue #20): the history of this run is the periodic
1111    /// past of a shooting interval (see [`LtraI::seed_periodic`]).
1112    periodic: bool,
1113    /// Why the periodic past could not be set up, reported at the
1114    /// first load.
1115    periodic_fault: Option<&'static str>,
1116}
1117
1118/// The recursive model's horizon in a periodic run, in periods: the
1119/// infinite periodic past is summed in closed form, and lags beyond
1120/// the horizon (where the fit stops tracking the `τ^{−3/2}` tails) see
1121/// a zero-mean deviation, so what they miss cancels over each period.
1122const PERIODIC_HORIZON_PERIODS: f64 = 1000.0;
1123
1124/// Wave blocks of a state snapshot.
1125const V1: usize = 0;
1126const V2: usize = 1;
1127const I1: usize = 2;
1128const I2: usize = 3;
1129
1130/// The recursive-mode state of one line (issue #8).
1131///
1132/// For every pole `−x_j` of the [`ExpFit`] and every port wave `w`
1133/// (`v1`, `v2`, `i1`, `i2`), `S_j[w](t_k) = ∫₀^∞ e^{−x_j σ}·d_w(t_k − σ)dσ`
1134/// with `d_w = w − w_init` piecewise linear on the accepted points
1135/// (and `d_w(t_0) = 0`, the C's convention of leaving index 0 out of
1136/// the sums). It advances once per accepted point, in `accept` — never
1137/// per Newton iteration or per TR-BDF2 stage. A load at time `t`
1138/// needs it at the newest point (`h1'`, RC kernels) and at the point
1139/// just older than `t − T` (`h2`, `h3'`), so a snapshot per accepted
1140/// point is kept from the oldest one a later load can still reach.
1141struct FitState {
1142    fit: ExpFit,
1143    /// Snapshots, `4·N` values per accepted point from `base` on.
1144    snaps: Vec<f64>,
1145    base: usize,
1146    /// Scratch for `e^{−x_j·lag}`.
1147    ew: Vec<f64>,
1148}
1149
1150impl FitState {
1151    fn new(fit: ExpFit) -> Self {
1152        let n = fit.rates.len();
1153        FitState {
1154            fit,
1155            snaps: vec![0.0; 4 * n],
1156            base: 0,
1157            ew: vec![0.0; n],
1158        }
1159    }
1160
1161    fn n(&self) -> usize {
1162        self.fit.rates.len()
1163    }
1164
1165    /// The snapshot of history point `k`; `None` if pruned (a bug).
1166    fn snap(&self, k: usize) -> Option<&[f64]> {
1167        let n4 = 4 * self.n();
1168        let off = k.checked_sub(self.base)? * n4;
1169        self.snaps.get(off..off + n4)
1170    }
1171
1172    /// `e^{−x_j·lag}` into the scratch.
1173    fn exps(&mut self, lag: f64) {
1174        for (e, x) in self.ew.iter_mut().zip(&self.fit.rates) {
1175            *e = exp(-x * lag);
1176        }
1177    }
1178
1179    /// `Σ_j r_j[kernel]·e^{−x_j·lag}·S_j[wave]` at snapshot `k`, with
1180    /// the exponentials already in the scratch.
1181    fn tail(&self, kernel: usize, k: usize, wave: usize) -> Option<f64> {
1182        let n = self.n();
1183        let s = &self.snap(k)?[wave * n..(wave + 1) * n];
1184        Some(
1185            self.fit.res[kernel]
1186                .iter()
1187                .zip(&self.ew)
1188                .zip(s)
1189                .map(|((r, e), s)| r * e * s)
1190                .sum(),
1191        )
1192    }
1193
1194    /// `.pss`: the states at a period boundary under the infinite
1195    /// periodic past. With `P` the states one period of the deviation
1196    /// builds from zero (`segs` = that period's segments), periodicity
1197    /// `S = e^{−xT}·S + P` gives `S = P/(1 − e^{−xT})` exactly — the
1198    /// geometric sum over every earlier period. Replaces the snapshots
1199    /// by that single one.
1200    fn periodic_start(&mut self, period: f64, segs: &[(f64, [f64; 4], [f64; 4])]) {
1201        let n = self.n();
1202        let mut s = vec![0.0; 4 * n];
1203        for &(h, dn, dp) in segs {
1204            for j in 0..n {
1205                let (e, a, b) = segment(self.fit.rates[j] * h);
1206                for w in 0..4 {
1207                    let v = &mut s[w * n + j];
1208                    *v = e * *v + h * (a * dn[w] + b * dp[w]);
1209                }
1210            }
1211        }
1212        for j in 0..n {
1213            let g = -expm1(-self.fit.rates[j] * period);
1214            for w in 0..4 {
1215                s[w * n + j] /= g;
1216            }
1217        }
1218        self.snaps = s;
1219        self.base = 0;
1220    }
1221
1222    /// Advance one accepted segment of length `h`: `dn`/`dp` are the
1223    /// four wave deviations at the new and the previous point.
1224    fn advance(&mut self, h: f64, dn: [f64; 4], dp: [f64; 4]) {
1225        let n = self.n();
1226        let start = self.snaps.len() - 4 * n;
1227        self.snaps.extend_from_within(start..);
1228        let new = start + 4 * n;
1229        for j in 0..n {
1230            let (e, a, b) = segment(self.fit.rates[j] * h);
1231            for w in 0..4 {
1232                let s = &mut self.snaps[new + w * n + j];
1233                *s = e * *s + h * (a * dn[w] + b * dp[w]);
1234            }
1235        }
1236    }
1237
1238    /// Drop the snapshots older than history point `keep`, in chunks.
1239    fn prune(&mut self, keep: usize) {
1240        let dead = keep.saturating_sub(self.base);
1241        if dead >= 1024 {
1242            self.snaps.drain(..dead * 4 * self.n());
1243            self.base += dead;
1244        }
1245    }
1246}
1247
1248impl LtraI {
1249    /// `nodes` = (P1+, P1−, P2+, P2−); `br` = the `#i1`/`#i2`
1250    /// equations.
1251    pub fn new(p: LtraParams, nodes: [Var; 4], br: [Var; 2]) -> Self {
1252        LtraI {
1253            p,
1254            pos1: nodes[0],
1255            neg1: nodes[1],
1256            pos2: nodes[2],
1257            neg2: nodes[3],
1258            br1: br[0],
1259            br2: br[1],
1260            slots: NO_SLOTS,
1261            input1: 0.0,
1262            input2: 0.0,
1263            init_v1: 0.0,
1264            init_i1: 0.0,
1265            init_v2: 0.0,
1266            init_i2: 0.0,
1267            times: Vec::new(),
1268            v1: Vec::new(),
1269            i1: Vec::new(),
1270            v2: Vec::new(),
1271            i2: Vec::new(),
1272            co: Coeffs::default(),
1273            gmin: 0.0,
1274            fit_on: false,
1275            slope_floor: false,
1276            dropped: Vec::new(),
1277            compacted: false,
1278            corner: false,
1279            interp_control: false,
1280            wave_peak: [0.0; 2],
1281            fit: None,
1282            fit_skip: None,
1283            periodic: false,
1284            periodic_fault: None,
1285        }
1286    }
1287
1288    /// Let the interpolation error of the delayed waves limit the
1289    /// step (the Dune profile).
1290    pub fn with_interp_control(mut self, on: bool) -> Self {
1291        self.interp_control = on;
1292        self
1293    }
1294
1295    /// Turn on the floor of the slope rule (the Dune profile).
1296    pub fn with_slope_floor(mut self, on: bool) -> Self {
1297        self.slope_floor = on;
1298        self
1299    }
1300
1301    /// Opt into the recursive convolution (`Options::ltra_fit`).
1302    pub fn with_fit(mut self, on: bool) -> Self {
1303        self.fit_on = on;
1304        self
1305    }
1306
1307    /// Build the recursive model for a run to `final_time` (at the
1308    /// operating-point accept). Lines without a history convolution,
1309    /// or whose kernels live entirely inside the exact window, keep
1310    /// the exact path and say why.
1311    fn start_fit(&mut self, final_time: f64) {
1312        self.build_fit(final_time.max(f64::MIN_POSITIVE));
1313    }
1314
1315    /// The recursive model for lags up to `horizon` (see `start_fit`).
1316    fn build_fit(&mut self, horizon: f64) {
1317        self.fit = None;
1318        self.fit_skip = None;
1319        let p = &self.p;
1320        match p.case {
1321            LtraCase::Lc | LtraCase::Rg => {
1322                self.fit_skip = Some("no history convolution in this regime");
1323            }
1324            LtraCase::Rlc if !(p.alpha > 0.0 && p.alpha.is_finite()) => {
1325                self.fit_skip = Some("needs R/L > 0");
1326            }
1327            LtraCase::Rlc => {
1328                self.fit = Some(FitState::new(fit_rlc(p.alpha, p.td, horizon)));
1329            }
1330            LtraCase::Rc => {
1331                let w = RC_WINDOW_FRACTION * p.rclsqr;
1332                if !(w > 0.0 && w.is_finite() && p.cbyr > 0.0 && p.cbyr.is_finite()) {
1333                    self.fit_skip = Some("needs R, C > 0");
1334                } else if w >= horizon {
1335                    self.fit_skip =
1336                        Some("the diffusion time exceeds the run: exact window covers it");
1337                } else {
1338                    self.fit = Some(FitState::new(fit_rc(p.cbyr, p.rclsqr, w, horizon)));
1339                }
1340            }
1341        }
1342    }
1343
1344    /// The deviation `w − w_init` of history point `k` (0 at `k = 0`,
1345    /// the operating point, outside a periodic run).
1346    fn dev(&self, wave: usize, k: usize) -> f64 {
1347        if k == 0 && !self.periodic {
1348            return 0.0;
1349        }
1350        match wave {
1351            V1 => self.v1[k] - self.init_v1,
1352            V2 => self.v2[k] - self.init_v2,
1353            I1 => self.i1[k] - self.init_i1,
1354            _ => self.i2[k] - self.init_i2,
1355        }
1356    }
1357
1358    /// `LTRArlcCoeffsSetup`'s `auxindex` by bisection: the newest
1359    /// point with `t − t_i ≥ T`, one older if the lag is exactly `T`.
1360    fn aux_index(&self, time: f64) -> usize {
1361        let td = self.p.td;
1362        if td == 0.0 {
1363            return self.times.len() - 1;
1364        }
1365        if time - td <= 0.0 {
1366            return 0;
1367        }
1368        let n = self.times.partition_point(|&t| time - t >= td);
1369        let Some(found) = n.checked_sub(1) else {
1370            return 0;
1371        };
1372        if time - self.times[found] == td {
1373            found.saturating_sub(1)
1374        } else {
1375            found
1376        }
1377    }
1378
1379    /// The oldest history point whose snapshot a load after the newest
1380    /// accepted point can still need.
1381    fn oldest_needed(&self) -> usize {
1382        let n = self.times.len() - 1;
1383        let t = self.times[n];
1384        let k = match self.p.case {
1385            LtraCase::Rc => {
1386                let w = self.fit.as_ref().map_or(0.0, |f| f.fit.window);
1387                self.times
1388                    .partition_point(|&ti| t - ti >= w)
1389                    .saturating_sub(1)
1390            }
1391            _ => self.aux_index(t),
1392        };
1393        k.saturating_sub(1).min(n)
1394    }
1395
1396    /// Recursive mode, LC/RLC: the first coefficients (for the matrix)
1397    /// and the history inputs of `LTRAload` in one pass. `h1'` is the
1398    /// exact first segment (lags `[0, Δ]`, the C's closed forms) plus
1399    /// the recursion for older lags; `h2`/`h3'` likewise at `t − T`,
1400    /// whose first segment keeps the C's linear-in-lag rule.
1401    fn fit_inputs_lc_rlc(&mut self, time: f64, del: Option<Delayed>) -> Result<(), &'static str> {
1402        const PRUNED: &str = "LTRA: recursive history state pruned";
1403        let n = self.times.len() - 1;
1404        let (mut in1, mut in2) = (0.0, 0.0);
1405        if self.p.case == LtraCase::Rlc {
1406            let (alpha, beta, td, admit) = (self.p.alpha, self.p.beta, self.p.td, self.p.admit);
1407            let fs = self.fit.as_mut().ok_or(PRUNED)?;
1408            // h1': H''(Δ)/Δ (LTRArlcH1dashTwiceIntFunc, scaled Bessel)
1409            // and the x_n share H'(Δ) − H''(Δ)/Δ, H'(Δ) = e^{−βΔ}I0(βΔ) − 1.
1410            let dlt = time - self.times[n];
1411            let bd = beta * dlt;
1412            let h1_first = if dlt == 0.0 {
1413                0.0
1414            } else {
1415                (i1e(bd) + i0e(bd)) - 1.0
1416            };
1417            let c_n = (i0e(bd) - 1.0) - h1_first;
1418            fs.exps(dlt);
1419            let t1 = fs.tail(0, n, V1).ok_or(PRUNED)?;
1420            let t2 = fs.tail(0, n, V2).ok_or(PRUNED)?;
1421            self.co.h1_first = h1_first;
1422            let p = &self.p;
1423            let mut d1 = c_n * self.dev(V1, n) + t1;
1424            let mut d2 = c_n * self.dev(V2, n) + t2;
1425            d1 += self.init_v1 * p.int_h1dash - self.init_v1 * h1_first;
1426            d2 += self.init_v2 * p.int_h1dash - self.init_v2 * h1_first;
1427            in1 -= d1 * admit;
1428            in2 -= d2 * admit;
1429
1430            let (mut e1, mut e2, mut f1, mut f2) = (0.0, 0.0, 0.0, 0.0);
1431            if let Some(dl) = del {
1432                let aux = self.aux_index(time);
1433                let hi = time - self.times[aux];
1434                let dp = hi - td;
1435                let h2lo = rlc_h2(td, td, alpha, beta);
1436                let z = if hi > td {
1437                    alpha * (hi * hi - td * td).sqrt()
1438                } else {
1439                    0.0
1440                };
1441                let damp = exp(-(beta * hi - z));
1442                let h2hi = if hi < td {
1443                    0.0
1444                } else {
1445                    alpha * alpha * td * damp * i1e_over_x(z)
1446                };
1447                let h2_first = twiceintlinfunc(td, hi, td, h2lo, h2hi, td, hi) / dp;
1448                let c2 = intlinfunc(td, hi, h2lo, h2hi, td, hi) - h2_first;
1449                let h3hi = if hi <= td || beta == 0.0 {
1450                    0.0
1451                } else {
1452                    damp * i0e(z) - exp(-beta * td)
1453                };
1454                let h3_first = intlinfunc(td, hi, 0.0, h3hi, td, hi) / dp;
1455                let c3 = h3hi - h3_first;
1456                let fs = self.fit.as_mut().ok_or(PRUNED)?;
1457                fs.exps(dp);
1458                let (s2a, s2b) = (
1459                    fs.tail(1, aux, I2).ok_or(PRUNED)?,
1460                    fs.tail(1, aux, I1).ok_or(PRUNED)?,
1461                );
1462                let (s3a, s3b) = (
1463                    fs.tail(2, aux, V2).ok_or(PRUNED)?,
1464                    fs.tail(2, aux, V1).ok_or(PRUNED)?,
1465                );
1466                e1 = (dl.i2 - self.init_i2) * h2_first + c2 * self.dev(I2, aux) + s2a;
1467                e2 = (dl.i1 - self.init_i1) * h2_first + c2 * self.dev(I1, aux) + s2b;
1468                f1 = (dl.v2 - self.init_v2) * h3_first + c3 * self.dev(V2, aux) + s3a;
1469                f2 = (dl.v1 - self.init_v1) * h3_first + c3 * self.dev(V1, aux) + s3b;
1470            }
1471            let p = &self.p;
1472            e1 += self.init_i2 * p.int_h2;
1473            e2 += self.init_i1 * p.int_h2;
1474            in1 += e1;
1475            in2 += e2;
1476            f1 += self.init_v2 * p.int_h3dash;
1477            f2 += self.init_v1 * p.int_h3dash;
1478            in1 += admit * f1;
1479            in2 += admit * f2;
1480        }
1481        let p = &self.p;
1482        match del {
1483            None => {
1484                in1 += p.attenuation * (self.init_v2 * p.admit + self.init_i2);
1485                in2 += p.attenuation * (self.init_v1 * p.admit + self.init_i1);
1486            }
1487            Some(dl) => {
1488                in1 += p.attenuation * (dl.v2 * p.admit + dl.i2);
1489                in2 += p.attenuation * (dl.v1 * p.admit + dl.i1);
1490            }
1491        }
1492        self.input1 = in1;
1493        self.input2 = in2;
1494        Ok(())
1495    }
1496
1497    /// Recursive mode, RC: the exact coefficients of
1498    /// `LTRArcCoeffsSetup` over the window of lags `< W` (from the
1499    /// newest point `b` at least `W` old), the recursion beyond it.
1500    fn fit_inputs_rc(&mut self, time: f64) -> Result<(), &'static str> {
1501        const PRUNED: &str = "LTRA: recursive history state pruned";
1502        let n = self.times.len() - 1;
1503        let (cbyr, rclsqr) = (self.p.cbyr, self.p.rclsqr);
1504        let w = self.fit.as_ref().ok_or(PRUNED)?.fit.window;
1505        let b = self
1506            .times
1507            .partition_point(|&t| time - t >= w)
1508            .saturating_sub(1)
1509            .min(n);
1510        let lb = time - self.times[b];
1511        let fs = self.fit.as_mut().ok_or(PRUNED)?;
1512        fs.exps(lb);
1513        // sums[k][port]: kernel k against (v1|i2|v2) for port 1 and
1514        // (v2|i1|v1) for port 2, like `inputs_rc`.
1515        let waves = [[V1, V2], [I2, I1], [V2, V1]];
1516        let mut tails = [[0.0; 2]; 3];
1517        for k in 0..3 {
1518            for q in 0..2 {
1519                tails[k][q] = fs.tail(k, b, waves[k][q]).ok_or(PRUNED)?;
1520            }
1521        }
1522        let mut firsts = [0.0; 3];
1523        let mut sums = tails;
1524        for k in 0..3 {
1525            // H''(L_i) for i = n (first segment), walking older.
1526            let ln = time - self.times[n];
1527            let mut hi_val = rc_integrals(k, cbyr, rclsqr, ln).1;
1528            let mut dd_new = hi_val / ln; // DD of the segment [n, now]
1529            firsts[k] = dd_new;
1530            for i in (b + 1..=n).rev() {
1531                let lag = time - self.times[i - 1];
1532                let v = rc_integrals(k, cbyr, rclsqr, lag).1;
1533                let dd_old = (v - hi_val) / (self.times[i] - self.times[i - 1]);
1534                let c = dd_old - dd_new;
1535                for q in 0..2 {
1536                    sums[k][q] += c * self.dev(waves[k][q], i);
1537                }
1538                hi_val = v;
1539                dd_new = dd_old;
1540            }
1541            // The boundary point b: its share of lags [L_{b+1}, L_b].
1542            let c_b = rc_integrals(k, cbyr, rclsqr, lb).0 - dd_new;
1543            for q in 0..2 {
1544                sums[k][q] += c_b * self.dev(waves[k][q], b);
1545            }
1546        }
1547        self.co.h1_first = firsts[0];
1548        self.co.h2_first = firsts[1];
1549        self.co.h3_first = firsts[2];
1550        let p = &self.p;
1551        let init = |wave: usize| match wave {
1552            V1 => self.init_v1,
1553            V2 => self.init_v2,
1554            I1 => self.init_i1,
1555            _ => self.init_i2,
1556        };
1557        let ints = [p.int_h1dash, p.int_h2, p.int_h3dash];
1558        let mut inp = [0.0; 2];
1559        for q in 0..2 {
1560            for k in 0..3 {
1561                let x0 = init(waves[k][q]);
1562                let d = sums[k][q] + x0 * ints[k] - x0 * firsts[k];
1563                if k == 0 {
1564                    inp[q] -= d;
1565                } else {
1566                    inp[q] += d;
1567                }
1568            }
1569        }
1570        self.input1 = inp[0];
1571        self.input2 = inp[1];
1572        Ok(())
1573    }
1574
1575    /// Advance the recursive states over the newest accepted segment
1576    /// and drop snapshots no later load can reach.
1577    fn fit_accept(&mut self) {
1578        let k = self.times.len() - 1;
1579        let h = self.times[k] - self.times[k - 1];
1580        let dn = [V1, V2, I1, I2].map(|w| self.dev(w, k));
1581        let dp = [V1, V2, I1, I2].map(|w| self.dev(w, k - 1));
1582        let keep = self.oldest_needed();
1583        if let Some(fs) = self.fit.as_mut() {
1584            fs.advance(h, dn, dp);
1585            fs.prune(keep);
1586        }
1587    }
1588
1589    /// The DC / RG stamps of `LTRAload` (`MODEDC`, or the RG case at
1590    /// any time).
1591    fn load_dc(&mut self, ctx: &mut LoadCtx) {
1592        let s = self.slots;
1593        let p = &self.p;
1594        match p.case {
1595            LtraCase::Rg => {
1596                ctx.add(s.ibr1_pos1, 1.0);
1597                ctx.add(s.ibr1_neg1, -1.0);
1598                ctx.add(s.ibr1_pos2, -p.cosh_lroot_gr);
1599                ctx.add(s.ibr1_neg2, p.cosh_lroot_gr);
1600                ctx.add(s.ibr1_ibr2, (1.0 + ctx.gmin) * p.r_rs_lr_gr_or_g);
1601
1602                ctx.add(s.ibr2_ibr2, p.cosh_lroot_gr);
1603                ctx.add(s.ibr2_pos2, -(1.0 + ctx.gmin) * p.r_gs_lr_gr_or_r);
1604                ctx.add(s.ibr2_neg2, (1.0 + ctx.gmin) * p.r_gs_lr_gr_or_r);
1605                ctx.add(s.ibr2_ibr1, 1.0);
1606
1607                ctx.add(s.pos1_ibr1, 1.0);
1608                ctx.add(s.neg1_ibr1, -1.0);
1609                ctx.add(s.pos2_ibr2, 1.0);
1610                ctx.add(s.neg2_ibr2, -1.0);
1611            }
1612            LtraCase::Lc | LtraCase::Rlc | LtraCase::Rc => {
1613                // "load a simple resistor": i1 + i2 = 0 and
1614                // v(P1+) − v(P2+) = R·len·i1 — the C ignores the
1615                // negative nodes here (ngspice too).
1616                ctx.add(s.pos1_ibr1, 1.0);
1617                ctx.add(s.neg1_ibr1, -1.0);
1618                ctx.add(s.pos2_ibr2, 1.0);
1619                ctx.add(s.neg2_ibr2, -1.0);
1620
1621                ctx.add(s.ibr1_ibr1, 1.0);
1622                ctx.add(s.ibr1_ibr2, 1.0);
1623                ctx.add(s.ibr2_pos1, 1.0);
1624                ctx.add(s.ibr2_pos2, -1.0);
1625                ctx.add(s.ibr2_ibr1, -p.resist * p.length);
1626            }
1627        }
1628        self.input1 = 0.0;
1629        self.input2 = 0.0;
1630    }
1631
1632    /// The delayed-timepoint search and interpolation of `LTRAload`
1633    /// (LC/RLC, `tdover`). `None` is the C's E_INTERN "cannot find
1634    /// delayed timepoint" (the step is then cut like a failed
1635    /// Newton solve).
1636    fn delayed(&self, time: f64) -> Option<Delayed> {
1637        let td = self.p.td;
1638        let time_index = self.times.len() - 1;
1639        let target = time - td;
1640        let mut i: isize = time_index as isize;
1641        while i >= 0 {
1642            if self.times[i as usize] < target {
1643                break;
1644            }
1645            i -= 1;
1646        }
1647        if i == time_index as isize {
1648            i -= 1;
1649        }
1650        if i < 0 {
1651            return None;
1652        }
1653        Some(self.interp_delayed(i as usize, target))
1654    }
1655
1656    /// [`Self::delayed`] by bisection (recursive mode): the same
1657    /// bracketing point — the newest one strictly before `time − T`,
1658    /// never the newest of all — in O(log n) instead of the C's
1659    /// backward scan.
1660    fn delayed_bisect(&self, time: f64) -> Option<Delayed> {
1661        let target = time - self.p.td;
1662        let time_index = self.times.len() - 1;
1663        let below = self.times.partition_point(|&t| t < target);
1664        if below == 0 {
1665            return None;
1666        }
1667        let i = (below - 1).min(time_index.checked_sub(1)?);
1668        Some(self.interp_delayed(i, target))
1669    }
1670
1671    /// The interpolation of `LTRAload` around `times[isaved]`,
1672    /// `times[isaved + 1]` (and `times[isaved − 1]` for quadratic).
1673    fn interp_delayed(&self, isaved: usize, target: f64) -> Delayed {
1674        let t2 = self.times[isaved];
1675        let t3 = self.times[isaved + 1];
1676        let interp = self.p.interp;
1677        let quad_ok = isaved != 0 && matches!(interp, LtraInterp::Quadratic | LtraInterp::Mixed);
1678        let mut qf = (0.0, 0.0, 0.0);
1679        if quad_ok {
1680            let t1 = self.times[isaved - 1];
1681            qf = quad_interp(target, t1, t2, t3).unwrap_or((0.0, 0.0, 0.0));
1682        }
1683        let mut lf = (0.0, 0.0);
1684        if isaved == 0 || matches!(interp, LtraInterp::Mixed | LtraInterp::Linear) {
1685            lf = lin_interp(target, t2, t3).unwrap_or((0.0, 0.0));
1686        }
1687        let one = |h: &[f64]| -> f64 {
1688            let mut d = 0.0;
1689            let (mut max, mut min) = (0.0, 0.0);
1690            if quad_ok {
1691                let (a, b, c) = (h[isaved - 1], h[isaved], h[isaved + 1]);
1692                d = a * qf.0 + b * qf.1 + c * qf.2;
1693                max = f64::max(f64::max(a, b), c);
1694                min = f64::min(f64::min(a, b), c);
1695            }
1696            if interp == LtraInterp::Linear || isaved == 0 || (quad_ok && (d > max || d < min)) {
1697                // QUADINTERP keeps the out-of-range quadratic value
1698                // (the C only prints a debug warning).
1699                if !(quad_ok && interp == LtraInterp::Quadratic) {
1700                    d = h[isaved] * lf.0 + h[isaved + 1] * lf.1;
1701                }
1702            }
1703            d
1704        };
1705        Delayed {
1706            v1: one(&self.v1),
1707            i1: one(&self.i1),
1708            v2: one(&self.v2),
1709            i2: one(&self.i2),
1710        }
1711    }
1712
1713    /// The `MODEINITPRED | MODEINITTRAN` part of `LTRAload` for
1714    /// LC/RLC: the history inputs of both branch equations.
1715    fn inputs_lc_rlc(&mut self, del: Option<Delayed>) {
1716        let p = &self.p;
1717        let time_index = self.times.len() - 1;
1718        let (mut in1, mut in2) = (0.0, 0.0);
1719        if p.case == LtraCase::Rlc {
1720            let co = &self.co;
1721            // Convolution of h1' with v1 and v2.
1722            let (mut d1, mut d2) = (0.0, 0.0);
1723            for i in (1..=time_index).rev() {
1724                if co.h1[i] != 0.0 {
1725                    d1 += co.h1[i] * (self.v1[i] - self.init_v1);
1726                    d2 += co.h1[i] * (self.v2[i] - self.init_v2);
1727                }
1728            }
1729            d1 += self.init_v1 * p.int_h1dash;
1730            d2 += self.init_v2 * p.int_h1dash;
1731            d1 -= self.init_v1 * co.h1_first;
1732            d2 -= self.init_v2 * co.h1_first;
1733            in1 -= d1 * p.admit;
1734            in2 -= d2 * p.admit;
1735
1736            // Convolution of h2 with i2 and i1.
1737            let (mut d1, mut d2) = (0.0, 0.0);
1738            if let Some(dl) = del {
1739                d1 = (dl.i2 - self.init_i2) * co.h2_first;
1740                d2 = (dl.i1 - self.init_i1) * co.h2_first;
1741                for i in (1..=co.aux_index).rev() {
1742                    if co.h2[i] != 0.0 {
1743                        d1 += co.h2[i] * (self.i2[i] - self.init_i2);
1744                        d2 += co.h2[i] * (self.i1[i] - self.init_i1);
1745                    }
1746                }
1747            }
1748            d1 += self.init_i2 * p.int_h2;
1749            d2 += self.init_i1 * p.int_h2;
1750            in1 += d1;
1751            in2 += d2;
1752
1753            // Convolution of h3' with v2 and v1.
1754            let (mut d1, mut d2) = (0.0, 0.0);
1755            if let Some(dl) = del {
1756                d1 = (dl.v2 - self.init_v2) * co.h3_first;
1757                d2 = (dl.v1 - self.init_v1) * co.h3_first;
1758                for i in (1..=co.aux_index).rev() {
1759                    if co.h3[i] != 0.0 {
1760                        d1 += co.h3[i] * (self.v2[i] - self.init_v2);
1761                        d2 += co.h3[i] * (self.v1[i] - self.init_v1);
1762                    }
1763                }
1764            }
1765            d1 += self.init_v2 * p.int_h3dash;
1766            d2 += self.init_v1 * p.int_h3dash;
1767            in1 += p.admit * d1;
1768            in2 += p.admit * d2;
1769        }
1770        // The lossless-like parts (RLC falls through into LC).
1771        match del {
1772            None => {
1773                in1 += p.attenuation * (self.init_v2 * p.admit + self.init_i2);
1774                in2 += p.attenuation * (self.init_v1 * p.admit + self.init_i1);
1775            }
1776            Some(dl) => {
1777                in1 += p.attenuation * (dl.v2 * p.admit + dl.i2);
1778                in2 += p.attenuation * (dl.v1 * p.admit + dl.i1);
1779            }
1780        }
1781        self.input1 = in1;
1782        self.input2 = in2;
1783    }
1784
1785    /// The RC inputs of `LTRAload`.
1786    fn inputs_rc(&mut self) {
1787        let p = &self.p;
1788        let co = &self.co;
1789        let time_index = self.times.len() - 1;
1790        let (mut in1, mut in2) = (0.0, 0.0);
1791
1792        let (mut d1, mut d2) = (0.0, 0.0);
1793        for i in (1..=time_index).rev() {
1794            if co.h1[i] != 0.0 {
1795                d1 += co.h1[i] * (self.v1[i] - self.init_v1);
1796                d2 += co.h1[i] * (self.v2[i] - self.init_v2);
1797            }
1798        }
1799        d1 += self.init_v1 * p.int_h1dash;
1800        d2 += self.init_v2 * p.int_h1dash;
1801        d1 -= self.init_v1 * co.h1_first;
1802        d2 -= self.init_v2 * co.h1_first;
1803        in1 -= d1;
1804        in2 -= d2;
1805
1806        let (mut d1, mut d2) = (0.0, 0.0);
1807        for i in (1..=time_index).rev() {
1808            if co.h2[i] != 0.0 {
1809                d1 += co.h2[i] * (self.i2[i] - self.init_i2);
1810                d2 += co.h2[i] * (self.i1[i] - self.init_i1);
1811            }
1812        }
1813        d1 += self.init_i2 * p.int_h2;
1814        d2 += self.init_i1 * p.int_h2;
1815        d1 -= self.init_i2 * co.h2_first;
1816        d2 -= self.init_i1 * co.h2_first;
1817        in1 += d1;
1818        in2 += d2;
1819
1820        let (mut d1, mut d2) = (0.0, 0.0);
1821        for i in (1..=time_index).rev() {
1822            if co.h3[i] != 0.0 {
1823                d1 += co.h3[i] * (self.v2[i] - self.init_v2);
1824                d2 += co.h3[i] * (self.v1[i] - self.init_v1);
1825            }
1826        }
1827        d1 += self.init_v2 * p.int_h3dash;
1828        d2 += self.init_v1 * p.int_h3dash;
1829        d1 -= self.init_v2 * co.h3_first;
1830        d2 -= self.init_v1 * co.h3_first;
1831        in1 += d1;
1832        in2 += d2;
1833
1834        self.input1 = in1;
1835        self.input2 = in2;
1836    }
1837}
1838
1839impl Device for LtraI {
1840    fn par_load(&self) -> bool {
1841        true
1842    }
1843
1844    /// `LTRAinfo` has no `DEVdisto`: the line is linear.
1845    fn disto_setup(&mut self, _ctx: &DistoSetupCtx) -> Result<(), &'static str> {
1846        Ok(())
1847    }
1848
1849    /// The recursive mode's report (issue #8): the model and its
1850    /// error against the closed-form responses, on the setup grid and
1851    /// at the lags the last timepoint's convolution actually spanned.
1852    fn tran_notes(&self) -> Vec<String> {
1853        if !self.fit_on {
1854            return Vec::new();
1855        }
1856        let name = &self.p.name;
1857        let Some(fs) = &self.fit else {
1858            let why = self.fit_skip.unwrap_or("not started");
1859            return vec![format!(
1860                "`{name}`: ltrafit not applied ({why}); exact convolution"
1861            )];
1862        };
1863        let f = &fs.fit;
1864        let (labels, delayed) = match self.p.case {
1865            LtraCase::Rc => (["h1", "h2", "h3"], false),
1866            _ => (["h1'", "h2", "h3'"], true),
1867        };
1868        let mut hist = [0.0; 3];
1869        if let Some(&te) = self.times.last() {
1870            let n = self.times.len() - 1;
1871            let w = f.window;
1872            let lags0 = self.times[..n].iter().map(|t| te - t).filter(|l| *l >= w);
1873            hist[0] = f.err_at(0, lags0);
1874            for (k, h) in hist.iter_mut().enumerate().skip(1) {
1875                *h = if delayed {
1876                    let td = self.p.td;
1877                    let lags = self.times.iter().map(|t| te - td - t).filter(|l| *l > 0.0);
1878                    f.err_at(k, lags)
1879                } else {
1880                    f.err_at(
1881                        k,
1882                        self.times[..n].iter().map(|t| te - t).filter(|l| *l >= w),
1883                    )
1884                };
1885            }
1886        }
1887        let errs: Vec<String> = (0..3)
1888            .map(|k| {
1889                format!(
1890                    "{} {:.1e}/{:.1e}/{:.1e}",
1891                    labels[k], f.err[k].max_rel, f.err[k].l1_rel, hist[k]
1892                )
1893            })
1894            .collect();
1895        let regime = match self.p.case {
1896            LtraCase::Rc => format!(
1897                "RC, RCl²={:.3e}s, exact window {:.3e}s",
1898                self.p.rclsqr, f.window
1899            ),
1900            _ => format!("RLC, T={:.3e}s, R/2L={:.3e}/s", self.p.td, self.p.alpha),
1901        };
1902        vec![format!(
1903            "`{name}`: ltrafit {regime}: {} real poles ({} panels x {} Gauss points); \
1904             kernel error max/L1 on [{:.3e}s, {:.3e}s] / max at the {} history lags: {}",
1905            f.rates.len(),
1906            f.panels,
1907            f.order,
1908            f.window,
1909            f.horizon,
1910            self.times.len(),
1911            errs.join(", ")
1912        )]
1913    }
1914
1915    fn alloc(&mut self, mb: &mut MatrixBuilder) {
1916        let (p1, n1, p2, n2, b1, b2) = (
1917            self.pos1, self.neg1, self.pos2, self.neg2, self.br1, self.br2,
1918        );
1919        self.slots = Slots {
1920            ibr1_pos1: mb.slot(b1, p1),
1921            ibr1_neg1: mb.slot(b1, n1),
1922            ibr1_pos2: mb.slot(b1, p2),
1923            ibr1_neg2: mb.slot(b1, n2),
1924            ibr1_ibr1: mb.slot(b1, b1),
1925            ibr1_ibr2: mb.slot(b1, b2),
1926            ibr2_pos1: mb.slot(b2, p1),
1927            ibr2_neg1: mb.slot(b2, n1),
1928            ibr2_pos2: mb.slot(b2, p2),
1929            ibr2_neg2: mb.slot(b2, n2),
1930            ibr2_ibr1: mb.slot(b2, b1),
1931            ibr2_ibr2: mb.slot(b2, b2),
1932            pos1_ibr1: mb.slot(p1, b1),
1933            neg1_ibr1: mb.slot(n1, b1),
1934            pos2_ibr2: mb.slot(p2, b2),
1935            neg2_ibr2: mb.slot(n2, b2),
1936        };
1937        // "so that SMPpreOrder does not screw up on occasion — for
1938        // example, when one end of the lossy line is hanging".
1939        mb.slot(p1, p1);
1940        mb.slot(n1, n1);
1941        mb.slot(p2, p2);
1942        mb.slot(n2, n2);
1943    }
1944
1945    fn load(&mut self, ctx: &mut LoadCtx) {
1946        if ctx.is_dc() {
1947            self.gmin = ctx.gmin;
1948            self.load_dc(ctx);
1949            ctx.add_rhs(self.br1, self.input1);
1950            ctx.add_rhs(self.br2, self.input2);
1951            return;
1952        }
1953        if self.times.is_empty() {
1954            // The operating point is always accepted (and recorded)
1955            // before the first transient load.
1956            ctx.fault = Some("LTRA: transient load before the operating point was stored");
1957            return;
1958        }
1959        if self.periodic {
1960            if let Some(f) = self.periodic_fault {
1961                ctx.fault = Some(f);
1962                return;
1963            }
1964            if self.fit.is_none() && matches!(self.p.case, LtraCase::Rlc | LtraCase::Rc) {
1965                // The exact walk sums a history that starts at an
1966                // operating point; the periodic past has none.
1967                ctx.fault = Some(
1968                    "LTRA: a lossy (RLC/RC) line in .pss needs the recursive convolution \
1969                     (.options ltrafit)",
1970                );
1971                return;
1972            }
1973        }
1974        let first = matches!(ctx.mode, InitMode::Tran | InitMode::Pred);
1975        let time_index = self.times.len() - 1;
1976        // `.pss`: the periodic past reaches back past every delay.
1977        let tdover = matches!(self.p.case, LtraCase::Lc | LtraCase::Rlc)
1978            && (ctx.time > self.p.td || self.periodic);
1979        let mut del = None;
1980        if first && self.fit.is_some() {
1981            // Recursive mode (issue #8): coefficients and inputs at once.
1982            if tdover {
1983                match self.delayed_bisect(ctx.time) {
1984                    Some(d) => del = Some(d),
1985                    None => {
1986                        ctx.fault = Some("LTRA: cannot find delayed timepoint");
1987                        return;
1988                    }
1989                }
1990            }
1991        } else if first {
1992            match self.p.case {
1993                LtraCase::Rlc => rlc_coeffs(
1994                    &mut self.co,
1995                    self.p.td,
1996                    self.p.alpha,
1997                    self.p.beta,
1998                    ctx.time,
1999                    &self.times,
2000                    time_index,
2001                    self.p.chop_reltol,
2002                ),
2003                LtraCase::Rc => rc_coeffs(
2004                    &mut self.co,
2005                    self.p.cbyr,
2006                    self.p.rclsqr,
2007                    ctx.time,
2008                    &self.times,
2009                    time_index,
2010                    self.p.chop_reltol,
2011                ),
2012                LtraCase::Lc | LtraCase::Rg => {}
2013            }
2014            if tdover {
2015                match self.delayed(ctx.time) {
2016                    Some(d) => del = Some(d),
2017                    None => {
2018                        ctx.fault = Some("LTRA: cannot find delayed timepoint");
2019                        return;
2020                    }
2021                }
2022            }
2023        }
2024
2025        if self.p.case == LtraCase::Rg {
2026            self.load_dc(ctx);
2027            ctx.add_rhs(self.br1, self.input1);
2028            ctx.add_rhs(self.br2, self.input2);
2029            return;
2030        }
2031
2032        // First timepoint after zero: record the operating point.
2033        if ctx.mode == InitMode::Tran && !ctx.uic && !self.periodic {
2034            self.init_v1 = ctx.sol[self.pos1] - ctx.sol[self.neg1];
2035            self.init_v2 = ctx.sol[self.pos2] - ctx.sol[self.neg2];
2036            self.init_i1 = ctx.sol[self.br1];
2037            self.init_i2 = ctx.sol[self.br2];
2038        }
2039
2040        // Recursive mode: the first coefficients the stamps below use
2041        // come out of the same pass as the history inputs.
2042        if first && self.fit.is_some() {
2043            let r = match self.p.case {
2044                LtraCase::Lc | LtraCase::Rlc => self.fit_inputs_lc_rlc(ctx.time, del),
2045                LtraCase::Rc => self.fit_inputs_rc(ctx.time),
2046                LtraCase::Rg => Ok(()),
2047            };
2048            if let Err(e) = r {
2049                ctx.fault = Some(e);
2050                return;
2051            }
2052        }
2053
2054        // Matrix loading, every call.
2055        let s = self.slots;
2056        let p = &self.p;
2057        match p.case {
2058            LtraCase::Rlc | LtraCase::Lc => {
2059                if p.case == LtraCase::Rlc {
2060                    let d = p.admit * self.co.h1_first;
2061                    ctx.add(s.ibr1_pos1, d);
2062                    ctx.add(s.ibr1_neg1, -d);
2063                    ctx.add(s.ibr2_pos2, d);
2064                    ctx.add(s.ibr2_neg2, -d);
2065                }
2066                ctx.add(s.ibr1_pos1, p.admit);
2067                ctx.add(s.ibr1_neg1, -p.admit);
2068                ctx.add(s.ibr1_ibr1, -1.0);
2069                ctx.add(s.pos1_ibr1, 1.0);
2070                ctx.add(s.neg1_ibr1, -1.0);
2071
2072                ctx.add(s.ibr2_pos2, p.admit);
2073                ctx.add(s.ibr2_neg2, -p.admit);
2074                ctx.add(s.ibr2_ibr2, -1.0);
2075                ctx.add(s.pos2_ibr2, 1.0);
2076                ctx.add(s.neg2_ibr2, -1.0);
2077            }
2078            LtraCase::Rc => {
2079                ctx.add(s.ibr1_ibr1, -1.0);
2080                ctx.add(s.pos1_ibr1, 1.0);
2081                ctx.add(s.neg1_ibr1, -1.0);
2082
2083                ctx.add(s.ibr2_ibr2, -1.0);
2084                ctx.add(s.pos2_ibr2, 1.0);
2085                ctx.add(s.neg2_ibr2, -1.0);
2086
2087                let d = self.co.h1_first;
2088                ctx.add(s.ibr1_pos1, d);
2089                ctx.add(s.ibr1_neg1, -d);
2090                ctx.add(s.ibr2_pos2, d);
2091                ctx.add(s.ibr2_neg2, -d);
2092
2093                let d = self.co.h2_first;
2094                ctx.add(s.ibr1_ibr2, -d);
2095                ctx.add(s.ibr2_ibr1, -d);
2096
2097                let d = self.co.h3_first;
2098                ctx.add(s.ibr1_pos2, -d);
2099                ctx.add(s.ibr1_neg2, d);
2100                ctx.add(s.ibr2_pos1, -d);
2101                ctx.add(s.ibr2_neg1, d);
2102            }
2103            LtraCase::Rg => {} // stamped by `load_dc` above
2104        }
2105
2106        if first && self.fit.is_none() {
2107            match self.p.case {
2108                LtraCase::Lc | LtraCase::Rlc => self.inputs_lc_rlc(del),
2109                LtraCase::Rc => self.inputs_rc(),
2110                LtraCase::Rg => {}
2111            }
2112        }
2113
2114        ctx.add_rhs(self.br1, self.input1);
2115        ctx.add_rhs(self.br2, self.input2);
2116    }
2117
2118    /// `LTRAtrunc`: the delay ceiling (`STEPLIMIT`, or a derivative
2119    /// change of the incident waves under `NOSTEPLIMIT`) and the RLC
2120    /// impulse-response safe step. The LTE branch needs
2121    /// `fullcontrol`/`halfcontrol`, never settable, so it is absent.
2122    fn trunc(&self, t: &TerrCtx, time_step: &mut f64) {
2123        let p = &self.p;
2124        if matches!(p.case, LtraCase::Lc | LtraCase::Rlc) {
2125            if p.step_limit {
2126                *time_step = time_step.min(p.td);
2127            } else if self.times.len() >= 2 {
2128                let idx = self.times.len() - 1;
2129                let sol = t.sol;
2130                let a = p.admit;
2131                let att = p.attenuation;
2132                let i1 = ((sol[self.pos2] - sol[self.neg2]) * a + sol[self.br2]) * att;
2133                let i2 = (self.v2[idx] * a + self.i2[idx]) * att;
2134                let i3 = (self.v2[idx - 1] * a + self.i2[idx - 1]) * att;
2135                let i4 = ((sol[self.pos1] - sol[self.neg1]) * a + sol[self.br1]) * att;
2136                let i5 = (self.v1[idx] * a + self.i1[idx]) * att;
2137                let i6 = (self.v1[idx - 1] * a + self.i1[idx - 1]) * att;
2138                let h_now = t.time - self.times[idx];
2139                let h_old = self.times[idx] - self.times[idx - 1];
2140                let d1 = (i1 - i2) / h_now;
2141                let d2 = (i2 - i3) / h_old;
2142                let d3 = (i4 - i5) / h_now;
2143                let d4 = (i5 - i6) / h_old;
2144                if (d1 - d2).abs() >= p.reltol * f64::max(d1.abs(), d2.abs()) + p.abstol
2145                    || (d3 - d4).abs() >= p.reltol * f64::max(d3.abs(), d4.abs()) + p.abstol
2146                {
2147                    // "derivative changing - need to schedule after
2148                    // delay"
2149                    *time_step = time_step.min(p.td);
2150                }
2151            }
2152        }
2153        if p.case == LtraCase::Rlc && !p.trunc_dont_cut {
2154            *time_step = time_step.min(p.max_safe_step);
2155        }
2156        if self.interp_control
2157            && matches!(p.case, LtraCase::Rlc | LtraCase::Lc)
2158            && let Some(err) = self.interp_error(t)
2159        {
2160            // The error goes as the square of the step: the step that
2161            // meets the tolerance is `h·√(tol/err)`.
2162            let peak = f64::max(self.wave_peak[0], self.wave_peak[1]);
2163            let tol = t.trtol * (t.reltol * peak + t.abstol);
2164            let h = t.time - self.times[self.times.len() - 1];
2165            if err > 0.0 && h > 0.0 {
2166                *time_step = time_step.min(h * (tol / err).sqrt());
2167            }
2168        }
2169    }
2170
2171    /// `LTRAacLoad`: `Y0(s)·V1 − I1 = e^{−λ(s)·l}·(Y0(s)·V2 + I2)` and
2172    /// its mirror, `Y0 = √((sC+G)/(sL+R))`, `λ = √((sC+G)(sL+R))`.
2173    /// RG is loaded exactly as in DC.
2174    fn load_ac(&mut self, ctx: &mut AcCtx) {
2175        let p = &self.p;
2176        let s = self.slots;
2177        let w = ctx.omega;
2178        let (y0_r, y0_i, lambda_r, lambda_i) = match p.case {
2179            LtraCase::Lc => (p.admit, 0.0, 0.0, p.td * w),
2180            LtraCase::Rlc => {
2181                let theta = 0.5 * atan(p.resist / (w * p.induct));
2182                let zmag = (p.resist * p.resist + w * w * p.induct * p.induct).sqrt();
2183                let mag = (w * p.capac / zmag).sqrt();
2184                let y0_r = mag * cos(theta);
2185                let y0_i = mag * sin(theta);
2186                let theta = std::f64::consts::FRAC_PI_2 - theta;
2187                let mag = mag * zmag;
2188                (y0_r, y0_i, mag * cos(theta), mag * sin(theta))
2189            }
2190            LtraCase::Rc => {
2191                let y = (0.5 * w * p.cbyr).sqrt();
2192                let l = (0.5 * w * p.resist * p.capac).sqrt();
2193                (y, y, l, l)
2194            }
2195            LtraCase::Rg => {
2196                let g1 = (1.0 + self.gmin) * p.r_rs_lr_gr_or_g;
2197                let g2 = (1.0 + self.gmin) * p.r_gs_lr_gr_or_r;
2198                ctx.add_g(s.ibr1_pos1, 1.0);
2199                ctx.add_g(s.ibr1_neg1, -1.0);
2200                ctx.add_g(s.ibr1_pos2, -p.cosh_lroot_gr);
2201                ctx.add_g(s.ibr1_neg2, p.cosh_lroot_gr);
2202                ctx.add_g(s.ibr1_ibr2, g1);
2203                ctx.add_g(s.ibr2_ibr2, p.cosh_lroot_gr);
2204                ctx.add_g(s.ibr2_pos2, -g2);
2205                ctx.add_g(s.ibr2_neg2, g2);
2206                ctx.add_g(s.ibr2_ibr1, 1.0);
2207                ctx.add_g(s.pos1_ibr1, 1.0);
2208                ctx.add_g(s.neg1_ibr1, -1.0);
2209                ctx.add_g(s.pos2_ibr2, 1.0);
2210                ctx.add_g(s.neg2_ibr2, -1.0);
2211                return;
2212            }
2213        };
2214        let exparg_r = -lambda_r * p.length;
2215        let exparg_i = -lambda_i * p.length;
2216        let explambda_r = exp(exparg_r) * cos(exparg_i);
2217        let explambda_i = exp(exparg_r) * sin(exparg_i);
2218        let y0exp_r = y0_r * explambda_r - y0_i * explambda_i;
2219        let y0exp_i = y0_r * explambda_i + y0_i * explambda_r;
2220
2221        let both = |ctx: &mut AcCtx, sl: Slot, re: f64, im: f64| {
2222            ctx.add_g(sl, re);
2223            ctx.add_b(sl, im);
2224        };
2225        both(ctx, s.ibr1_pos1, y0_r, y0_i);
2226        both(ctx, s.ibr1_neg1, -y0_r, -y0_i);
2227        ctx.add_g(s.ibr1_ibr1, -1.0);
2228        both(ctx, s.ibr1_pos2, -y0exp_r, -y0exp_i);
2229        both(ctx, s.ibr1_neg2, y0exp_r, y0exp_i);
2230        both(ctx, s.ibr1_ibr2, -explambda_r, -explambda_i);
2231
2232        both(ctx, s.ibr2_pos2, y0_r, y0_i);
2233        both(ctx, s.ibr2_neg2, -y0_r, -y0_i);
2234        ctx.add_g(s.ibr2_ibr2, -1.0);
2235        both(ctx, s.ibr2_pos1, -y0exp_r, -y0exp_i);
2236        both(ctx, s.ibr2_neg1, y0exp_r, y0exp_i);
2237        both(ctx, s.ibr2_ibr1, -explambda_r, -explambda_i);
2238
2239        ctx.add_g(s.pos1_ibr1, 1.0);
2240        ctx.add_g(s.neg1_ibr1, -1.0);
2241        ctx.add_g(s.pos2_ibr2, 1.0);
2242        ctx.add_g(s.neg2_ibr2, -1.0);
2243    }
2244
2245    fn compact_ready(&self) -> Option<bool> {
2246        self.straight_tail()
2247    }
2248
2249    fn compact(&mut self) {
2250        self.drop_middle();
2251    }
2252
2253    fn history_len(&self) -> Option<usize> {
2254        Some(self.times.len())
2255    }
2256
2257    /// `LTRAaccept`: store the accepted point in the history and, when
2258    /// the slope of an incident wave `(v + Z0·i)·e^{−βT}` changes,
2259    /// plant a breakpoint one delay after the *previous* point (where
2260    /// the change happened).
2261    fn accept(&mut self, ctx: &mut AcceptCtx) {
2262        if ctx.first {
2263            // MODEINITTRAN: the operating point starts the history.
2264            self.wave_peak = [0.0; 2];
2265            self.times.clear();
2266            self.v1.clear();
2267            self.i1.clear();
2268            self.v2.clear();
2269            self.i2.clear();
2270            self.periodic = false;
2271            self.periodic_fault = None;
2272            if let Some(past) = ctx.periodic {
2273                self.seed_periodic(past, ctx);
2274                return;
2275            }
2276        }
2277        // The sample before this one was kept: it is the new anchor.
2278        if !self.compacted {
2279            self.dropped.clear();
2280        }
2281        self.compacted = false;
2282        let sol = ctx.sol;
2283        self.times.push(ctx.time);
2284        self.v1.push(sol[self.pos1] - sol[self.neg1]);
2285        self.v2.push(sol[self.pos2] - sol[self.neg2]);
2286        self.i1.push(sol[self.br1]);
2287        self.i2.push(sol[self.br2]);
2288
2289        let idx = self.times.len() - 1;
2290        if self.fit_on {
2291            if ctx.first {
2292                self.start_fit(ctx.final_time);
2293            } else if idx > 0 && self.fit.is_some() {
2294                self.fit_accept();
2295            }
2296        }
2297        let p = &self.p;
2298        let wave = |v: f64, i: f64| ((v + i * p.imped) * p.attenuation).abs();
2299        self.wave_peak[0] = self.wave_peak[0].max(wave(self.v1[idx], self.i1[idx]));
2300        self.wave_peak[1] = self.wave_peak[1].max(wave(self.v2[idx], self.i2[idx]));
2301        if idx == 0 {
2302            return;
2303        }
2304        self.corner = self.slope_changed(idx, ctx.reltol, ctx.abstol);
2305        if self.corner {
2306            let t = self.times[idx - 1] + self.p.td;
2307            ctx.set_slope_break(t);
2308        }
2309    }
2310}
2311
2312/// The relative change of slope that makes a corner of an incident
2313/// wave under the Dune profile ([`LtraI::slope_changed`]).
2314const CORNER_REL: f64 = 0.5;
2315
2316impl LtraI {
2317    /// The error the delayed waves will carry when this point is
2318    /// read back one delay later (Dune's, D-0055): `v(t − T)` and
2319    /// `i(t − T)` are interpolated between history points, and
2320    /// between two points `h` apart a straight line misses a wave of
2321    /// second derivative `w″` by `h²·|w″|/8`. `w″` is the second
2322    /// divided difference of each incident wave over the last two
2323    /// accepted points and the one just solved. Nothing in the 3f5
2324    /// bounds this: `LTRAtrunc` only keeps the step under the delay,
2325    /// and its `LTRAlteCalculate` (the convolutions' truncation
2326    /// error; unreachable from a card) was ported and measured — on
2327    /// the corpus it sits six orders below its tolerance and changes
2328    /// no deck. `None` before there are two accepted points.
2329    fn interp_error(&self, t: &TerrCtx) -> Option<f64> {
2330        let p = &self.p;
2331        let idx = self.times.len().checked_sub(1)?;
2332        if idx < 1 {
2333            return None;
2334        }
2335        let wave = |v: f64, i: f64| (v + i * p.imped) * p.attenuation;
2336        let (t2, t1, t0) = (self.times[idx - 1], self.times[idx], t.time);
2337        let h = t0 - t1;
2338        let second = |a: f64, b: f64, c: f64| ((c - b) / h - (b - a) / (t1 - t2)) / (t0 - t2);
2339        let d1 = second(
2340            wave(self.v1[idx - 1], self.i1[idx - 1]),
2341            wave(self.v1[idx], self.i1[idx]),
2342            wave(t.sol[self.pos1] - t.sol[self.neg1], t.sol[self.br1]),
2343        );
2344        let d2 = second(
2345            wave(self.v2[idx - 1], self.i2[idx - 1]),
2346            wave(self.v2[idx], self.i2[idx]),
2347            wave(t.sol[self.pos2] - t.sol[self.neg2], t.sol[self.br2]),
2348        );
2349        let err = 0.125 * h * h * f64::max(d1.abs(), d2.abs());
2350        err.is_finite().then_some(err)
2351    }
2352
2353    /// `trytocompact`'s test in `LTRAaccept` (`ltraacct.c:84-127`):
2354    /// whether the last three samples of `v1`, `v2`, `i1` and `i2`
2355    /// each lie on a straight line in time within the model's
2356    /// `COMPACTREL`/`COMPACTABS` (`LTRAstraightLineCheck`). `None` when
2357    /// the line takes no part (fewer than three samples — the C's
2358    /// `CKTtimeIndex >= 2` —, the recursive mode, or a `.pss` run).
2359    fn straight_tail(&self) -> Option<bool> {
2360        let n = self.times.len();
2361        if n < 3 || self.fit_on || self.periodic {
2362            return None;
2363        }
2364        // Dune's guard (D-0055, with the slope floor): the middle
2365        // sample is a corner the slope rule has just found. The C's
2366        // test is on areas, and one step after a corner — a
2367        // picosecond, on a refined run — the corner passes it; the
2368        // history then holds a ramp from the last kept point to the
2369        // newest, the corner slides along with every step, and the
2370        // slope rule fires at each of them (the breakpoint cascade of
2371        // `regress/ltra_compact` at TMAX/40).
2372        if self.slope_floor {
2373            return Some(!self.corner && self.chord_holds());
2374        }
2375        let (t1, t2, t3) = (self.times[n - 3], self.times[n - 2], self.times[n - 1]);
2376        let (rt, at) = (self.p.st_line_reltol, self.p.st_line_abstol);
2377        let straight =
2378            |y: &[f64]| straight_line_check(t1, y[n - 3], t2, y[n - 2], t3, y[n - 1], rt, at);
2379        Some(straight(&self.v1) && straight(&self.v2) && straight(&self.i1) && straight(&self.i2))
2380    }
2381
2382    /// Dune's test for dropping the middle of the last three samples
2383    /// (D-0056): the chord from the last sample kept to the newest
2384    /// passes within `COMPACTREL`/`COMPACTABS` of the middle one *and
2385    /// of every sample dropped since that one was kept*, in all four
2386    /// port waveforms. The C's test looks at the last three samples
2387    /// of the history as compacted so far, on areas: each drop is
2388    /// within tolerance of a history that has already lost the
2389    /// samples before it, and the errors add up without bound — on a
2390    /// refined run the history of a ramp and its plateau becomes one
2391    /// long ramp. Against the anchor, the whole run of dropped
2392    /// samples stays within one tolerance of what the line reads.
2393    fn chord_holds(&self) -> bool {
2394        let n = self.times.len();
2395        let (ta, tc) = (self.times[n - 3], self.times[n - 1]);
2396        let (rt, at) = (self.p.st_line_reltol, self.p.st_line_abstol);
2397        let hist = [&self.v1, &self.v2, &self.i1, &self.i2];
2398        let middle = (
2399            self.times[n - 2],
2400            [
2401                self.v1[n - 2],
2402                self.v2[n - 2],
2403                self.i1[n - 2],
2404                self.i2[n - 2],
2405            ],
2406        );
2407        self.dropped
2408            .iter()
2409            .chain(std::iter::once(&middle))
2410            .all(|&(t, y)| {
2411                let f = (t - ta) / (tc - ta);
2412                (0..4).all(|k| {
2413                    let (ya, yc) = (hist[k][n - 3], hist[k][n - 1]);
2414                    let chord = ya + f * (yc - ya);
2415                    let scale = ya.abs().max(yc.abs()).max(y[k].abs());
2416                    (y[k] - chord).abs() <= rt * scale + at
2417                })
2418            })
2419    }
2420
2421    /// The compaction itself (`ltraacct.c:262-285`): the newest sample
2422    /// overwrites the middle one, which leaves the history.
2423    fn drop_middle(&mut self) {
2424        let n = self.times.len();
2425        debug_assert!(n >= 3);
2426        self.dropped.push((
2427            self.times[n - 2],
2428            [
2429                self.v1[n - 2],
2430                self.v2[n - 2],
2431                self.i1[n - 2],
2432                self.i2[n - 2],
2433            ],
2434        ));
2435        self.compacted = true;
2436        for h in [
2437            &mut self.times,
2438            &mut self.v1,
2439            &mut self.v2,
2440            &mut self.i1,
2441            &mut self.i2,
2442        ] {
2443            h.swap_remove(n - 2);
2444        }
2445    }
2446
2447    /// `LTRAaccept`'s test at history point `idx ≥ 1`: whether the
2448    /// slope of an incident wave changed enough (and the wave is not
2449    /// a straight line within tolerance) to want a breakpoint one
2450    /// delay after point `idx − 1`.
2451    fn slope_changed(&self, idx: usize, reltol: f64, abstol: f64) -> bool {
2452        let p = &self.p;
2453        let wave1 = |k: usize| (self.v1[k] + self.i1[k] * p.imped) * p.attenuation;
2454        let wave2 = |k: usize| (self.v2[k] + self.i2[k] * p.imped) * p.attenuation;
2455        let v1 = wave1(idx);
2456        let v2 = wave1(idx - 1);
2457        let v3 = if idx < 2 { v2 } else { wave1(idx - 2) };
2458        let v4 = wave2(idx);
2459        let v5 = wave2(idx - 1);
2460        let v6 = if idx < 2 { v5 } else { wave2(idx - 2) };
2461        let h1 = self.times[idx] - self.times[idx - 1];
2462        let d1 = (v1 - v2) / h1;
2463        let d3 = (v4 - v5) / h1;
2464        // At the first step the C divides the zero `v2 − v3` by a
2465        // difference reaching before the list (`CKTtimePoints[-1]`);
2466        // the quotient is zero.
2467        let (d2, d4) = if idx < 2 {
2468            (0.0, 0.0)
2469        } else {
2470            let h2 = self.times[idx - 1] - self.times[idx - 2];
2471            ((v2 - v3) / h2, (v5 - v6) / h2)
2472        };
2473        if self.slope_floor {
2474            // Dune's corner test (D-0056). The C's (below) has two
2475            // conditions that between them miss most corners. With
2476            // the model's default `REL = 1` the slope must change by
2477            // its own size *plus* `ABS`, so a ramp that starts from a
2478            // plateau fails by 1e-12 and only reversals pass; and the
2479            // three values must spread by 50·RELTOL of their *mean* —
2480            // 5 % of the level a wave sits at, which the first, short
2481            // step after a breakpoint never covers (while around
2482            // zero the same test is 50·ABSTOL and passes on residue).
2483            // A corner that is missed gets no grid point one delay
2484            // later, the interpolation rounds it, and the rounding
2485            // compounds at every reflection.
2486            //
2487            // Here a corner is a slope that changes by half its size
2488            // by an amount that a straight line across the two steps
2489            // would miss by more than RELTOL of the largest the
2490            // line's waves have been (`Δslope·h/8`, the same measure
2491            // as [`LtraI::interp_error`]). Smooth curvature does not
2492            // qualify: its change of slope shrinks with the step,
2493            // which that control already keeps short enough.
2494            let peak = f64::max(self.wave_peak[0], self.wave_peak[1]);
2495            let h = if idx < 2 {
2496                h1
2497            } else {
2498                f64::max(h1, self.times[idx - 1] - self.times[idx - 2])
2499            };
2500            let corner = |a: f64, b: f64| {
2501                let change = (a - b).abs();
2502                change >= CORNER_REL * f64::max(a.abs(), b.abs())
2503                    && 0.125 * change * h >= reltol * peak + abstol
2504            };
2505            return corner(d1, d2) || corner(d3, d4);
2506        }
2507        // `LTRAaccept`: the slope changed by `REL`/`ABS` (the model's),
2508        // and the three values are not a straight line.
2509        let check = |a: f64, b: f64, c: f64| {
2510            f64::max(f64::max(a, b), c) - f64::min(f64::min(a, b), c)
2511                >= (50.0 * (reltol / 3.0 * (a + b + c) + abstol)).abs()
2512        };
2513        let test1 = (d1 - d2).abs() >= p.reltol * f64::max(d1.abs(), d2.abs()) + p.abstol
2514            && check(v1, v2, v3);
2515        let test2 = (d3 - d4).abs() >= p.reltol * f64::max(d3.abs(), d4.abs()) + p.abstol
2516            && check(v4, v5, v6);
2517        test1 || test2
2518    }
2519
2520    /// `.pss` (issue #20): the history of a shooting interval starting
2521    /// at `ctx.time = t₀` is the periodic past. The port histories get
2522    /// whole periods of the orbit back to the oldest lag a load can
2523    /// reach (one delay; the exact window of an RC fit), then the
2524    /// shooting state at `t₀`. The deviations are taken from the
2525    /// orbit's mean (not the operating point: there is none), so the
2526    /// constant part is the closed-form `∫h` term as always and the
2527    /// recursive states see a zero-mean periodic input, which
2528    /// [`FitState::periodic_start`] sums over the infinite past in
2529    /// closed form. The breakpoints the slope rule would have planted
2530    /// one delay after the past's slope changes are planted where they
2531    /// fall inside the interval.
2532    fn seed_periodic(&mut self, past: &PeriodicPast, ctx: &mut AcceptCtx) {
2533        self.periodic = true;
2534        let t0 = ctx.time;
2535        let period = past.period;
2536        let mut reach = self.p.td;
2537        if self.fit_on && self.p.case == LtraCase::Rc {
2538            reach = reach.max(RC_WINDOW_FRACTION * self.p.rclsqr);
2539        }
2540        // A sliver more than the reach, so a delay of exactly k periods
2541        // still finds a point before its target (quadratic interpolation).
2542        let from = t0 - reach - 1e-6 * period;
2543        let Some(samples) = past.samples(t0, from, ctx.sol) else {
2544            self.periodic_fault = Some("LTRA: the delay spans too many periods for .pss");
2545            return;
2546        };
2547        let (p1, n1, p2, n2, b1, b2) = (
2548            self.pos1, self.neg1, self.pos2, self.neg2, self.br1, self.br2,
2549        );
2550        for (t, x) in samples.into_iter().chain([(t0, ctx.sol)]) {
2551            self.times.push(t);
2552            self.v1.push(x[p1] - x[n1]);
2553            self.v2.push(x[p2] - x[n2]);
2554            self.i1.push(x[b1]);
2555            self.i2.push(x[b2]);
2556        }
2557        // One period is points 0..=per of the first copy.
2558        let per = past.phase.len() + 1;
2559        let mean = |w: &[f64]| {
2560            let mut acc = 0.0;
2561            for k in 1..=per {
2562                acc += 0.5 * (w[k] + w[k - 1]) * (self.times[k] - self.times[k - 1]);
2563            }
2564            acc / (self.times[per] - self.times[0])
2565        };
2566        self.init_v1 = mean(&self.v1);
2567        self.init_v2 = mean(&self.v2);
2568        self.init_i1 = mean(&self.i1);
2569        self.init_i2 = mean(&self.i2);
2570        if matches!(self.p.case, LtraCase::Rlc | LtraCase::Rc) {
2571            // The DC pin. With G = 0 the characteristic form loses the
2572            // line's DC drop: `Y0(0) = 0`, so at s = 0 both branch
2573            // equations reduce to `i1 + i2 = 0`, and the drop
2574            // `v1 − v2 = R·LEN·i1` lives only in the `√s` order — in
2575            // the time domain, the `τ^{−3/2}` tails integrated over a
2576            // history that starts at an operating point (whose DC
2577            // stamps are that resistor). A past that has always been
2578            // periodic carries no such start: its mean drop is a
2579            // neutral mode the orbit just copies (measured: the line's
2580            // apparent DC resistance drifting a fixed amount per
2581            // iteration, the period mismatch never closing at tight
2582            // tolerances). The past is pinned to the exact DC
2583            // relation instead: its mean voltages are shifted
2584            // symmetrically, the mean current kept, so that the mean
2585            // drop is `R·LEN` times the mean current. A fixed point of
2586            // the iteration then has the past equal to the orbit, so
2587            // the orbit itself meets the DC relation — the shift is
2588            // only nonzero while iterating.
2589            let i = 0.5 * (self.init_i1 - self.init_i2);
2590            let drop = self.p.resist * self.p.length * i;
2591            let avg = 0.5 * (self.init_v1 + self.init_v2);
2592            let (s1, s2) = (
2593                avg + 0.5 * drop - self.init_v1,
2594                avg - 0.5 * drop - self.init_v2,
2595            );
2596            let last = self.times.len() - 1;
2597            for k in 0..last {
2598                self.v1[k] += s1;
2599                self.v2[k] += s2;
2600            }
2601            self.init_v1 += s1;
2602            self.init_v2 += s2;
2603        }
2604        if self.fit_on {
2605            self.build_fit(ctx.final_time.max(PERIODIC_HORIZON_PERIODS * period));
2606            let seg = |k: usize| {
2607                (
2608                    self.times[k] - self.times[k - 1],
2609                    [V1, V2, I1, I2].map(|w| self.dev(w, k)),
2610                    [V1, V2, I1, I2].map(|w| self.dev(w, k - 1)),
2611                )
2612            };
2613            let first: Vec<_> = (1..=per).map(seg).collect();
2614            let rest: Vec<_> = (1..self.times.len()).map(seg).collect();
2615            let keep = self.oldest_needed();
2616            let span = self.times[per] - self.times[0];
2617            if let Some(fs) = self.fit.as_mut() {
2618                fs.periodic_start(span, &first);
2619                for (h, dn, dp) in rest {
2620                    fs.advance(h, dn, dp);
2621                }
2622                fs.prune(keep);
2623            }
2624        }
2625        for idx in 1..self.times.len() {
2626            let t = self.times[idx - 1] + self.p.td;
2627            if t > t0 + ctx.min_break
2628                && t < ctx.final_time - ctx.min_break
2629                && self.slope_changed(idx, ctx.reltol, ctx.abstol)
2630            {
2631                ctx.set_slope_break(t);
2632            }
2633        }
2634    }
2635}
2636
2637/// The C's unscaled Bessel fits, for the scaled ones' tests.
2638#[cfg(test)]
2639pub(crate) fn tests_bess(x: f64) -> (f64, f64) {
2640    (bess_i0(x), bess_i1(x))
2641}
2642
2643#[cfg(test)]
2644mod tests {
2645    use super::*;
2646
2647    fn close(a: f64, b: f64, rel: f64) -> bool {
2648        (a - b).abs() <= rel * f64::max(a.abs(), b.abs())
2649    }
2650
2651    /// `LTRArlcH1dashFunc` (test reference only; the load uses its
2652    /// closed-form twice integral).
2653    fn rlc_h1dash(time: f64, alpha: f64, beta: f64) -> f64 {
2654        if alpha == 0.0 {
2655            return 0.0;
2656        }
2657        (bess_i1(alpha * time) - bess_i0(alpha * time)) * alpha * (-beta * time).exp()
2658    }
2659
2660    /// `LTRArlcH3dashFunc`.
2661    fn rlc_h3dash(time: f64, t: f64, alpha: f64, beta: f64) -> f64 {
2662        if alpha == 0.0 || time < t {
2663            return 0.0;
2664        }
2665        let besselarg = if time != t {
2666            alpha * (time * time - t * t).sqrt()
2667        } else {
2668            0.0
2669        };
2670        (alpha * time * bess_i1_x_over_x(besselarg) - bess_i0(besselarg))
2671            * alpha
2672            * (-beta * time).exp()
2673    }
2674
2675    /// `LTRArlcH1dashTwiceIntFunc`.
2676    fn rlc_h1dash_twice_int(time: f64, beta: f64) -> f64 {
2677        if beta == 0.0 {
2678            return time;
2679        }
2680        let arg = beta * time;
2681        if arg == 0.0 {
2682            return 0.0;
2683        }
2684        (bess_i1(arg) + bess_i0(arg)) * time * (-arg).exp() - time
2685    }
2686
2687    /// `LTRArlcH3dashIntFunc`.
2688    fn rlc_h3dash_int(time: f64, t: f64, beta: f64) -> f64 {
2689        if time <= t || beta == 0.0 {
2690            return 0.0;
2691        }
2692        (-beta * time).exp() * bess_i0(beta * (time * time - t * t).sqrt()) - (-beta * t).exp()
2693    }
2694
2695    #[test]
2696    fn bessel_fits_match_reference_values() {
2697        // Abramowitz & Stegun table values; NR fits are good to ~1e-7.
2698        assert!(close(bess_i0(1.0), 1.266_065_877_752_008, 2e-7));
2699        assert!(close(bess_i1(1.0), 0.565_159_103_992_485, 2e-7));
2700        assert!(close(bess_i0(5.0), 27.239_871_823_604_44, 2e-7));
2701        assert!(close(bess_i1(5.0), 24.335_642_142_450_52, 2e-7));
2702        assert!(close(bess_i1(-5.0), -24.335_642_142_450_52, 2e-7));
2703        assert!(close(
2704            bess_i1_x_over_x(5.0),
2705            24.335_642_142_450_52 / 5.0,
2706            2e-7
2707        ));
2708        assert_eq!(bess_i1_x_over_x(0.0), 0.5);
2709        assert!(close(bess_i1_x_over_x(2.0), bess_i1(2.0) / 2.0, 1e-15));
2710    }
2711
2712    #[test]
2713    fn linear_integrals_are_exact() {
2714        // h(τ) = 1 + τ through (0, 1) and (2, 3).
2715        assert!(close(intlinfunc(0.0, 2.0, 1.0, 3.0, 0.0, 2.0), 4.0, 1e-15));
2716        assert!(close(intlinfunc(0.5, 1.5, 1.0, 3.0, 0.0, 2.0), 2.0, 1e-15));
2717        // ∫_0^2 ∫_0^τ (1 + τ') dτ' dτ = ∫_0^2 (τ + τ²/2) dτ = 2 + 4/3.
2718        let v = twiceintlinfunc(0.0, 2.0, 0.0, 1.0, 3.0, 0.0, 2.0);
2719        assert!(close(v, 2.0 + 4.0 / 3.0, 1e-14), "{v}");
2720        // Inner lower limit 1: ∫_1^2 ∫_1^τ (1+τ') = ∫_1^2 (τ−1)(3+τ)/2
2721        // = 7/6.
2722        let v = twiceintlinfunc(1.0, 2.0, 1.0, 1.0, 3.0, 0.0, 2.0);
2723        assert!(close(v, 7.0 / 6.0, 1e-14), "{v}");
2724    }
2725
2726    #[test]
2727    fn interpolation_reproduces_polynomials() {
2728        let (c1, c2, c3) = quad_interp(1.7, 0.0, 1.0, 3.0).unwrap();
2729        let f = |t: f64| 2.0 - t + 0.5 * t * t;
2730        let q = c1 * f(0.0) + c2 * f(1.0) + c3 * f(3.0);
2731        assert!(close(q, f(1.7), 1e-14));
2732        assert_eq!(quad_interp(1.0, 0.0, 1.0, 3.0), Some((0.0, 1.0, 0.0)));
2733        assert_eq!(quad_interp(0.5, 1.0, 1.0, 3.0), None);
2734        let (l1, l2) = lin_interp(0.25, 0.0, 1.0).unwrap();
2735        assert_eq!((l1, l2), (0.75, 0.25));
2736    }
2737
2738    #[test]
2739    fn closed_form_integrals_differentiate_to_the_responses() {
2740        // α = β (G = 0): H1''' = h1', d/dt H3int = h3'.
2741        let (alpha, t) = (1.1e7, 4.38e-9);
2742        for &time in &[1e-9, 5e-9, 2e-8] {
2743            let h = time * 1e-4;
2744            let f = |x: f64| rlc_h1dash_twice_int(x, alpha);
2745            let second = (f(time + h) - 2.0 * f(time) + f(time - h)) / (h * h);
2746            assert!(
2747                close(second, rlc_h1dash(time, alpha, alpha), 1e-4),
2748                "{second} vs {}",
2749                rlc_h1dash(time, alpha, alpha)
2750            );
2751            if time > t {
2752                let g = |x: f64| rlc_h3dash_int(x, t, alpha);
2753                let first = (g(time + h) - g(time - h)) / (2.0 * h);
2754                assert!(close(first, rlc_h3dash(time, t, alpha, alpha), 1e-4));
2755            }
2756        }
2757    }
2758
2759    #[test]
2760    fn rlc_coefficients_telescope_to_the_last_divided_difference() {
2761        // With chopping off, h1First + Σ h1[i] is the divided
2762        // difference of H1'' over the oldest interval.
2763        let (alpha, td) = (1.1e7, 4.38e-9);
2764        let times: Vec<f64> = (0..40)
2765            .map(|k| k as f64 * 0.1e-9 + (k * k) as f64 * 1e-12)
2766            .collect();
2767        let n = times.len() - 1;
2768        let cur = times[n] + 0.07e-9;
2769        let mut co = Coeffs::default();
2770        rlc_coeffs(&mut co, td, alpha, alpha, cur, &times, n, 0.0);
2771        let sum: f64 = co.h1_first + co.h1[1..=n].iter().sum::<f64>();
2772        let f = |x: f64| rlc_h1dash_twice_int(x, alpha);
2773        let last = (f(cur - times[0]) - f(cur - times[1])) / (times[1] - times[0]);
2774        assert!(close(sum, last, 1e-9), "{sum} vs {last}");
2775        // The first coefficient is H1''(Δ)/Δ.
2776        let d = cur - times[n];
2777        assert!(close(co.h1_first, f(d) / d, 1e-15));
2778        // The lag-T search: the point just older than cur − T.
2779        assert!(cur - times[co.aux_index] > td);
2780        assert!(cur - times[co.aux_index + 1] <= td);
2781        assert!(co.h2_first > 0.0);
2782    }
2783
2784    #[test]
2785    fn rc_coefficients_telescope() {
2786        let (cbyr, rclsqr) = (3.65e-12 / 0.2, 0.2 * 3.65e-12 * 24.0 * 24.0);
2787        let times: Vec<f64> = (0..30).map(|k| k as f64 * 0.1e-9).collect();
2788        let n = times.len() - 1;
2789        let cur = times[n] + 0.05e-9;
2790        let mut co = Coeffs::default();
2791        rc_coeffs(&mut co, cbyr, rclsqr, cur, &times, n, 0.0);
2792        let f = |x: f64| (4.0 * cbyr * x / std::f64::consts::PI).sqrt();
2793        let sum: f64 = co.h1_first + co.h1[1..=n].iter().sum::<f64>();
2794        let last = (f(cur - times[0]) - f(cur - times[1])) / (times[1] - times[0]);
2795        assert!(close(sum, last, 1e-9));
2796    }
2797
2798    /// A matched lossless line: the far end is the near end delayed by
2799    /// `T`, and the near end sees `Z0` (a 2:1 divider with the source
2800    /// resistance) until the delay is over and forever after.
2801    #[test]
2802    fn matched_lc_line_delays_by_td() {
2803        use crate::circuit::{Circuit, Ltra, Resistor, Vsource};
2804        let (l, c, len) = (250e-9, 100e-12, 1.0); // Z0 = 50 Ω, T = 5 ns
2805        let mut ckt = Circuit::new("matched line");
2806        ckt.add(Vsource {
2807            name: "v1".into(),
2808            nodes: ["1".into(), "0".into()],
2809            dc: None,
2810            wave: Some(crate::srcfn::Wave::Pulse(vec![
2811                0.0, 1.0, 1e-9, 1e-9, 1e-9, 100e-9, 200e-9,
2812            ])),
2813            ac: None,
2814            distof1: None,
2815            distof2: None,
2816            port: None,
2817        })
2818        .unwrap();
2819        for (name, a, b) in [("rs", "1", "2"), ("rl", "3", "0")] {
2820            ckt.add(Resistor {
2821                name: name.into(),
2822                nodes: [a.into(), b.into()],
2823                value: 50.0,
2824            })
2825            .unwrap();
2826        }
2827        ckt.add(Ltra {
2828            name: "o1".into(),
2829            nodes: ["2".into(), "0".into(), "3".into(), "0".into()],
2830            model: LtraModel {
2831                r: Some(0.0),
2832                l: Some(l),
2833                c: Some(c),
2834                len: Some(len),
2835                ..LtraModel::default()
2836            },
2837        })
2838        .unwrap();
2839        let opts = crate::options::Options::default();
2840        let tr = ckt.tran(&opts, 0.1e-9, 20e-9, 0.0, None, false).unwrap();
2841        let t = tr.solution.column("time").unwrap();
2842        let v2 = tr.solution.column("v(2)").unwrap();
2843        let v3 = tr.solution.column("v(3)").unwrap();
2844        assert!(tr.solution.column("v(o1#i1)").is_some());
2845        let at = |col: &[f64], when: f64| {
2846            let k = t.iter().position(|&x| x >= when).unwrap();
2847            let f = (when - t[k - 1]) / (t[k] - t[k - 1]);
2848            col[k - 1] + f * (col[k] - col[k - 1])
2849        };
2850        // Before the wave arrives the far end is quiet.
2851        assert!(at(v3, 5.5e-9).abs() < 1e-6, "{}", at(v3, 5.5e-9));
2852        // Mid-ramp: v3(t) = v2(t − 5 ns), both half the source.
2853        assert!((at(v2, 1.5e-9) - 0.25).abs() < 2e-3, "{}", at(v2, 1.5e-9));
2854        assert!((at(v3, 6.5e-9) - 0.25).abs() < 2e-3, "{}", at(v3, 6.5e-9));
2855        assert!((at(v3, 12e-9) - 0.5).abs() < 1e-6);
2856        assert!((at(v2, 12e-9) - 0.5).abs() < 1e-6);
2857    }
2858
2859    #[test]
2860    fn setup_classifies_and_rejects_like_ltraset() {
2861        let base = LtraModel {
2862            r: Some(0.2),
2863            l: Some(9.13e-9),
2864            c: Some(3.65e-12),
2865            len: Some(24.0),
2866            ..LtraModel::default()
2867        };
2868        let p = LtraParams::new("o1", &base, 1e-3, 1e-12).unwrap();
2869        assert_eq!(p.case, LtraCase::Rlc);
2870        assert!(close(p.td, (9.13e-9f64 * 3.65e-12).sqrt() * 24.0, 1e-15));
2871        assert!(p.max_safe_step > 0.0 && p.max_safe_step <= 9.0 * p.td);
2872        assert!(p.notes.is_empty());
2873
2874        let lc = LtraModel {
2875            r: Some(0.0),
2876            ..base.clone()
2877        };
2878        assert_eq!(
2879            LtraParams::new("o1", &lc, 1e-3, 1e-12).unwrap().case,
2880            LtraCase::Lc
2881        );
2882        let rc = LtraModel {
2883            l: Some(0.0),
2884            ..base.clone()
2885        };
2886        assert_eq!(
2887            LtraParams::new("o1", &rc, 1e-3, 1e-12).unwrap().case,
2888            LtraCase::Rc
2889        );
2890        let rl = LtraModel {
2891            c: Some(0.0),
2892            ..base.clone()
2893        };
2894        assert!(
2895            LtraParams::new("o1", &rl, 1e-3, 1e-12)
2896                .unwrap_err()
2897                .contains("RL line")
2898        );
2899        let rlcg = LtraModel {
2900            g: Some(1e-3),
2901            ..base.clone()
2902        };
2903        assert!(
2904            LtraParams::new("o1", &rlcg, 1e-3, 1e-12)
2905                .unwrap_err()
2906                .contains("Nonzero G")
2907        );
2908        let rg = LtraModel {
2909            g: Some(1e-3),
2910            l: Some(0.0),
2911            c: Some(0.0),
2912            ..base.clone()
2913        };
2914        let p = LtraParams::new("o1", &rg, 1e-3, 1e-12).unwrap();
2915        assert_eq!(p.case, LtraCase::Rg);
2916        assert!(p.cosh_lroot_gr > 1.0);
2917        let nolen = LtraModel {
2918            len: None,
2919            ..base.clone()
2920        };
2921        assert!(
2922            LtraParams::new("o1", &nolen, 1e-3, 1e-12)
2923                .unwrap_err()
2924                .contains("length")
2925        );
2926        let one = LtraModel {
2927            r: None,
2928            l: None,
2929            ..base.clone()
2930        };
2931        let e = LtraParams::new("o1", &one, 1e-3, 1e-12).unwrap_err();
2932        assert!(e.contains("At least two"), "{e}");
2933        let nan = LtraModel {
2934            r: Some(f64::NAN),
2935            ..base
2936        };
2937        assert!(LtraParams::new("o1", &nan, 1e-3, 1e-12).is_err());
2938    }
2939}