// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package integrate import ( "sourcedock.dev/petrbalvin/tensor/internal/base" "sourcedock.dev/petrbalvin/tensor/internal/core" ) import ( "errors" "math" ) // The stiff workhorse beside IntegrateBackwardEuler: the variable-step // second-order backward differentiation formula. Where the explicit // Dormand-Prince pair must keep h·λ inside its stability region, BDF2 // is A-stable and damps the stiff mode like (2hλ)^(−1/2), so the step // size follows accuracy alone. Each step solves the implicit relation // α·z − h·f(t_{n+1}, z) = β by Newton over a numerical Jacobian and // the library's LU solver, the machinery IntegrateBackwardEuler // already carries. // // The step size is driven by a Milne-type estimate of the one-step // error: the gap between the corrector and the quadratic predictor // through the three most recent states, scaled by the constant that // turns that gap into the BDF2 truncation error (2/11 for equal // steps). The first step runs backward Euler, whose size a // Hairer-Nørsett-Wanner style probe picks so the starter's own error // already sits below the tolerance; the second BDF2 step repeats that // size untested, safe because its truncation error is an order in h // below the starter's; from the third step on the estimate controls // everything. // IntegrateBDF2 integrates y' = f(t, y) from t0 to t1 with the // variable-step BDF2 scheme and returns y(t1). Backward integration // works: a t1 < t0 simply integrates in the negative direction. An // exhausted step budget, a collapsed step size, an f that returns a // wrongly shaped state, or a Newton iteration that cannot converge // even as the step shrinks is an error, never a silently truncated // trajectory. func IntegrateBDF2(f func(t float64, y *core.Array) (*core.Array, error), t0, t1 float64, y0 *core.Array, opts ODEOptions) (*core.Array, error) { const name = "IntegrateBDF2" y, err := odeCheck("IntegrateBDF2", y0, &opts) if err != nil { return nil, err } n := len(y) h, err := bdf2InitialStep(name, f, t0, t1, y, &opts) if err != nil { return nil, err } t := t0 yn := cloneDenseSlice(y) var yNm1, yNm2 []float64 var tNm1, tNm2 float64 budget := odeBudget{max: opts.MaxSteps} // The implicit relation's right side and the Newton seed live in // reused buffers: both are fully rewritten at the top of every step // and neither outlives the step's solve. The converged state lands // in a four-buffer ring: at every acceptance the live history is // the three most recent ring slots, so the next slot aliases // nothing the step reads, and a rejected or stalled attempt // reuses the slot it already holds. beta := make([]float64, n) seed := make([]float64, n) var ring [4][]float64 next := 0 w := &odeWork{} for !odeArrived(t, t1) { if err := budget.spend(name, t, t1); err != nil { return nil, err } // Never step past t1; t1−t carries the integration direction. h = odeClampStep(h, t, t1) tNext := t + h hN := tNext - t // The implicit equation and the Newton seed. The very first // step has no history and runs backward Euler, seeded with // the semi-implicit prediction; from the second step on the // BDF2 weights carry the 1/h scaling themselves, so the // derivative enters the implicit equation with weight 1. var alpha, weight float64 estimated := yNm2 != nil if yNm1 == nil { alpha, weight = 1, hN copy(beta, yn) fy, ferr := odeEval(name, f, tNext, yn, n, &w.views) if ferr != nil { return nil, ferr } for i := range n { seed[i] = yn[i] + hN*fy[i] } } else { weight = 1 alpha = bdf2Coefficients(t, tNext, tNm1, tNm2, yn, yNm1, yNm2, beta, seed) } dst := ring[next] if dst == nil { dst = make([]float64, n) ring[next] = dst } if nerr := odeNewton(name, f, w, tNext, alpha, weight, beta, seed, dst, opts.AbsTol, opts.RelTol); nerr != nil { if errors.Is(nerr, errNewtonStalled) { // The implicit solve struggled: halve the step and // retry the same interval, within the step budget. h *= 0.5 continue } return nil, nerr } factor := 1.0 if estimated { // Milne-type local error estimate against the mixed // absolute and relative tolerance. c := bdf2Milne(t, tNext, tNm1, tNm2) errNorm := 0.0 for i := range n { scale := opts.AbsTol + opts.RelTol*math.Max(math.Abs(yn[i]), math.Abs(dst[i])) ratio := c * (dst[i] - seed[i]) / scale errNorm += ratio * ratio } errNorm = math.Sqrt(errNorm/float64(n)) + 1e-10 if errNorm <= 1 { factor = math.Min(2, math.Max(0.2, 0.9*math.Pow(1/errNorm, 1.0/3))) } else { // Rejected: retry the same interval with a smaller step. h *= math.Max(0.1, math.Min(1, 0.9*math.Pow(1/errNorm, 1.0/3))) continue } } // Accepted: shift the history one step forward. The ring slot // becomes the working state and the buffers flow without // copying; nothing aliases them afterwards. yNm2, tNm2 = yNm1, tNm1 yNm1, tNm1 = yn, t yn = dst next = (next + 1) % len(ring) prevT := t t = tNext h *= factor // Collapse is "t did not move": a span below the absolute time // scale is integrable, and an accepted step that arrives at the // end exactly is not a failure either. if t == prevT { return nil, base.Errf("%s: the step size shrank below the resolution of t at t=%g", name, prevT) } } return arrayFromVector(yn), nil } // bdf2InitialStep picks the first step by probing f: a trial step h0 // compares the derivative at y against the derivative one h0 further, // and the result sizes the step so a first-order scheme's local error // sits a factor hundred below the mixed tolerance. The span and a // hundredfold h0 bound the answer, and the sign carries the // integration direction. func bdf2InitialStep(name string, f func(t float64, y *core.Array) (*core.Array, error), t0, t1 float64, y []float64, opts *ODEOptions) (float64, error) { span := math.Abs(t1 - t0) if span == 0 { return 0, nil } n := len(y) f0, err := odeEval(name, f, t0, y, n, nil) if err != nil { return 0, err } scale := make([]float64, n) d0, d1 := 0.0, 0.0 for i := range n { scale[i] = opts.AbsTol + opts.RelTol*math.Abs(y[i]) d0 = math.Max(d0, math.Abs(y[i])/scale[i]) d1 = math.Max(d1, math.Abs(f0[i])/scale[i]) } h0 := 1e-6 if d0 > 1e-5 && d1 > 1e-5 { h0 = 0.01 * d0 / d1 } h0 = math.Min(h0, span) // The probe steps in the integration direction: a backward span // samples t0−h0 with the derivative subtracted, or the difference // f1−f0 measures the wrong side of the dynamics. dir := 1.0 if t1 < t0 { dir = -1 } probe := make([]float64, n) for i := range n { probe[i] = y[i] + dir*h0*f0[i] } f1, err := odeEval(name, f, t0+dir*h0, probe, n, nil) if err != nil { return 0, err } d2 := 0.0 for i := range n { d2 = math.Max(d2, math.Abs(f1[i]-f0[i])/(scale[i]*h0)) } h1 := span if d := math.Max(d1, d2); d > 1e-15 { h1 = math.Sqrt(0.01 / d) } h1 = math.Min(h1, math.Min(100*h0, span)) if t1 < t0 { h1 = -h1 } return h1, nil } // bdf2Coefficients assembles the variable-step BDF2 relation for a // step from t, whose previous point sits at tNm1 (and the one before // that at tNm2 when known), to tNext. It writes α's companions β and // the Newton seed into the caller's buffers and returns α: the // implicit equation is α·z − h·f(tNext, z) = β. Both buffers are fully // overwritten. The seed is the quadratic predictor through the last // three states when yNm2 is given, otherwise the linear ramp over the // last two. All differences are signed, so backward integration needs // no separate path. func bdf2Coefficients(t, tNext, tNm1, tNm2 float64, yn, yNm1, yNm2, beta, seed []float64) float64 { n := len(yn) hN := tNext - t hP := t - tNm1 alpha := (hP + 2*hN) / ((hP + hN) * hN) w1 := (hP + hN) / (hP * hN) w0 := hN / (hP * (hP + hN)) for i := range n { beta[i] = w1*yn[i] - w0*yNm1[i] } if yNm2 != nil { l2 := (tNext - tNm1) * (tNext - t) / ((tNm2 - tNm1) * (tNm2 - t)) l1 := (tNext - tNm2) * (tNext - t) / ((tNm1 - tNm2) * (tNm1 - t)) l0 := (tNext - tNm2) * (tNext - tNm1) / ((t - tNm2) * (t - tNm1)) for i := range n { seed[i] = l2*yNm2[i] + l1*yNm1[i] + l0*yn[i] } } else { ramp := hN / hP for i := range n { seed[i] = yn[i] + ramp*(yn[i]-yNm1[i]) } } return alpha } // bdf2Milne returns the constant that turns the gap between the BDF2 // corrector and the quadratic predictor through the three previous // states into an estimate of the corrector's one-step error: 2/11 for // equal steps, from the leading error terms h³y”' of the predictor // and (2/9)h³y”' of the corrector. func bdf2Milne(t, tNext, tNm1, tNm2 float64) float64 { hN := tNext - t hP := t - tNm1 hPp := tNm1 - tNm2 return hN * (hN + hP) / ((hP+2*hN)*(hPp+hP+hN) + hN*(hN+hP)) }