Files
tensor/integrate/odebdf2.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

261 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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))
}