415 lines
15 KiB
Go
415 lines
15 KiB
Go
// 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 variable-order stiff workhorse above IntegrateBDF2: the backward
|
||
// differentiation formula of order one through five, with the step size
|
||
// and the order both adapted every step in the VODE manner. Each step
|
||
// interpolates a polynomial of degree k through the k most recent
|
||
// states and the unknown end value and requires its derivative at the
|
||
// new time to equal f, the same implicit relation BDF2 solves; the
|
||
// Newton iteration, the LU machinery, the Hairer-Nørsett-Wanner initial
|
||
// step probe and the step controller are the ones IntegrateBDF2
|
||
// already carries.
|
||
//
|
||
// The coefficients are the variable-step, divided-difference form: the
|
||
// Newton form of the interpolating polynomial through (tNext, z) and
|
||
// the stored back values, written per component from a small divided-
|
||
// difference table over the stored times. The form was chosen over the
|
||
// fixed-coefficient one because the package keeps a solution history
|
||
// rather than a Nordsieck array, because the divided differences feed
|
||
// the order selection (the a-priori error estimate per candidate order
|
||
// falls out of the same table) and because the relation leaves the
|
||
// Newton contract α·z − h·f(tNext, z) = β of odeNewton untouched. At
|
||
// order two with equal steps the assembled α and β agree with
|
||
// bdf2Coefficients to rounding, so the shipped BDF2 behaviour is the
|
||
// special case the driver degrades to.
|
||
//
|
||
// The local error estimate is the Milne-type one: the gap between the
|
||
// corrector and the degree-k predictor extrapolated from the k+1
|
||
// newest states, scaled by the constant that turns the gap into the
|
||
// corrector's own error. The variable-step constant generalises the
|
||
// 2/11 of bdf2Milne: with α the derivative weight of the new point and
|
||
// S the span from tNext to the oldest predictor node, the estimate is
|
||
// (z − seed)/(1 + α·S), which for equal steps of order two reproduces
|
||
// 2/11 exactly. The order itself is chosen before the solve, from the
|
||
// divided differences of the stored states: the (k+1)-th divided
|
||
// difference approximates y^(k+1)/(k+1)!, and the candidate whose
|
||
// implied optimal step is largest wins, with a margin so the order
|
||
// does not flicker between neighbours.
|
||
//
|
||
// The first step is backward Euler, sized by the shared probe; the
|
||
// order ramps up as the history accumulates, one level per step.
|
||
|
||
// BDFVarStats reports what a variable-order run did: the accepted and
|
||
// rejected steps and the highest order the driver reached.
|
||
type BDFVarStats struct {
|
||
Steps int
|
||
Rejected int
|
||
MaxOrder int
|
||
}
|
||
|
||
// BDFVarOptions tunes IntegrateBDFVar. RelTol ≤ 0 means 1e-6, AbsTol ≤ 0
|
||
// means 1e-9, MaxSteps ≤ 0 means 100000, the ODEOptions defaults. Stats,
|
||
// when not nil, receives the run's counters.
|
||
type BDFVarOptions struct {
|
||
RelTol float64
|
||
AbsTol float64
|
||
MaxSteps int
|
||
Stats *BDFVarStats
|
||
}
|
||
|
||
// bdfVarOrderMax is the highest order the driver raises to. bdfVarKeep
|
||
// is the number of states held back: order k needs k back values for
|
||
// its corrector, k+1 for its predictor and k+2 for the a-priori order
|
||
// comparison, so seven states serve order five in every role.
|
||
const (
|
||
bdfVarOrderMax = 5
|
||
bdfVarKeep = bdfVarOrderMax + 2
|
||
)
|
||
|
||
// IntegrateBDFVar integrates y' = f(t, y) from t0 to t1 with the
|
||
// variable-step, variable-order BDF scheme of orders one through five
|
||
// 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 IntegrateBDFVar(f func(t float64, y *core.Array) (*core.Array, error),
|
||
t0, t1 float64, y0 *core.Array, opts BDFVarOptions) (*core.Array, error) {
|
||
end, err := integrateBDFVar("IntegrateBDFVar", f, t0, t1, y0, opts, bdfVarOrderMax, false)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return arrayFromVector(end), nil
|
||
}
|
||
|
||
// integrateBDFVar drives the variable-order loop. maxOrder caps the
|
||
// order adaptation and lockOrder pins the order at maxOrder once the
|
||
// history ramp reaches it, which is the fixed-order hook the tests
|
||
// drive; the public entry always asks for adaptive order five.
|
||
func integrateBDFVar(name string, f func(t float64, y *core.Array) (*core.Array, error),
|
||
t0, t1 float64, y0 *core.Array, opts BDFVarOptions, maxOrder int, lockOrder bool) ([]float64, error) {
|
||
if maxOrder < 1 || maxOrder > bdfVarOrderMax {
|
||
return nil, base.Errf("%s: maxOrder must be between 1 and %d, got %d", name, bdfVarOrderMax, maxOrder)
|
||
}
|
||
y, err := odeCheck(name, y0, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
relTol, absTol, maxSteps := opts.RelTol, opts.AbsTol, opts.MaxSteps
|
||
if relTol <= 0 {
|
||
relTol = 1e-6
|
||
}
|
||
if absTol <= 0 {
|
||
absTol = 1e-9
|
||
}
|
||
if maxSteps <= 0 {
|
||
maxSteps = 100000
|
||
}
|
||
n := len(y)
|
||
h, err := bdf2InitialStep(name, f, t0, t1, y, &ODEOptions{RelTol: relTol, AbsTol: absTol})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var stats BDFVarStats
|
||
hist := &bdfVarHistory{}
|
||
hist.push(t0, y)
|
||
// The implicit relation's right side, the predictor seed and the
|
||
// divided-difference workspace live in reused buffers: all are fully
|
||
// rewritten at the top of every step. The Newton result lands in a
|
||
// per-solve scratch buffer that never touches the history window:
|
||
// the order selection and the coefficient assembly reread the whole
|
||
// window on a retry, so a rejected attempt must leave every stored
|
||
// state intact. Only an accepted step copies the state into the
|
||
// ring slot its push then occupies.
|
||
beta := make([]float64, n)
|
||
seed := make([]float64, n)
|
||
zbuf := make([]float64, n)
|
||
dd := make([]float64, bdfVarKeep)
|
||
nodes := make([]float64, bdfVarKeep)
|
||
spans := make([]float64, bdfVarKeep)
|
||
ddTab := make([][]float64, bdfVarKeep)
|
||
for level := range ddTab {
|
||
ddTab[level] = make([]float64, n)
|
||
}
|
||
budget := odeBudget{max: maxSteps}
|
||
w := &odeWork{}
|
||
t := t0
|
||
carried := 1
|
||
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
|
||
var alpha, weight float64
|
||
var order int
|
||
estimated := false
|
||
if hist.n == 1 {
|
||
// The very first step has no history and runs backward
|
||
// Euler, seeded with the semi-implicit prediction: the
|
||
// house starter IntegrateBDF2 begins with.
|
||
alpha, weight, order = 1, hN, 1
|
||
copy(beta, y)
|
||
fy, ferr := odeEval(name, f, tNext, y, n, &w.views)
|
||
if ferr != nil {
|
||
return nil, ferr
|
||
}
|
||
for i := range n {
|
||
seed[i] = y[i] + hN*fy[i]
|
||
}
|
||
} else {
|
||
order = min(carried, maxOrder, hist.n-1)
|
||
switch {
|
||
case lockOrder && hist.n > maxOrder:
|
||
// The fixed-order contract: once the history ramp can
|
||
// feed the requested order, every step runs at it.
|
||
order = maxOrder
|
||
case !lockOrder && hist.n >= 3:
|
||
bdfVarDividedDifferences(hist, n, ddTab, dd, nodes)
|
||
order = bdfVarPickOrder(order, maxOrder, hist.n, tNext, h, hist, y, absTol, relTol, ddTab, spans)
|
||
}
|
||
weight = 1
|
||
alpha = bdfVarCoefficients(order, tNext, hist, beta, seed, dd, nodes, spans)
|
||
estimated = true
|
||
}
|
||
if nerr := odeNewton(name, f, w, tNext, alpha, weight, beta, seed, zbuf, absTol, 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.
|
||
_, tOldest := hist.back(order)
|
||
c := 1 / (1 + alpha*(tNext-tOldest))
|
||
errNorm := 0.0
|
||
for i := range n {
|
||
scale := absTol + relTol*math.Max(math.Abs(y[i]), math.Abs(zbuf[i]))
|
||
ratio := c * (zbuf[i] - seed[i]) / scale
|
||
errNorm += ratio * ratio
|
||
}
|
||
errNorm = math.Sqrt(errNorm/float64(n)) + 1e-10
|
||
if errNorm <= 1 {
|
||
factor = min(2, max(0.2, 0.9*math.Pow(1/errNorm, 1/float64(order+1))))
|
||
} else {
|
||
// Rejected: retry the same interval with a smaller step.
|
||
stats.Rejected++
|
||
h *= max(0.1, min(1, 0.9*math.Pow(1/errNorm, 1/float64(order+1))))
|
||
continue
|
||
}
|
||
}
|
||
// Accepted: the corrector is copied into the ring slot the push
|
||
// fills and becomes the working state, so back(0) is always
|
||
// (t, y) and the buffers flow without copying.
|
||
slot := hist.y[hist.next]
|
||
if slot == nil {
|
||
slot = make([]float64, n)
|
||
}
|
||
copy(slot, zbuf)
|
||
hist.push(tNext, slot)
|
||
y = slot
|
||
carried = order
|
||
if order > stats.MaxOrder {
|
||
stats.MaxOrder = order
|
||
}
|
||
stats.Steps++
|
||
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)
|
||
}
|
||
}
|
||
if opts.Stats != nil {
|
||
*opts.Stats = stats
|
||
}
|
||
return y, nil
|
||
}
|
||
|
||
// bdfVarHistory holds the last bdfVarKeep accepted states with their
|
||
// times in a fixed ring. back(0) is the newest state, back(1) the one
|
||
// before it, and so on; slots are recycled only once they are too old
|
||
// to serve any order, so the buffers flow without copying.
|
||
type bdfVarHistory struct {
|
||
y [bdfVarKeep][]float64
|
||
t [bdfVarKeep]float64
|
||
next int
|
||
n int
|
||
}
|
||
|
||
// push records an accepted state and its time as the new newest entry.
|
||
func (h *bdfVarHistory) push(t float64, y []float64) {
|
||
h.y[h.next], h.t[h.next] = y, t
|
||
h.next = (h.next + 1) % bdfVarKeep
|
||
if h.n < bdfVarKeep {
|
||
h.n++
|
||
}
|
||
}
|
||
|
||
// back returns the state i steps behind the newest one.
|
||
func (h *bdfVarHistory) back(i int) ([]float64, float64) {
|
||
j := (h.next - 1 - i + bdfVarKeep) % bdfVarKeep
|
||
return h.y[j], h.t[j]
|
||
}
|
||
|
||
// bdfVarCoefficients assembles the variable-step BDF relation of the
|
||
// given order for a step to tNext from the newest history state. It
|
||
// writes α's companions β and the Newton seed into the caller's
|
||
// buffers and returns α: the implicit equation is α·z − f(tNext, z) =
|
||
// β, the weight already scaled out. Both buffers are fully overwritten.
|
||
// The seed is the degree-order polynomial through the order+1 newest
|
||
// states evaluated at tNext, the predictor the error estimate reads.
|
||
// All differences are signed, so backward integration needs no
|
||
// separate path.
|
||
//
|
||
// The construction is the divided-difference (Newton) form: with nodes
|
||
// x_0 = tNext and x_q = the q-th back time, the interpolating
|
||
// polynomial's derivative at tNext is Σ_j c_j·Π_j where c_j are the
|
||
// divided differences of the data (z at x_0, the back values after)
|
||
// and Π_j the Newton basis products. Splitting c_j into its z part,
|
||
// 1/Π_j, and its history part gives α = Σ 1/(tNext − x_m), the Lagrange
|
||
// derivative weight of the new point, and β from the history-only
|
||
// table, all from one per-component recursion.
|
||
func bdfVarCoefficients(order int, tNext float64, hist *bdfVarHistory,
|
||
beta, seed, dd, nodes, spans []float64) float64 {
|
||
nodes[0] = tNext
|
||
// The ring's nodes and value slices are the same for every
|
||
// component: gather both once, on the stack, instead of walking the
|
||
// ring inside the per-element loop.
|
||
var backVals [bdfVarKeep][]float64
|
||
for q := range order + 1 {
|
||
backVals[q], nodes[q+1] = hist.back(q)
|
||
}
|
||
// spans[m] is Π_m, the product of tNext − x_q over q < m: the
|
||
// Newton basis value the level-m coefficients multiply.
|
||
spans[0] = 1
|
||
for m := 1; m <= order; m++ {
|
||
spans[m] = spans[m-1] * (tNext - nodes[m])
|
||
}
|
||
alpha := 0.0
|
||
for m := 1; m <= order; m++ {
|
||
alpha += 1 / (tNext - nodes[m])
|
||
}
|
||
for i := range beta {
|
||
// dd[q] starts as the value at node q: zero at tNext, the back
|
||
// values after. One level of the recursion per Newton term;
|
||
// level order leaves dd[0] holding the order-th divided
|
||
// difference over the new point and dd[1] the one over the
|
||
// stored values, which is the predictor's top coefficient.
|
||
dd[0] = 0
|
||
for q := range order + 1 {
|
||
dd[q+1] = backVals[q][i]
|
||
}
|
||
seed[i] = dd[1]
|
||
betaSum := 0.0
|
||
for level := 1; level <= order; level++ {
|
||
for q := range order + 2 - level {
|
||
dd[q] = (dd[q+1] - dd[q]) / (nodes[q+level] - nodes[q])
|
||
}
|
||
betaSum += dd[0] * spans[level-1]
|
||
seed[i] += dd[1] * spans[level]
|
||
}
|
||
beta[i] = -betaSum
|
||
}
|
||
return alpha
|
||
}
|
||
|
||
// bdfVarDividedDifferences fills tab with the divided differences of
|
||
// the stored back values alone: tab[level][i] is the level-th divided
|
||
// difference of (y_n, y_{n-1}, …) over their times for component i.
|
||
// The (order+1)-th entry approximates y^(order+1)/(order+1)! and is
|
||
// what the a-priori order comparison reads.
|
||
func bdfVarDividedDifferences(hist *bdfVarHistory, n int, tab [][]float64, dd, times []float64) {
|
||
// The ring's times and value slices do not depend on the component:
|
||
// gather both once, on the stack, instead of walking the ring
|
||
// inside the per-element loops.
|
||
var backVals [bdfVarKeep][]float64
|
||
for q := range hist.n {
|
||
backVals[q], times[q] = hist.back(q)
|
||
}
|
||
for i := range n {
|
||
for q := range hist.n {
|
||
dd[q] = backVals[q][i]
|
||
}
|
||
for level := 1; level < hist.n; level++ {
|
||
for q := range hist.n - level {
|
||
dd[q] = (dd[q+1] - dd[q]) / (times[q+level] - times[q])
|
||
}
|
||
tab[level][i] = dd[0]
|
||
}
|
||
}
|
||
}
|
||
|
||
// bdfVarPickOrder returns the order for the coming step. Every order
|
||
// the history supports gets an a-priori optimal step: the local error
|
||
// the divided differences predict, raised to the power that would
|
||
// bring it to the tolerance. The scan runs from order 1 upward and a
|
||
// candidate must beat the running best by a clear margin, so the
|
||
// effective pick is the lowest order within 15 percent of the largest
|
||
// predicted step: short histories and cheap coefficients win near
|
||
// ties, and the order settles instead of flickering between equals.
|
||
// The carried order survives the scan only before a second state is
|
||
// held; after that some candidate always displaces it.
|
||
func bdfVarPickOrder(carried, maxOrder, held int, tNext, h float64, hist *bdfVarHistory, y []float64,
|
||
absTol, relTol float64, tab [][]float64, spans []float64) int {
|
||
best, bestH := carried, 0.0
|
||
for j := 1; j <= min(maxOrder, held-1); j++ {
|
||
hj := math.Abs(h)
|
||
if j <= held-2 {
|
||
e := bdfVarPriorNorm(j, tNext, hist, y, absTol, relTol, tab, spans)
|
||
hj = math.Abs(h) * math.Pow(1/e, 1/float64(j+1))
|
||
}
|
||
if hj > bestH*1.15 {
|
||
best, bestH = j, hj
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
// bdfVarPriorNorm estimates the RMS error norm a step of size h at the
|
||
// given order would produce: the (order+1)-th divided difference of
|
||
// the stored states approximates y^(order+1)/(order+1)!, and the
|
||
// order's local error scales that by the Newton basis product over α,
|
||
// the same estimate the Milne constant formalises a posteriori.
|
||
func bdfVarPriorNorm(order int, tNext float64, hist *bdfVarHistory, y []float64,
|
||
absTol, relTol float64, tab [][]float64, spans []float64) float64 {
|
||
spans[0] = 1
|
||
alpha := 0.0
|
||
for q := range order {
|
||
_, tq := hist.back(q)
|
||
spans[q+1] = spans[q] * math.Abs(tNext-tq)
|
||
alpha += 1 / math.Abs(tNext-tq)
|
||
}
|
||
w := spans[order] / alpha
|
||
norm := 0.0
|
||
for i := range y {
|
||
scale := absTol + relTol*math.Abs(y[i])
|
||
ratio := math.Abs(tab[order+1][i]) * w / scale
|
||
norm += ratio * ratio
|
||
}
|
||
return math.Sqrt(norm/float64(len(y))) + 1e-10
|
||
}
|