Files

261 lines
8.8 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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))
}