feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+878
View File
@@ -0,0 +1,878 @@
// 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"
)
// Ordinary differential equation solvers for initial value problems
// y' = f(t, y). The state y is a rank-1 vector of length n; a system
// of higher rank flattens to its leading-axis vector first.
//
// Three schemes cover the standard regimes. IntegrateODE is the
// workhorse: an adaptive embedded Runge-Kutta pair (Dormand-Prince
// 4(5)) that controls the local error against a mixed absolute and
// relative tolerance. IntegrateRK4 is the classical fixed-step
// fourth-order scheme, useful when a uniform step or simple
// reproducibility per step matters. IntegrateBackwardEuler is the
// entry-level stiff scheme: fully implicit, with each step's
// nonlinear equation solved by Newton over a numerical Jacobian and
// the library's LU solver.
// ODEOptions tunes the adaptive integrator. RelTol ≤ 0 means 1e-6,
// AbsTol ≤ 0 means 1e-9, MaxSteps ≤ 0 means 100000.
type ODEOptions struct {
RelTol float64
AbsTol float64
MaxSteps int
}
// Dormand-Prince 4(5): node offsets, stage coefficients, and the
// 5th- and 4th-order solution weights. Stage 7 shares the 5th-order
// weights (the FSAL property), which is why it needs no separate row.
var (
odeC = [7]float64{0, 1.0 / 5, 3.0 / 10, 4.0 / 5, 8.0 / 9, 1, 1}
odeA = [][]float64{
{},
{1.0 / 5},
{3.0 / 40, 9.0 / 40},
{44.0 / 45, -56.0 / 15, 32.0 / 9},
{19372.0 / 6561, -25360.0 / 2187, 64448.0 / 6561, -212.0 / 729},
{9017.0 / 3168, -355.0 / 33, 46732.0 / 5247, 49.0 / 176, -5103.0 / 18656},
{35.0 / 384, 0, 500.0 / 1113, 125.0 / 192, -2187.0 / 6784, 11.0 / 84},
}
odeB5 = [7]float64{35.0 / 384, 0, 500.0 / 1113, 125.0 / 192, -2187.0 / 6784, 11.0 / 84, 0}
odeB4 = [7]float64{5179.0 / 57600, 0, 7571.0 / 16695, 393.0 / 640, -92097.0 / 339200, 187.0 / 2100, 1.0 / 40}
)
// IntegrateODE integrates y' = f(t, y) from t0 to t1 with the adaptive
// Dormand-Prince 4(5) pair and returns y(t1). Backward integration
// works: a t1 < t0 simply integrates in the negative direction. An
// exhausted step budget, a collapsed step size or an f that returns a
// wrongly shaped state is an error, never a silently truncated
// trajectory.
func IntegrateODE(f func(t float64, y *core.Array) (*core.Array, error),
t0, t1 float64, y0 *core.Array, opts ODEOptions) (*core.Array, error) {
return odeRun(f, t0, t1, y0, opts, nil)
}
// readVector copies a's elements into dst, sweeping the raw float64
// payload when a is a dense float64 array and falling back to the
// widening accessor for views and other dtypes. The values written are
// identical either way.
func readVector(dst []float64, a *core.Array) {
if !a.Strided() && a.Dtype() == core.Float {
copy(dst, a.RawFloats())
return
}
for i := range dst {
dst[i] = a.FloatAt(i)
}
}
// denseFloats returns a's elements as a plain float64 slice, sharing
// the payload when a is a dense float64 array and copying the widened
// values otherwise. The values read are the ones the accessor
// returned; a shared slice is read-only, and only an array the caller
// owns may be written through it. A caller sweeping the elements of a
// solver's result uses this instead of one accessor call per element.
func denseFloats(a *core.Array) []float64 {
if !a.Strided() && a.Dtype() == core.Float {
return a.RawFloats()
}
out := make([]float64, a.Len())
for i := range out {
out[i] = a.FloatAt(i)
}
return out
}
// odeRun drives the adaptive Dormand-Prince loop over the whole span.
// When watch is not nil it is called after every accepted step with
// the interval just integrated and clones of the states at both ends;
// a true return stops the integration there, and the watch's error
// aborts it. Everything else behaves exactly like IntegrateODE.
func odeRun(f func(t float64, y *core.Array) (*core.Array, error),
t0, t1 float64, y0 *core.Array, opts ODEOptions,
watch func(tPrev, tNow float64, yPrev, yNow []float64) (bool, error)) (*core.Array, error) {
const name = "IntegrateODE"
y, err := odeCheck(name, y0, &opts)
if err != nil {
return nil, err
}
n := len(y)
w := &odeWork{}
w.useStage(n)
k := make([][]float64, 8) // k[1..7] are the stages; k[0] unused
for i := 1; i <= 7; i++ {
k[i] = make([]float64, n)
}
// One scratch accumulator serves every stage of every step: it is
// rebuilt from y at the top of each stage call and read only by
// that call's f evaluation, the same transient view the package's
// fixed-step solvers hand out.
acc := w.stage
stage := func(i int, t float64, h float64) error {
copy(acc, y)
row := odeA[i-1]
for j := 1; j < i; j++ {
if row[j-1] == 0 {
continue
}
// The step-scaled weight is one product, the same (h·a)·k
// grouping the plain component loop evaluated.
hj := h * row[j-1]
kj := k[j]
for m := range n {
acc[m] += hj * kj[m]
}
}
out, err := odeCall(name, f, t+odeC[i-1]*h, acc, n, &w.views)
if err != nil {
return err
}
readVector(k[i], out)
return nil
}
t := t0
h := odeInitialStep(t0, t1, y)
budget := odeBudget{max: opts.MaxSteps}
yEnd := w.yEnd
// The solution weights scaled by the step size: one product each,
// the same (h·b)·k grouping the component loop evaluated.
var hb5, hb4 [7]float64
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)
for j := 1; j <= 7; j++ {
hb5[j-1] = h * odeB5[j-1]
hb4[j-1] = h * odeB4[j-1]
}
for i := 1; i <= 7; i++ {
if err := stage(i, t, h); err != nil {
return nil, err
}
}
// The embedded pair: the 5th-order solution advances, the gap
// to the 4th-order one estimates the local error.
errNorm := 0.0
for m := range n {
y5, y4 := y[m], y[m]
for j := 1; j <= 7; j++ {
y5 += hb5[j-1] * k[j][m]
y4 += hb4[j-1] * k[j][m]
}
yEnd[m] = y5
scale := opts.AbsTol + opts.RelTol*math.Max(math.Abs(y[m]), math.Abs(y5))
ratio := (y5 - y4) / scale
errNorm += ratio * ratio
}
errNorm = math.Sqrt(errNorm/float64(n)) + 1e-10
factor := math.Min(5, math.Max(0.2, 0.9*math.Pow(1/errNorm, 1.0/5)))
if errNorm <= 1 {
if watch != nil {
stop, werr := watch(t, t+h, cloneDenseSlice(y), cloneDenseSlice(yEnd))
if werr != nil {
return nil, werr
}
if stop {
return arrayFromVector(yEnd), nil
}
}
copy(y, yEnd)
prevT := t
t += h
h *= factor
// Collapse is "t did not move", not "h is small": a span
// far below the absolute time scale is perfectly
// integrable, and the old absolute floor refused it.
if t == prevT {
return nil, base.Errf("%s: the step size shrank below the resolution of t at t=%g", name, prevT)
}
} else {
// Rejected: retry the same interval with the smaller step.
h *= math.Max(0.2, factor)
}
}
return arrayFromVector(y), nil
}
// IntegrateODEPath integrates y' = f(t, y) from t0 to t1 and returns
// the trajectory sampled at nSamples evenly spaced points, endpoints
// included: times[i] is the sample time and states[i] the state there,
// so states[0] is the initial state and states[nSamples−1] the answer
// IntegrateODE would return. Every interval between neighbouring
// samples is integrated on its own, so the adaptive step control never
// has to align with the sampling grid. Backward integration
// (t1 < t0) works, and the error contract of IntegrateODE applies
// per interval.
func IntegrateODEPath(f func(t float64, y *core.Array) (*core.Array, error),
t0, t1 float64, y0 *core.Array, nSamples int, opts ODEOptions) ([]float64, []*core.Array, error) {
if nSamples < 2 {
return nil, nil, base.Errf("IntegrateODEPath: nSamples must be ≥ 2, got %d", nSamples)
}
y, err := odeCheck("IntegrateODEPath", y0, &opts)
if err != nil {
return nil, nil, base.Errf("IntegrateODEPath: %w", err)
}
times := make([]float64, nSamples)
states := make([]*core.Array, nSamples)
times[0] = t0
states[0] = wrapVector(y)
// The last sample is pinned to t1 exactly; the intermediate ones
// are the evenly spaced grid.
for i := 1; i < nSamples; i++ {
times[i] = t0 + float64(i)*(t1-t0)/float64(nSamples-1)
}
times[nSamples-1] = t1
for i := 1; i < nSamples; i++ {
states[i], err = IntegrateODE(f, times[i-1], times[i], states[i-1], opts)
if err != nil {
return nil, nil, base.Errf("IntegrateODEPath: %w", err)
}
}
return times, states, nil
}
// IntegrateODESteps integrates y' = f(t, y) from t0 to t1 and returns
// the trajectory as recorded at every accepted solver step: times[i]
// carries states[i] = y(times[i]), starting with (t0, y0) and ending
// with (t1, y(t1)). The accepted steps are where the adaptive control
// judged the local error within tolerance, so they are the natural
// interpolation nodes for post-processing, sensitivity analysis and
// adjoint passes. Backward integration records descending times; the
// error contract of IntegrateODE applies.
func IntegrateODESteps(f func(t float64, y *core.Array) (*core.Array, error),
t0, t1 float64, y0 *core.Array, opts ODEOptions) ([]float64, []*core.Array, error) {
y, err := odeCheck("IntegrateODESteps", y0, &opts)
if err != nil {
return nil, nil, base.Errf("IntegrateODESteps: %w", err)
}
times := []float64{t0}
states := []*core.Array{wrapVector(y)}
watch := func(tPrev, tNow float64, yPrev, yNow []float64) (bool, error) {
times = append(times, tNow)
// yNow is the run's own per-call clone; nothing aliases it.
states = append(states, wrapVector(yNow))
return false, nil
}
if _, err := odeRun(f, t0, t1, y0, opts, watch); err != nil {
return nil, nil, base.Errf("IntegrateODESteps: %w", err)
}
// The run's last accepted boundary is t+h with h = t1−t, which
// rounds a few ulps off t1 whenever the magnitudes demand it; the
// documented endpoint is t1 exactly, and the recorded state there
// is already the run's answer y(t1).
times[len(times)-1] = t1
return times, states, nil
}
// IntegrateRK4 integrates y' = f(t, y) with the classical fixed-step
// fourth-order Runge-Kutta scheme over the given number of equal
// steps, returning y(t1).
func IntegrateRK4(f func(t float64, y *core.Array) (*core.Array, error),
t0, t1 float64, y0 *core.Array, steps int) (*core.Array, error) {
if steps <= 0 {
return nil, base.Errf("IntegrateRK4: steps must be ≥ 1, got %d", steps)
}
y, err := odeCheck("IntegrateRK4", y0, nil)
if err != nil {
return nil, err
}
n := len(y)
h := (t1 - t0) / float64(steps)
k1 := make([]float64, n)
k2 := make([]float64, n)
k3 := make([]float64, n)
k4 := make([]float64, n)
tmp := make([]float64, n)
views := &odeViews{}
call := func(t float64, v []float64, out []float64) error {
o, err := odeCall("IntegrateRK4", f, t, v, n, views)
if err != nil {
return err
}
readVector(out, o)
// A non-finite stage flows straight into the state with no
// rejection mechanism to catch it, and the fixed-step run
// would publish NaN with a nil error; the adaptive drivers
// reject it, this one has to refuse it.
for i := range n {
if math.IsNaN(out[i]) || math.IsInf(out[i], 0) {
return base.Errf("IntegrateRK4: f returned the non-finite value %g at coordinate %d, t=%g", out[i], i, t)
}
}
return nil
}
// The stage times come from the exact grid t0 + i·h, never from an
// accumulated t += h: the addition's rounding walks over a long run
// (measured on y' = cos t from t0 = 1e6 the walk contributes an
// error of 1.1e-8 that no step count refines away), while each grid
// point carries a single rounding that stays put.
for i := range steps {
t := t0 + float64(i)*h
if err := call(t, y, k1); err != nil {
return nil, err
}
for i := range n {
tmp[i] = y[i] + h*k1[i]/2
}
if err := call(t+h/2, tmp, k2); err != nil {
return nil, err
}
for i := range n {
tmp[i] = y[i] + h*k2[i]/2
}
if err := call(t+h/2, tmp, k3); err != nil {
return nil, err
}
for i := range n {
tmp[i] = y[i] + h*k3[i]
}
if err := call(t+h, tmp, k4); err != nil {
return nil, err
}
for i := range n {
y[i] += h * (k1[i] + 2*k2[i] + 2*k3[i] + k4[i]) / 6
}
}
return arrayFromVector(y), nil
}
// IntegrateBackwardEuler integrates y' = f(t, y) with the fully
// implicit Euler scheme y_{n+1} = y_n + h·f(t_{n+1}, y_{n+1}), solving
// each step by Newton over a numerical Jacobian and the library's LU
// solver. The extra work per step is what buys stability on stiff
// systems, where the explicit schemes need step sizes far below what
// accuracy alone would ask for.
func IntegrateBackwardEuler(f func(t float64, y *core.Array) (*core.Array, error),
t0, t1 float64, y0 *core.Array, steps int, opts ODEOptions) (*core.Array, error) {
if steps <= 0 {
return nil, base.Errf("IntegrateBackwardEuler: steps must be ≥ 1, got %d", steps)
}
y, err := odeCheck("IntegrateBackwardEuler", y0, &opts)
if err != nil {
return nil, err
}
n := len(y)
h := (t1 - t0) / float64(steps)
yn := cloneDenseSlice(y)
seed := make([]float64, n)
fy := make([]float64, n)
// One Newton result buffer serves every step: it aliases neither the
// state nor the seed, and each step overwrites it fully.
zbuf := make([]float64, n)
w := &odeWork{}
// The step times come from the exact grid t0 + i·h, never from an
// accumulated t += h: the addition's rounding walks over a long
// run, while each grid point carries a single rounding that stays
// put.
for i := range steps {
tNext := t0 + float64(i+1)*h
// Newton on G(z) = z − y_n − h·f(t_{n+1}, z) = 0, seeded with
// the semi-implicit Euler prediction.
out, ferr := odeCall("IntegrateBackwardEuler", f, tNext, yn, n, &w.views)
if ferr != nil {
return nil, ferr
}
readVector(fy, out)
for i := range n {
seed[i] = yn[i] + h*fy[i]
}
if nerr := odeNewton("IntegrateBackwardEuler", f, w, tNext, 1, h, yn, seed,
zbuf, opts.AbsTol, opts.RelTol); nerr != nil {
return nil, nerr
}
copy(yn, zbuf)
}
return arrayFromVector(yn), nil
}
// errNewtonStalled marks an implicit solve whose Newton iteration ran
// out of budget or hit a singular matrix without converging. A driver
// that can shrink the step retries on it; a failed f evaluation is a
// different, fatal error.
var errNewtonStalled = errors.New("the Newton iteration did not converge")
// odeCall evaluates f at (t, v) and validates that the result is a
// vector of the expected length n, returning it unchanged. views,
// when not nil, caches the read-only wrapper handed to f, which a
// driver calling f repeatedly wants; a cold call site passes nil.
// Callers that need plain floats follow up with odeEval; callers that
// want to place the values themselves read them straight off the
// array.
func odeCall(name string, f func(t float64, y *core.Array) (*core.Array, error),
t float64, v []float64, n int, views *odeViews) (*core.Array, error) {
out, err := f(t, views.of(v))
if err != nil {
return nil, base.Errf("%s: %w", name, err)
}
if out.NDim() != 1 || out.Len() != n {
return nil, base.Errf("%s: f returned shape %s, want a vector of length %d",
name, base.ShapeText(out.Shape()), n)
}
return out, nil
}
// odeEval calls f at (t, v) and returns the derivative as a plain
// float64 slice.
func odeEval(name string, f func(t float64, y *core.Array) (*core.Array, error),
t float64, v []float64, n int, views *odeViews) ([]float64, error) {
out, err := odeCall(name, f, t, v, n, views)
if err != nil {
return nil, err
}
r := make([]float64, n)
readVector(r, out)
return r, nil
}
// odeBudget counts attempted solver steps against the MaxSteps option:
// the budget is spent before the step's stages are evaluated, so a
// rejected step consumes it like an accepted one.
type odeBudget struct {
used int
max int
}
// spend spends one step of the budget, failing once it is exhausted.
func (b *odeBudget) spend(name string, t, t1 float64) error {
if b.used >= b.max {
return base.Errf("%s: reached MaxSteps=%d at t=%g before t1=%g", name, b.max, t, t1)
}
b.used++
return nil
}
// odeClampStep caps h so a step never overshoots t1; t1−t carries the
// integration direction.
func odeClampStep(h, t, t1 float64) float64 {
if math.Abs(h) > math.Abs(t1-t) {
return t1 - t
}
return h
}
// odeArrived reports whether t sits within a few ulps of t1. The
// accumulating t += h can miss the exact endpoint by rounding once
// t and t1 differ in magnitude beyond Sterbenz territory, and the
// residual distance is indistinguishable from zero at working
// precision, so the solvers treat it as arrived rather than report a
// collapsed step over it.
func odeArrived(t, t1 float64) bool {
if t == t1 {
return true
}
return math.Abs(t1-t) <= 8*base.EpsF*math.Max(math.Abs(t), math.Abs(t1))
}
// odeWork holds the scratch the implicit schemes reuse across the
// steps of one run: the Newton vectors, one flat numerical Jacobian
// with its difference stencils, the factored matrix's rows, the ROS4
// stage buffers and the read-only views handed to f. Every buffer is
// overwritten before it is read, so a run builds the workspace once
// and no step allocates scratch of its own.
type odeWork struct {
// The Newton iteration's residual, step and derivative buffers,
// plus the DAE mass-matrix product M·z.
g, col, fzs []float64
mz []float64
// jac is the numerical Jacobian as one flat n×n buffer, row-major:
// jac[i*n+j] is ∂f_i/∂z_j.
jac []float64
// The Jacobian's central-difference stencils: the two perturbed
// states and their two results.
zp, zm, fp, fm []float64
// mat is the implicit relation's matrix in row-major rows, ROS4's
// (1/(γh))I − J or Newton's α·I − h·J depending on the caller, with
// perm the row permutation its factorisation produced. Factor
// shuffles the rows in place, and every rebuild rewrites the lot.
mat [][]float64
perm []int
// The ROS4 stage buffers: the four divided differences, the stage
// value, its right side, f's result and the candidate end state.
ks [4][]float64
stage, rhs, fy []float64
yEnd []float64
// The Newton iterate the implicit schemes iterate in, and the visit
// bitmap the stage permutation walks: both reused across every step
// and every attempt of one run.
zwork []float64
visited []bool
// views caches the wrapper handed to f per scratch slice.
views odeViews
}
// use returns the workspace's buffers at the state length n, growing
// them on first use. The content is left as the previous step wrote
// it: every consumer overwrites its buffer before reading it.
func (w *odeWork) use(n int) {
w.g = sizedBuf(w.g, n)
w.col = sizedBuf(w.col, n)
w.fzs = sizedBuf(w.fzs, n)
w.mz = sizedBuf(w.mz, n)
w.jac = sizedBuf(w.jac, n*n)
w.zp = sizedBuf(w.zp, n)
w.zm = sizedBuf(w.zm, n)
w.fp = sizedBuf(w.fp, n)
w.fm = sizedBuf(w.fm, n)
w.mat = sizedRows(w.mat, n)
for s := range w.ks {
w.ks[s] = sizedBuf(w.ks[s], n)
}
w.stage = sizedBuf(w.stage, n)
w.rhs = sizedBuf(w.rhs, n)
w.fy = sizedBuf(w.fy, n)
w.yEnd = sizedBuf(w.yEnd, n)
w.zwork = sizedBuf(w.zwork, n)
w.visited = sizedBools(w.visited, n)
}
// useStage returns the explicit step loop's buffers at the state
// length n, growing them on first use: the stage accumulator and the
// candidate end state are all an explicit pair needs, and sizing the
// implicit buffers here would allocate the Jacobian and the factored
// matrix for a loop that never takes a derivative.
func (w *odeWork) useStage(n int) {
w.stage = sizedBuf(w.stage, n)
w.yEnd = sizedBuf(w.yEnd, n)
}
// sizedBuf returns b cut to length n, reusing its storage when it is
// large enough.
func sizedBuf(b []float64, n int) []float64 {
if cap(b) < n {
return make([]float64, n)
}
return b[:n]
}
// sizedBools returns b cut to length n, reusing its storage when it is
// large enough.
func sizedBools(b []bool, n int) []bool {
if cap(b) < n {
return make([]bool, n)
}
return b[:n]
}
// odePermuteColumn reorders col in place so that col[i] takes the
// value that sat at perm[i], the permutation the workspace's LU
// factorisation produced. The walk carries each displaced value around
// its cycle exactly as the library's PermuteColumn does, plain
// assignments moving each value once, so the column ends up bit-
// identical; the difference is that the visit bitmap is the caller's
// reused scratch rather than a fresh allocation per call. The bitmap
// is cleared on entry, so a dirty buffer behaves exactly like a fresh
// one.
func odePermuteColumn(col []float64, perm []int, visited []bool) {
vis := visited[:len(col)]
clear(vis)
for i := range col {
if vis[i] || perm[i] == i {
vis[i] = true
continue
}
// Carry the displaced value around the cycle.
tmp := col[i]
j := i
for {
vis[j] = true
k := perm[j]
if k == i {
break
}
col[j] = col[k]
j = k
}
col[j] = tmp
}
}
// arrayFromVector copies a float64 slice into a fresh rank-1 float64
// array: the trajectory endpoint contract, without the intermediate
// wrapper a cloneArray(wrapVector(...)) pair built. The result never
// aliases the input.
func arrayFromVector(v []float64) *core.Array {
a := core.New(core.Float, len(v))
copy(a.RawFloats(), v)
return a
}
// sizedRows returns m as n row slices of length n, reusing the rows it
// already holds. The rows' content is the caller's to overwrite.
func sizedRows(m [][]float64, n int) [][]float64 {
if cap(m) < n {
m = make([][]float64, n)
}
m = m[:n]
for i := range m {
m[i] = sizedBuf(m[i], n)
}
return m
}
// odeViews caches the read-only wrapper handed to f for one scratch
// slice, so a driver that calls f thousands of times builds the
// wrapper once per slice instead of once per call. The values behind
// the wrapper are the driver's own scratch and keep changing exactly
// as they did; only the Array header is reused. The slices a driver
// hands in are few, so a linear scan beats a map; a driver that
// presents a fresh slice every call cannot grow the cache without
// bound, because the oldest entry makes way.
type odeViews struct {
entries []odeView
}
type odeView struct {
vals []float64
arr *core.Array
}
// odeViewSlots bounds the cache. A driver holds a handful of scratch
// slices at once, and each slot pins one sliced buffer, so the bound
// keeps both the scan and the retention small.
const odeViewSlots = 8
// of returns a read-only view of s, reusing the one already built for
// that slice. A nil cache builds a fresh view, which is what a cold
// call site wants.
func (v *odeViews) of(s []float64) *core.Array {
if v == nil || len(s) == 0 {
return wrapVector(s)
}
for i := range v.entries {
e := &v.entries[i]
if len(e.vals) == len(s) && &e.vals[0] == &s[0] {
return e.arr
}
}
arr := wrapVector(s)
if len(v.entries) == odeViewSlots {
copy(v.entries, v.entries[1:])
v.entries = v.entries[:odeViewSlots-1]
}
v.entries = append(v.entries, odeView{vals: s, arr: arr})
return arr
}
// odeJacobian fills the workspace's flat Jacobian with the central
// differences of f at (t, z), one column per state component:
// entry i*n+j is ∂f_i/∂z_j. The returned slice is the workspace's, so
// it stays valid until the next Jacobian. The two perturbed stencils
// and their two result buffers are reused across columns: each round
// rebuilds the stencils from z and overwrites both results before
// reading them.
func odeJacobian(name string, f func(t float64, y *core.Array) (*core.Array, error),
t float64, z []float64, w *odeWork) ([]float64, error) {
n := len(z)
w.use(n)
jac := w.jac
for j := range n {
eps := math.Sqrt(base.EpsF) * math.Max(1, math.Abs(z[j]))
copy(w.zp, z)
copy(w.zm, z)
w.zp[j] += eps
w.zm[j] -= eps
out, e1 := odeCall(name, f, t, w.zp, n, &w.views)
if e1 != nil {
return nil, e1
}
readVector(w.fp, out)
out, e2 := odeCall(name, f, t, w.zm, n, &w.views)
if e2 != nil {
return nil, e2
}
readVector(w.fm, out)
for i := range n {
jac[i*n+j] = (w.fp[i] - w.fm[i]) / (2 * eps)
}
}
return jac, nil
}
// odeNewton solves the implicit step equation α·z − h·f(tNext, z) = β
// for z by Newton over a numerical Jacobian and the library's LU
// solver, writing the converged state into dst and returning an error
// otherwise. The Jacobian is frozen from the seed and rebuilt twice
// when convergence drags, so a converged step costs one Jacobian and a
// handful of f evaluations. The iteration runs in the workspace's own
// buffer and the column permutation walks the workspace's bitmap, so
// the only allocation a converged step costs is the caller's dst: a
// driver whose dst recycles through a ring allocates its result
// buffers once per solve, not once per step, and a rejected or stalled
// attempt allocates nothing. dst must not alias seed or beta; the
// workspace overwrites it fully at convergence. Convergence is
// measured on the residual against the error scale the caller
// integrates to, an order of magnitude below it, but never below the
// floating-point floor of the residual's own terms, which would
// otherwise be unreachable at the tiny steps a stiff start begins
// with. An iteration that outlives twenty rounds, or a singular Newton
// matrix, surfaces as errNewtonStalled so a stepping driver can retry
// with a smaller step; an f that fails is the fatal error it is.
func odeNewton(name string, f func(t float64, y *core.Array) (*core.Array, error),
w *odeWork, tNext float64, alpha, h float64, beta, seed, dst []float64,
absTol, relTol float64) error {
n := len(seed)
w.use(n)
// The iterate starts as the seed and stays the workspace's buffer:
// the views cache then hands f one stable wrapper for every Newton
// call of the run.
z := sizedBuf(w.zwork, n)
copy(z, seed)
for iteration := range 20 {
out, err := odeCall(name, f, tNext, z, n, &w.views)
if err != nil {
return err
}
readVector(w.fzs, out)
worst, terms := 0.0, 0.0
for i := range n {
w.g[i] = alpha*z[i] - h*w.fzs[i] - beta[i]
worst = math.Max(worst, math.Abs(w.g[i]))
terms = math.Max(terms, math.Abs(alpha*z[i])+math.Abs(h*w.fzs[i])+math.Abs(beta[i]))
}
limit := math.Max(0.1*(absTol+relTol*normInfOfStep(z)), 8*base.EpsF*terms)
if worst <= limit {
copy(dst, z)
return nil
}
if iteration == 0 || iteration == 4 || iteration == 10 {
jac, jerr := odeJacobian(name, f, tNext, z, w)
if jerr != nil {
return jerr
}
// Newton matrix α·I − h·J, a fresh LU for the frozen
// Jacobian; the iterations that follow only substitute.
// Every row is rebuilt entry by entry before the
// factorisation reads it.
for i := range n {
row := w.mat[i]
for j := range n {
row[j] = -h * jac[i*n+j]
}
row[i] += alpha
}
w.perm, _ = base.Factor(w.mat)
if err := base.CheckSingular(name, w.mat); err != nil {
return base.Errf("%s: %w, singular Newton matrix at t=%g",
name, errNewtonStalled, tNext)
}
}
for i := range n {
w.col[i] = -w.g[i]
}
odePermuteColumn(w.col, w.perm, w.visited)
base.SolveColumn(w.mat, w.col)
for i := range n {
z[i] += w.col[i]
}
}
return base.Errf("%s: %w at t=%g", name, errNewtonStalled, tNext)
}
// odeCheck validates the initial state, applies option defaults and
// returns the flat float64 working state.
func odeCheck(name string, y0 *core.Array, opts *ODEOptions) ([]float64, error) {
if y0.NDim() != 1 {
return nil, base.Errf("%s: the state must be a vector, got shape %s", name, base.ShapeText(y0.Shape()))
}
if y0.Len() == 0 {
return nil, base.Errf("%s: the state must not be empty", name)
}
if y0.Dtype() == core.Complex {
return nil, base.Errf("%s: complex states are not supported", name)
}
if opts != nil {
if opts.RelTol <= 0 {
opts.RelTol = 1e-6
}
if opts.AbsTol <= 0 {
opts.AbsTol = 1e-9
}
if opts.MaxSteps <= 0 {
opts.MaxSteps = 100000
}
}
return cloneDense(y0), nil
}
// odeInitialStep guesses the first step size as a small fraction of
// the integration span, carrying the direction in its sign.
func odeInitialStep(t0, t1 float64, y []float64) float64 {
h := 0.01 * math.Abs(t1-t0)
if h == 0 {
h = 1e-6
}
// The step sign carries the integration direction: a t1 < t0
// integrates backwards.
if t1 < t0 {
h = -h
}
return h
}
// cloneArray copies an array element by element, so the integration
// steps' results never alias a buffer already handed out.
func cloneArray(a *core.Array) *core.Array {
out := core.New(a.Dtype(), a.Shape()...)
switch a.Dtype() {
case core.Float:
copy(out.RawFloats(), a.RawFloats())
case core.Float32:
copy(out.RawFloat32s(), a.RawFloat32s())
case core.Int:
copy(out.RawInts(), a.RawInts())
default:
copy(out.RawComplexes(), a.RawComplexes())
}
return out
}
// normInfOfStep returns the infinity norm of a step vector.
func normInfOfStep(step []float64) float64 {
worst := 0.0
for _, v := range step {
if a := math.Abs(v); a > worst {
worst = a
}
}
return worst
}
// wrapVector views a float64 slice as a rank-1 Array without copying.
// The caller must treat the result as read-only.
func wrapVector(v []float64) *core.Array {
a, _ := core.FloatsFromArray(v, len(v))
return a
}
// cloneDense copies an array's elements into a plain float64 slice,
// widening int and float32 elements exactly.
func cloneDense(y0 *core.Array) []float64 {
vals := make([]float64, y0.Len())
for i := range vals {
vals[i] = y0.FloatAt(i)
}
return vals
}
// cloneDenseSlice copies a float64 slice.
func cloneDenseSlice(v []float64) []float64 {
out := make([]float64, len(v))
copy(out, v)
return out
}