Files
tensor/integrate/odebdfvar.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

415 lines
15 KiB
Go
Raw 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 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
}