feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
"sourcedock.dev/petrbalvin/tensor/linalg"
|
||||
)
|
||||
|
||||
// Benchmarks for the per-step scratch of the stiff solvers, the PDE
|
||||
// stencil steps and the finite-element assemblies: the paths where
|
||||
// allocation churn and repeated lookups, not the arithmetic, set the
|
||||
// cost.
|
||||
|
||||
// perfVector wraps a fixed literal as a rank-1 array.
|
||||
func perfVector(b *testing.B, vals []float64) *core.Array {
|
||||
b.Helper()
|
||||
a, err := core.FromFloats(vals, len(vals))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// perfStiffDecay builds the diagonal stiff system y' = −100(i+1)·y_i
|
||||
// with every component started at one: the rates span three decades,
|
||||
// so the step control stretches over the fast transient and the
|
||||
// Jacobian stays diagonal and cheap to evaluate.
|
||||
func perfStiffDecay(n int) func(t float64, y *core.Array) (*core.Array, error) {
|
||||
rates := make([]float64, n)
|
||||
for i := range rates {
|
||||
rates[i] = -100 * float64(i+1)
|
||||
}
|
||||
return func(t float64, y *core.Array) (*core.Array, error) {
|
||||
out := core.New(core.Float, n)
|
||||
vals := out.RawFloats()
|
||||
ys := y.RawFloats()
|
||||
for i := range n {
|
||||
vals[i] = rates[i] * ys[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// perfConstantState returns a vector of n ones.
|
||||
func perfConstantState(n int) []float64 {
|
||||
vals := make([]float64, n)
|
||||
for i := range vals {
|
||||
vals[i] = 1
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
func BenchmarkROS4Stiff(b *testing.B) {
|
||||
const n = 32
|
||||
f := perfStiffDecay(n)
|
||||
start := perfVector(b, perfConstantState(n))
|
||||
opts := ODEOptions{RelTol: 1e-6, AbsTol: 1e-9}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateROS4(f, 0, 1, start, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBDFVarStiff(b *testing.B) {
|
||||
const n = 32
|
||||
f := perfStiffDecay(n)
|
||||
start := perfVector(b, perfConstantState(n))
|
||||
opts := BDFVarOptions{RelTol: 1e-6, AbsTol: 1e-9}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateBDFVar(f, 0, 1, start, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHeat1DStepLoop(b *testing.B) {
|
||||
const n = 256
|
||||
u0 := make([]float64, n)
|
||||
for i := range u0 {
|
||||
u0[i] = math.Sin(float64(i+1) / float64(n+1) * math.Pi)
|
||||
}
|
||||
state := perfVector(b, u0)
|
||||
// Two samples put every step inside the loop under test: the
|
||||
// published history costs one copy either way.
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateHeat1D(state, 1, 1.0/257, 0.05, 1e-4, 2, 0, 0); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWave1DStepLoop(b *testing.B) {
|
||||
const n = 256
|
||||
u0 := make([]float64, n)
|
||||
v0 := make([]float64, n)
|
||||
for i := range u0 {
|
||||
u0[i] = math.Sin(float64(i+1) / float64(n+1) * math.Pi)
|
||||
}
|
||||
state := perfVector(b, u0)
|
||||
vel := perfVector(b, v0)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateWave1D(state, vel, 1, 1.0/257, 0.05, 1e-4, 2); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHeat2DStepLoop(b *testing.B) {
|
||||
const rows, cols = 64, 64
|
||||
u0 := make([]float64, rows*cols)
|
||||
for r := range rows {
|
||||
for c := range cols {
|
||||
u0[r*cols+c] = math.Sin(float64(c+1)/float64(cols+1)*math.Pi) *
|
||||
math.Sin(float64(r+1)/float64(rows+1)*math.Pi)
|
||||
}
|
||||
}
|
||||
state, err := core.FromFloats(u0, rows, cols)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateHeat2D(state, 1, 1.0/65, 1.0/65, 0.002, 2e-5, 2, 0, 0, 0, 0); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// perfSquareBoundary lists the boundary nodes of the m by m cell grid
|
||||
// on the unit square: the bottom and top rows, then the interior
|
||||
// nodes of the left and right columns.
|
||||
func perfSquareBoundary(m int) []int {
|
||||
nodes := make([]int, 0, 4*m)
|
||||
for i := range m + 1 {
|
||||
nodes = append(nodes, i, m*(m+1)+i)
|
||||
}
|
||||
for j := 1; j < m; j++ {
|
||||
nodes = append(nodes, j*(m+1), j*(m+1)+m)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func BenchmarkPoissonFEM2D(b *testing.B) {
|
||||
const m = 48
|
||||
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, m, m)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
bound := perfSquareBoundary(m)
|
||||
values := make([]float64, len(bound))
|
||||
opts := FEMPoissonOptions{
|
||||
Kappa: 1,
|
||||
DirichletNodes: bound,
|
||||
DirichletValues: values,
|
||||
Ordering: linalg.SparseOrderingReverseCuthillMcKee,
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// perfBoxBoundary lists the vertices of the box tetrahedral mesh that
|
||||
// sit on the unit cube's surface.
|
||||
func perfBoxBoundary(mesh *TetraMesh3D) []int {
|
||||
nodes := make([]int, 0, mesh.Vertices3())
|
||||
for i := range mesh.Vertices3() {
|
||||
x, y, z := mesh.Vertices[3*i], mesh.Vertices[3*i+1], mesh.Vertices[3*i+2]
|
||||
if x == 0 || x == 1 || y == 0 || y == 1 || z == 0 || z == 1 {
|
||||
nodes = append(nodes, i)
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func BenchmarkPoissonFEM3D(b *testing.B) {
|
||||
const m = 8
|
||||
mesh, err := BoxTetraMesh3D(0, 0, 0, 1, 1, 1, m, m, m)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
bound := perfBoxBoundary(mesh)
|
||||
values := make([]float64, len(bound))
|
||||
opts := FEMPoisson3DOptions{
|
||||
Kappa: 1,
|
||||
DirichletNodes: bound,
|
||||
DirichletValues: values,
|
||||
Ordering: linalg.SparseOrderingReverseCuthillMcKee,
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPoissonFEM3DLoad(b *testing.B) {
|
||||
const m = 5
|
||||
mesh, err := BoxTetraMesh3D(0, 0, 0, 1, 1, 1, m, m, m)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
bound := perfBoxBoundary(mesh)
|
||||
values := make([]float64, len(bound))
|
||||
src := func(x, y, z float64) float64 {
|
||||
return 3 * math.Pi * math.Pi * math.Sin(math.Pi*x) * math.Sin(math.Pi*y) * math.Sin(math.Pi*z)
|
||||
}
|
||||
opts := FEMPoisson3DOptions{
|
||||
Kappa: 1,
|
||||
DirichletNodes: bound,
|
||||
DirichletValues: values,
|
||||
Ordering: linalg.SparseOrderingReverseCuthillMcKee,
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := SolvePoissonFEM3D(mesh, src, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkIntegrateHeat2DBig is the same scheme on a grid large enough
|
||||
// that the step sweeps have work to share: 512 lines of 512 unknowns per
|
||||
// half-step.
|
||||
func BenchmarkIntegrateHeat2DBig(b *testing.B) {
|
||||
const rows, cols = 512, 512
|
||||
u0 := make([]float64, rows*cols)
|
||||
for r := range rows {
|
||||
for c := range cols {
|
||||
u0[r*cols+c] = math.Sin(float64(c)/float64(cols)*math.Pi) * math.Sin(float64(r)/float64(rows)*math.Pi)
|
||||
}
|
||||
}
|
||||
state, err := core.FromFloats(u0, rows, cols)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateHeat2D(state, 1, 1.0/513, 1.0/513, 0.02, 0.0004, 2, 0, 0, 0, 0); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Benchmarks for the package's heavy paths: the adaptive Dormand-Prince
|
||||
// step loop, the stiff implicit schemes with their numerical
|
||||
// Jacobians, the adaptive quadrature and cubature, and the PDE
|
||||
// stencils.
|
||||
|
||||
// odeLinear builds the closed-form linear system y' = A·y with a
|
||||
// stable diagonal A, the cheapest honest workload for an adaptive
|
||||
// step loop, and returns f plus the analytic solution for callers
|
||||
// that want it.
|
||||
func odeLinear(n int) (func(float64, *core.Array) (*core.Array, error), []float64) {
|
||||
rates := make([]float64, n)
|
||||
for i := range rates {
|
||||
rates[i] = -0.25 * float64(i+1)
|
||||
}
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
out := core.New(core.Float, n)
|
||||
vals := out.RawFloats()
|
||||
ys := y.RawFloats()
|
||||
for i := range n {
|
||||
vals[i] = rates[i] * ys[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
y0 := make([]float64, n)
|
||||
for i := range y0 {
|
||||
y0[i] = 1
|
||||
}
|
||||
return f, y0
|
||||
}
|
||||
|
||||
func benchVector(b *testing.B, vals []float64) *core.Array {
|
||||
b.Helper()
|
||||
a, err := core.FromFloats(vals, len(vals))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateODE(b *testing.B) {
|
||||
f, y0 := odeLinear(16)
|
||||
start := benchVector(b, y0)
|
||||
opts := ODEOptions{}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateODE(f, 0, 10, start, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateRK4(b *testing.B) {
|
||||
f, y0 := odeLinear(16)
|
||||
start := benchVector(b, y0)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateRK4(f, 0, 10, start, 2000); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateBackwardEuler(b *testing.B) {
|
||||
// A stiff diagonal system: rates from −1 to −1000.
|
||||
const n = 4
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
out := core.New(core.Float, n)
|
||||
vals := out.RawFloats()
|
||||
ys := y.RawFloats()
|
||||
for i := range n {
|
||||
vals[i] = -float64(i+1) * 100 * ys[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
start := benchVector(b, []float64{1, 1, 1, 1})
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateBackwardEuler(f, 0, 1, start, 200, ODEOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateBDF2(b *testing.B) {
|
||||
const n = 4
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
out := core.New(core.Float, n)
|
||||
vals := out.RawFloats()
|
||||
ys := y.RawFloats()
|
||||
for i := range n {
|
||||
vals[i] = -float64(i+1) * 100 * ys[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
start := benchVector(b, []float64{1, 1, 1, 1})
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateBDF2(f, 0, 1, start, ODEOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateDAE(b *testing.B) {
|
||||
// The linear index-1 circuit shape: one differential row, one
|
||||
// algebraic constraint, the Newton solve carrying the step.
|
||||
m, err := core.FromFloats([]float64{1, 0, 0, 0}, 2, 2)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1) - y.FloatAt(0)}, 2)
|
||||
}
|
||||
start := benchVector(b, []float64{1, 1})
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateDAE(f, m, 0, 1, start, 200, DAEOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateMidpoint(b *testing.B) {
|
||||
// The harmonic oscillator's quadratic H: the implicit stage is a
|
||||
// root find whose gradient is linear in z.
|
||||
const n = 8
|
||||
gradH := func(z *core.Array) (*core.Array, error) {
|
||||
out := core.New(core.Float, 2*n)
|
||||
vals := out.RawFloats()
|
||||
zs := z.RawFloats()
|
||||
for i := range n {
|
||||
vals[i] = zs[n+i]
|
||||
vals[n+i] = zs[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
q0 := make([]float64, n)
|
||||
p0 := make([]float64, n)
|
||||
for i := range q0 {
|
||||
q0[i] = math.Sin(float64(i))
|
||||
p0[i] = math.Cos(float64(i))
|
||||
}
|
||||
qs := benchVector(b, q0)
|
||||
ps := benchVector(b, p0)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, _, err := IntegrateMidpoint(gradH, 0, 1, qs, ps, 50, MidpointOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateFunction(b *testing.B) {
|
||||
f := func(x float64) (float64, error) { return math.Sin(x), nil }
|
||||
opts := QuadratureOptions{}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, _, err := IntegrateFunction(f, 0, 100, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateND(b *testing.B) {
|
||||
f := func(x []float64) float64 {
|
||||
s := 0.0
|
||||
for _, v := range x {
|
||||
s += v * v
|
||||
}
|
||||
return math.Exp(-s)
|
||||
}
|
||||
lo := []float64{-2, -2, -2}
|
||||
hi := []float64{2, 2, 2}
|
||||
opts := CubatureOptions{Tolerance: 1e-6}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateND(f, lo, hi, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateHeat1D(b *testing.B) {
|
||||
n := 256
|
||||
u0 := make([]float64, n)
|
||||
for i := range u0 {
|
||||
u0[i] = math.Sin(float64(i) / float64(n) * math.Pi)
|
||||
}
|
||||
state := benchVector(b, u0)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateHeat1D(state, 1, 1.0/257, 0.1, 0.0002, 10, 0, 0); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateWave1D(b *testing.B) {
|
||||
n := 256
|
||||
u0 := make([]float64, n)
|
||||
v0 := make([]float64, n)
|
||||
for i := range u0 {
|
||||
u0[i] = math.Sin(float64(i) / float64(n) * math.Pi)
|
||||
}
|
||||
us := benchVector(b, u0)
|
||||
vs := benchVector(b, v0)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateWave1D(us, vs, 1, 1.0/257, 0.5, 0.002, 10); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateHeat2D(b *testing.B) {
|
||||
rows, cols := 32, 32
|
||||
u0 := make([]float64, rows*cols)
|
||||
for r := range rows {
|
||||
for c := range cols {
|
||||
u0[r*cols+c] = math.Sin(float64(c)/float64(cols)*math.Pi) * math.Sin(float64(r)/float64(rows)*math.Pi)
|
||||
}
|
||||
}
|
||||
state, err := core.FromFloats(u0, rows, cols)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateHeat2D(state, 1, 1.0/33, 1.0/33, 0.02, 0.0004, 5, 0, 0, 0, 0); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateWave2D(b *testing.B) {
|
||||
rows, cols := 32, 32
|
||||
u0 := make([]float64, rows*cols)
|
||||
v0 := make([]float64, rows*cols)
|
||||
for r := range rows {
|
||||
for c := range cols {
|
||||
u0[r*cols+c] = math.Sin(float64(c)/float64(cols)*math.Pi) * math.Sin(float64(r)/float64(rows)*math.Pi)
|
||||
}
|
||||
}
|
||||
us, err := core.FromFloats(u0, rows, cols)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
vs, err := core.FromFloats(v0, rows, cols)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateWave2D(us, vs, 1, 1.0/33, 1.0/33, 0.05, 0.002, 5); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIntegrateVerlet(b *testing.B) {
|
||||
// Two coupled oscillators apiece: the acceleration reads the
|
||||
// neighbour spring terms.
|
||||
n := 32
|
||||
q0 := make([]float64, n)
|
||||
p0 := make([]float64, n)
|
||||
for i := range q0 {
|
||||
q0[i] = math.Sin(float64(i))
|
||||
}
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
out := core.New(core.Float, n)
|
||||
vals := out.RawFloats()
|
||||
qs := q.RawFloats()
|
||||
for i := range n {
|
||||
l, r := 0.0, 0.0
|
||||
if i > 0 {
|
||||
l = qs[i-1]
|
||||
}
|
||||
if i < n-1 {
|
||||
r = qs[i+1]
|
||||
}
|
||||
vals[i] = l - 2*qs[i] + r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
qs := benchVector(b, q0)
|
||||
ps := benchVector(b, p0)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 10, qs, ps, 500); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Regression pins: budgets that did not bound what they
|
||||
// promised, and non-finite states that integrated to no error.
|
||||
|
||||
// TestIntegrateNDDimensionBudget: in 10 dimensions the root box alone
|
||||
// costs 5^10 + 3^10 evaluations, about five times the default budget,
|
||||
// before the first budget check could fire.
|
||||
func TestIntegrateNDDimensionBudget(t *testing.T) {
|
||||
lower := make([]float64, 10)
|
||||
upper := make([]float64, 10)
|
||||
for i := range upper {
|
||||
upper[i] = 1
|
||||
}
|
||||
f := func(x []float64) float64 { return 1 }
|
||||
_, err := IntegrateND(f, lower, upper, CubatureOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "budget") {
|
||||
t.Fatalf("IntegrateND in 10 dimensions under the default budget: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPDEStepCountBound: a dt far below tFinal/1e12 wrapped the step
|
||||
// count conversion, and the silently larger step ran past the wave
|
||||
// equation's CFL check.
|
||||
func TestPDEStepCountBound(t *testing.T) {
|
||||
u0, err := core.FromFloats([]float64{0, 1, 0, 1, 0}, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := IntegrateHeat1D(u0, 1, 0.1, 1, 1e-300, 2, 0, 0); err == nil || !strings.Contains(err.Error(), "1e12") {
|
||||
t.Fatalf("Heat1D with an unhonourable dt: err = %v", err)
|
||||
}
|
||||
u2, err := core.FromFloats([]float64{1, 2, 3, 4, 5, 6, 7, 8, 9}, 3, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := IntegrateHeat2D(u2, 1, 0.1, 0.1, 1, 1e-300, 2, 0, 0, 0, 0); err == nil || !strings.Contains(err.Error(), "1e12") {
|
||||
t.Fatalf("Heat2D with an unhonourable dt: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPDEVerletRejectNonFinite: a NaN or Inf initial state flowed
|
||||
// through the stencils and published an all-NaN history with no error.
|
||||
func TestPDEVerletRejectNonFinite(t *testing.T) {
|
||||
bad, err := core.FromFloats([]float64{1, math.NaN(), 0, 1, 0}, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := IntegrateHeat1D(bad, 1, 0.1, 1, 0.1, 2, 0, 0); err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||||
t.Fatalf("Heat1D on a NaN state: err = %v", err)
|
||||
}
|
||||
q, err := core.FromFloats([]float64{1, math.Inf(1)}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := core.FromFloats([]float64{0, 0}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accel := func(x *core.Array) (*core.Array, error) { return core.Copy(x), nil }
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 1, q, p, 2); err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||||
t.Fatalf("Verlet on an Inf state: err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
)
|
||||
|
||||
// Adaptive cubature over hyperrectangles: the many-dimensional
|
||||
// twin of the adaptive Gauss-Legendre quadrature. Each box is measured
|
||||
// by two product rules (orders 3 and 5 per axis); the difference is
|
||||
// that box's error estimate, and the globally adaptive loop always
|
||||
// bisects the worst box along its longest edge, so effort concentrates
|
||||
// where the integrand actually varies. The 1-D case degenerates to the
|
||||
// quadrature the package already ships, which doubles as its oracle.
|
||||
|
||||
// CubatureOptions tunes IntegrateND. Tolerance bounds the global sum
|
||||
// of box error estimates (default 1e-10); MaxEvals bounds the function
|
||||
// evaluations (default two million), an exhausted budget being an
|
||||
// error naming the achieved estimate, never a silent answer.
|
||||
type CubatureOptions struct {
|
||||
Tolerance float64
|
||||
MaxEvals int
|
||||
}
|
||||
|
||||
// cubBox is one hyperrectangle of the adaptive subdivision: its
|
||||
// bounds, its measured value and error estimate, and seq, the order
|
||||
// in which it entered the subdivision. The sequence is the heap's
|
||||
// tie-break: among equal estimates the earliest inserted box leaves
|
||||
// first, the same one a scan over the insertion order picks.
|
||||
type cubBox struct {
|
||||
lo, hi []float64
|
||||
val float64
|
||||
est float64
|
||||
seq int
|
||||
}
|
||||
|
||||
// cubBoxAbove reports whether a leaves the box heap before b: the
|
||||
// larger error estimate first, and among equal estimates the earlier
|
||||
// insertion. Popping that maximum reproduces the selection of a
|
||||
// linear scan over the insertion order exactly, ties included, for
|
||||
// every finite estimate. A non-finite estimate can only come out of
|
||||
// an overflowed measure, a state in which the integral is already
|
||||
// meaningless: such a box is a total-order special case and stays at
|
||||
// the bottom of the heap, leaving every finite estimate to run first,
|
||||
// where the scan would have left it wherever its insertion happened
|
||||
// to place it. Two non-finite boxes keep insertion order between
|
||||
// themselves.
|
||||
func cubBoxAbove(a, b *cubBox) bool {
|
||||
aOut := math.IsNaN(a.est) || math.IsInf(a.est, 0)
|
||||
bOut := math.IsNaN(b.est) || math.IsInf(b.est, 0)
|
||||
if aOut != bOut {
|
||||
return !aOut
|
||||
}
|
||||
if aOut {
|
||||
return a.seq < b.seq
|
||||
}
|
||||
if a.est != b.est {
|
||||
return a.est > b.est
|
||||
}
|
||||
return a.seq < b.seq
|
||||
}
|
||||
|
||||
// cubSiftUp restores the max-heap order after a push at the tail.
|
||||
func cubSiftUp(h []*cubBox) {
|
||||
i := len(h) - 1
|
||||
for i > 0 {
|
||||
parent := (i - 1) / 2
|
||||
if !cubBoxAbove(h[i], h[parent]) {
|
||||
return
|
||||
}
|
||||
h[i], h[parent] = h[parent], h[i]
|
||||
i = parent
|
||||
}
|
||||
}
|
||||
|
||||
// cubSiftDown restores the max-heap order after the top has been
|
||||
// replaced from the tail.
|
||||
func cubSiftDown(h []*cubBox) {
|
||||
n := len(h)
|
||||
i := 0
|
||||
for {
|
||||
left := 2*i + 1
|
||||
if left >= n {
|
||||
return
|
||||
}
|
||||
above := left
|
||||
if right := left + 1; right < n && cubBoxAbove(h[right], h[left]) {
|
||||
above = right
|
||||
}
|
||||
if !cubBoxAbove(h[above], h[i]) {
|
||||
return
|
||||
}
|
||||
h[i], h[above] = h[above], h[i]
|
||||
i = above
|
||||
}
|
||||
}
|
||||
|
||||
// cubBoxChunk and cubBoundsChunk size the bisection arenas: one
|
||||
// allocation per chunk of boxes or bound coordinates instead of one
|
||||
// per box, so a subdivision that reaches thousands of boxes spends
|
||||
// tens of allocations, not six per bisection. A chunk never moves once
|
||||
// handed out, so the heap's pointers stay valid across growth.
|
||||
const (
|
||||
cubBoxChunk = 256 // boxes per arena chunk
|
||||
cubBoundsChunk = 1024 // float64 coordinates per arena chunk
|
||||
)
|
||||
|
||||
// cubBoxArena hands out frozen cubBox values in fixed chunks.
|
||||
type cubBoxArena struct {
|
||||
chunks [][]cubBox
|
||||
}
|
||||
|
||||
// alloc returns the next box, zeroed. A box's fields are written once
|
||||
// by the caller and never after, which is what lets the heap hold the
|
||||
// pointer for the life of the subdivision.
|
||||
func (a *cubBoxArena) alloc() *cubBox {
|
||||
if len(a.chunks) == 0 || len(a.chunks[len(a.chunks)-1]) == cubBoxChunk {
|
||||
a.chunks = append(a.chunks, make([]cubBox, 0, cubBoxChunk))
|
||||
}
|
||||
last := len(a.chunks) - 1
|
||||
c := append(a.chunks[last], cubBox{})
|
||||
a.chunks[last] = c
|
||||
return &c[len(c)-1]
|
||||
}
|
||||
|
||||
// cubBoundsArena hands out box-coordinate slices copied from a parent
|
||||
// box in fixed chunks. A handed-out slice is written once (the copy,
|
||||
// then the bisected face) and read-only afterwards.
|
||||
type cubBoundsArena struct {
|
||||
chunks [][]float64
|
||||
used int
|
||||
}
|
||||
|
||||
// copy returns src's values in a fresh arena slice.
|
||||
func (a *cubBoundsArena) copy(src []float64) []float64 {
|
||||
n := len(src)
|
||||
size := max(n, cubBoundsChunk)
|
||||
if len(a.chunks) == 0 || a.used+n > cap(a.chunks[len(a.chunks)-1]) {
|
||||
a.chunks = append(a.chunks, make([]float64, 0, size))
|
||||
a.used = 0
|
||||
}
|
||||
last := len(a.chunks) - 1
|
||||
c := a.chunks[last]
|
||||
keep := len(c)
|
||||
c = append(c, src...)
|
||||
a.chunks[last] = c
|
||||
a.used += n
|
||||
return c[keep : keep+n]
|
||||
}
|
||||
|
||||
// IntegrateND returns the integral of f over the hyperrectangle
|
||||
// [lower, upper] element-wise, by globally adaptive bisection with
|
||||
// product Gauss-Legendre rules. f receives the evaluation point and
|
||||
// must not mutate it. A non-finite value, mismatched or empty bounds,
|
||||
// a reversed edge, or an exhausted evaluation budget is an error.
|
||||
func IntegrateND(f func(x []float64) float64, lower, upper []float64, opts CubatureOptions) (float64, error) {
|
||||
const name = "IntegrateND"
|
||||
if len(lower) == 0 || len(lower) != len(upper) {
|
||||
return 0, base.Errf("%s: lower and upper must be equal-length non-empty bounds", name)
|
||||
}
|
||||
for d := range lower {
|
||||
if !(upper[d] > lower[d]) {
|
||||
return 0, base.Errf("%s: edge %d runs from %g to %g", name, d, lower[d], upper[d])
|
||||
}
|
||||
}
|
||||
tol := opts.Tolerance
|
||||
if tol <= 0 {
|
||||
tol = 1e-10
|
||||
}
|
||||
maxEvals := opts.MaxEvals
|
||||
if maxEvals <= 0 {
|
||||
maxEvals = 2_000_000
|
||||
}
|
||||
d := len(lower)
|
||||
n5, w5, err := GaussLegendreNodes(5)
|
||||
if err != nil {
|
||||
return 0, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
n3, w3, err := GaussLegendreNodes(3)
|
||||
if err != nil {
|
||||
return 0, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
|
||||
evals := 0
|
||||
// One odometer and one evaluation point serve every rule call:
|
||||
// measure is sequential, so each call overwrites what the last
|
||||
// read. The bisection budget term is fixed by the dimension.
|
||||
idx := make([]int, d)
|
||||
point := make([]float64, d)
|
||||
// The root box alone costs 5^d + 3^d evaluations before the first
|
||||
// budget check could fire, and a bisection calls measure twice,
|
||||
// costing 2·(5^d + 3^d); the loop accounts that true cost below.
|
||||
// The pre-loop guard is deliberately conservative: it compares
|
||||
// against the wider bound 8^d + 6^d, saturating, because a MaxInt
|
||||
// budget must not admit a dimension whose true cost merely fits
|
||||
// the integer range while needing years to evaluate.
|
||||
c5, c3, c8, c6 := 1, 1, 1, 1
|
||||
for range d {
|
||||
c5 = satMul(c5, 5)
|
||||
c3 = satMul(c3, 3)
|
||||
c8 = satMul(c8, 8)
|
||||
c6 = satMul(c6, 6)
|
||||
// A saturated product means the true power left the int range:
|
||||
// it is above every budget, and letting it through would put a
|
||||
// wrapped count into the later comparisons.
|
||||
if c8 == math.MaxInt || c6 == math.MaxInt || c8 > maxEvals || c6 > maxEvals {
|
||||
return 0, base.Errf("%s: dimension %d needs more than the %d-evaluation budget for a single bisection", name, d, maxEvals)
|
||||
}
|
||||
}
|
||||
if c5+c3 > maxEvals {
|
||||
return 0, base.Errf("%s: dimension %d needs %d evaluations for the root box alone, above the %d budget", name, d, c5+c3, maxEvals)
|
||||
}
|
||||
boxEvals := 2*c5 + 2*c3
|
||||
measure := func(lo, hi []float64) (val, est float64, err error) {
|
||||
prodRule := func(nodes, weights []float64) (float64, error) {
|
||||
// One odometer over the per-axis nodes; the axis weights
|
||||
// multiply along the way, the Jacobian at the end.
|
||||
jac := 1.0
|
||||
for a := range d {
|
||||
jac *= (hi[a] - lo[a]) / 2
|
||||
}
|
||||
var sum float64
|
||||
for {
|
||||
for a := range d {
|
||||
point[a] = 0.5*(hi[a]-lo[a])*nodes[idx[a]] + 0.5*(hi[a]+lo[a])
|
||||
}
|
||||
w := jac
|
||||
for a := range d {
|
||||
w *= weights[idx[a]]
|
||||
}
|
||||
v := f(point)
|
||||
evals++
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return 0, base.Errf("%s: the integrand is non-finite at %v", name, point)
|
||||
}
|
||||
sum += w * v
|
||||
// Odometer advance.
|
||||
a := d - 1
|
||||
for ; a >= 0; a-- {
|
||||
idx[a]++
|
||||
if idx[a] < len(nodes) {
|
||||
break
|
||||
}
|
||||
idx[a] = 0
|
||||
}
|
||||
if a < 0 {
|
||||
return sum, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
fine, err := prodRule(n5, w5)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
coarse, err := prodRule(n3, w3)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return fine, math.Abs(fine - coarse), nil
|
||||
}
|
||||
|
||||
rootVal, rootEst, err := measure(lower, upper)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// The boxes awaiting bisection live in a binary max-heap keyed by
|
||||
// the error estimate with the insertion sequence as the tie-break,
|
||||
// so, while every estimate stays finite, each pop hands back
|
||||
// exactly the box a linear scan over the insertion order selects,
|
||||
// at logarithmic instead of linear cost; an overflowed measure's
|
||||
// non-finite estimate sorts below every finite one. The sifts work
|
||||
// index-wise and the backing array grows amortised. The boxes and
|
||||
// their bound slices come from the chunk arenas above, one
|
||||
// allocation per chunk instead of per box; the root box aliases the
|
||||
// caller's bounds, which the solve only reads.
|
||||
var boxArena cubBoxArena
|
||||
var boundArena cubBoundsArena
|
||||
root := boxArena.alloc()
|
||||
*root = cubBox{lower, upper, rootVal, rootEst, 0}
|
||||
boxes := []*cubBox{root}
|
||||
seq := 1
|
||||
total := rootVal
|
||||
totalEst := rootEst
|
||||
// The stopping rule scales the tolerance with the magnitude of the
|
||||
// integral, the way Integrate combines its bounds: the error
|
||||
// estimate of an integral of size 1e6 cannot fall below the
|
||||
// rounding floor of the sum itself, so a purely absolute tolerance
|
||||
// would burn the whole budget and report an exhausted budget
|
||||
// instead of the answer. Below unit magnitude the rule is exactly
|
||||
// the absolute one it always was.
|
||||
for totalEst > tol*math.Max(1, math.Abs(total)) {
|
||||
if evals+boxEvals > maxEvals {
|
||||
return 0, base.Errf("%s: evaluation budget exhausted (%d), estimate %.6g ± %.2g",
|
||||
name, maxEvals, total, totalEst)
|
||||
}
|
||||
// The heap top is the box with the largest error estimate,
|
||||
// the earliest inserted among equals.
|
||||
worst := boxes[0]
|
||||
if worst.est == 0 {
|
||||
break // every box is already exact by the estimate
|
||||
}
|
||||
// Pop it: move the tail box to the top and sift it down.
|
||||
last := len(boxes) - 1
|
||||
boxes[0] = boxes[last]
|
||||
boxes[last] = nil
|
||||
boxes = boxes[:last]
|
||||
cubSiftDown(boxes)
|
||||
// Bisect along the longest edge.
|
||||
longest := 0
|
||||
for a := 1; a < d; a++ {
|
||||
if worst.hi[a]-worst.lo[a] > worst.hi[longest]-worst.lo[longest] {
|
||||
longest = a
|
||||
}
|
||||
}
|
||||
// The dividing plane keeps every other edge: each child is the
|
||||
// parent with one face moved to the midpoint, not a corner
|
||||
// slice (which would collapse the untouched axes). The
|
||||
// unmodified faces stay the parent's own slices, aliased
|
||||
// read-only, and the moved face lives in a fresh arena slice:
|
||||
// box bounds are never written after their one construction
|
||||
// write, so the aliases hold for the life of the heap.
|
||||
m := 0.5 * (worst.lo[longest] + worst.hi[longest])
|
||||
hi1 := boundArena.copy(worst.hi)
|
||||
hi1[longest] = m
|
||||
lo2 := boundArena.copy(worst.lo)
|
||||
lo2[longest] = m
|
||||
v1, e1, err := measure(worst.lo, hi1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v2, e2, err := measure(lo2, worst.hi)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
b1 := boxArena.alloc()
|
||||
*b1 = cubBox{worst.lo, hi1, v1, e1, seq}
|
||||
boxes = append(boxes, b1)
|
||||
cubSiftUp(boxes)
|
||||
seq++
|
||||
b2 := boxArena.alloc()
|
||||
*b2 = cubBox{lo2, worst.hi, v2, e2, seq}
|
||||
boxes = append(boxes, b2)
|
||||
cubSiftUp(boxes)
|
||||
seq++
|
||||
total += v1 + v2 - worst.val
|
||||
totalEst += e1 + e2 - worst.est
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// satMul multiplies with saturation at MaxInt, so a power that
|
||||
// outgrows the int range reads as "above every budget" instead of
|
||||
// wrapping into a count the comparisons would read as small.
|
||||
func satMul(a, b int) int {
|
||||
if a > math.MaxInt/b {
|
||||
return math.MaxInt
|
||||
}
|
||||
return a * b
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// cubHeapPop takes the heap's head the way IntegrateND does: the last
|
||||
// box replaces the root and sifts down.
|
||||
func cubHeapPop(h []*cubBox) ([]*cubBox, *cubBox) {
|
||||
top := h[0]
|
||||
last := len(h) - 1
|
||||
h[0] = h[last]
|
||||
h[last] = nil
|
||||
h = h[:last]
|
||||
if last > 0 {
|
||||
cubSiftDown(h)
|
||||
}
|
||||
return h, top
|
||||
}
|
||||
|
||||
// TestCubatureBoxHeapOrder pins the order the box heap pops in, the
|
||||
// substance of the heap replacing the linear scan: the largest finite
|
||||
// estimate first, the earliest insertion among equals, and a
|
||||
// non-finite estimate, which only an overflowed measure produces,
|
||||
// below every finite one.
|
||||
func TestCubatureBoxHeapOrder(t *testing.T) {
|
||||
t.Run("larger estimate first", func(t *testing.T) {
|
||||
h := []*cubBox{{est: 5, seq: 1}, {est: 1, seq: 2}, {est: math.NaN(), seq: 3}, {est: 3, seq: 4}}
|
||||
for i := range h {
|
||||
cubSiftUp(h[:i+1])
|
||||
}
|
||||
for _, want := range []float64{5, 3, 1} {
|
||||
var top *cubBox
|
||||
h, top = cubHeapPop(h)
|
||||
if top.est != want {
|
||||
t.Fatalf("popped estimate %v, want %v", top.est, want)
|
||||
}
|
||||
}
|
||||
if h[0].est == h[0].est {
|
||||
t.Fatalf("a finite estimate %v survived before the non-finite one", h[0].est)
|
||||
}
|
||||
})
|
||||
t.Run("earliest insertion among equals", func(t *testing.T) {
|
||||
h := []*cubBox{{est: 2, seq: 2}, {est: 2, seq: 0}, {est: 2, seq: 3}, {est: 2, seq: 1}}
|
||||
for i := range h {
|
||||
cubSiftUp(h[:i+1])
|
||||
}
|
||||
for _, want := range []int{0, 1, 2, 3} {
|
||||
var top *cubBox
|
||||
h, top = cubHeapPop(h)
|
||||
if top.seq != want {
|
||||
t.Fatalf("popped insertion %d, want %d", top.seq, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("interleaved push and pop", func(t *testing.T) {
|
||||
h := []*cubBox{{est: 5, seq: 0}}
|
||||
for _, b := range []*cubBox{{est: 7, seq: 1}, {est: 6, seq: 2}} {
|
||||
h = append(h, b)
|
||||
cubSiftUp(h)
|
||||
}
|
||||
var top *cubBox
|
||||
h, top = cubHeapPop(h)
|
||||
if top.est != 7 {
|
||||
t.Fatalf("popped estimate %v, want 7", top.est)
|
||||
}
|
||||
h = append(h, &cubBox{est: 4, seq: 3})
|
||||
cubSiftUp(h)
|
||||
h, top = cubHeapPop(h)
|
||||
if top.est != 6 {
|
||||
t.Fatalf("popped estimate %v, want 6", top.est)
|
||||
}
|
||||
h, top = cubHeapPop(h)
|
||||
if top.est != 5 {
|
||||
t.Fatalf("popped estimate %v, want 5", top.est)
|
||||
}
|
||||
})
|
||||
t.Run("overflowed estimates sort below every finite one", func(t *testing.T) {
|
||||
// An overflowed measure can carry +Inf, and a corrupted one a
|
||||
// NaN: both belong at the bottom of the heap, and the insertion
|
||||
// order holds between them.
|
||||
h := []*cubBox{
|
||||
{est: math.Inf(1), seq: 0},
|
||||
{est: 2, seq: 1},
|
||||
{est: math.NaN(), seq: 2},
|
||||
{est: 4, seq: 3},
|
||||
{est: math.Inf(-1), seq: 4},
|
||||
{est: 3, seq: 5},
|
||||
}
|
||||
for i := range h {
|
||||
cubSiftUp(h[:i+1])
|
||||
}
|
||||
for _, want := range []float64{4, 3, 2} {
|
||||
var top *cubBox
|
||||
h, top = cubHeapPop(h)
|
||||
if top.est != want {
|
||||
t.Fatalf("popped estimate %v, want %v", top.est, want)
|
||||
}
|
||||
}
|
||||
for _, want := range []int{0, 2, 4} {
|
||||
var top *cubBox
|
||||
h, top = cubHeapPop(h)
|
||||
if top.seq != want {
|
||||
t.Fatalf("popped insertion %d, want %d", top.seq, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCubatureGaussian pins the 2-D Gaussian against its exact box
|
||||
// value π·erf(3)²; the infinite-domain π is not what a box integral
|
||||
// returns.
|
||||
func TestCubatureGaussian(t *testing.T) {
|
||||
got, err := IntegrateND(func(x []float64) float64 {
|
||||
return math.Exp(-x[0]*x[0] - x[1]*x[1])
|
||||
}, []float64{-3, -3}, []float64{3, 3}, CubatureOptions{Tolerance: 1e-11})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateND: %v", err)
|
||||
}
|
||||
want := math.Pi * math.Erf(3) * math.Erf(3)
|
||||
if math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("∫∫e^{-r²} = %.12f, want %.12f", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCubaturePolynomials pins exactness on products of polynomials.
|
||||
func TestCubaturePolynomials(t *testing.T) {
|
||||
got, err := IntegrateND(func(x []float64) float64 {
|
||||
return x[0] * x[0] * x[1]
|
||||
}, []float64{0, 0}, []float64{1, 1}, CubatureOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateND: %v", err)
|
||||
}
|
||||
if math.Abs(got-1.0/6.0) > 1e-13 {
|
||||
t.Fatalf("∫x²y = %.14f, want 1/6", got)
|
||||
}
|
||||
// 3-D volume of the unit cube shifted.
|
||||
got3, err := IntegrateND(func(x []float64) float64 { return 1 },
|
||||
[]float64{1, 2, 3}, []float64{3, 5, 7}, CubatureOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateND: %v", err)
|
||||
}
|
||||
if math.Abs(got3-24) > 1e-12 {
|
||||
t.Fatalf("volume = %.12f, want 24", got3)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCubaturePeaked pins adaptivity: a sharp ridge that uniform
|
||||
// refinement would crawl on, checked against a dense product Simpson.
|
||||
func TestCubaturePeaked(t *testing.T) {
|
||||
f := func(x []float64) float64 {
|
||||
d2 := (x[0] - 0.4) * (x[0] - 0.4)
|
||||
d2 += (x[1] - 0.6) * (x[1] - 0.6)
|
||||
return 1 / (0.003 + d2)
|
||||
}
|
||||
got, err := IntegrateND(f, []float64{0, 0}, []float64{1, 1}, CubatureOptions{Tolerance: 1e-9})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateND: %v", err)
|
||||
}
|
||||
// Reference: 800×800 composite midpoint product.
|
||||
const n = 800
|
||||
h := 1.0 / n
|
||||
ref := 0.0
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
ref += h * h * f([]float64{(float64(i) + 0.5) * h, (float64(j) + 0.5) * h})
|
||||
}
|
||||
}
|
||||
if math.Abs(got-ref) > 2e-4*ref {
|
||||
t.Fatalf("peaked integral = %.8f, reference %.8f", got, ref)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCubatureMatches1D pins the degenerate dimension against the
|
||||
// one-dimensional adaptive quadrature.
|
||||
func TestCubatureMatches1D(t *testing.T) {
|
||||
f := func(x float64) float64 { return math.Exp(-x) * math.Cos(3*x) }
|
||||
got, err := IntegrateND(func(x []float64) float64 { return f(x[0]) },
|
||||
[]float64{0}, []float64{5}, CubatureOptions{Tolerance: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateND: %v", err)
|
||||
}
|
||||
ref, _, err := IntegrateFunction(func(x float64) (float64, error) { return f(x), nil },
|
||||
0, 5, QuadratureOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateFunction: %v", err)
|
||||
}
|
||||
if math.Abs(got-ref) > 1e-9 {
|
||||
t.Fatalf("1-D degenerate = %.12f, quadrature says %.12f", got, ref)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCubatureErrors pins the input gates.
|
||||
func TestCubatureErrors(t *testing.T) {
|
||||
if _, err := IntegrateND(func(x []float64) float64 { return 0 },
|
||||
[]float64{}, []float64{}, CubatureOptions{}); err == nil {
|
||||
t.Error("empty bounds accepted")
|
||||
}
|
||||
if _, err := IntegrateND(func(x []float64) float64 { return 0 },
|
||||
[]float64{1, 0}, []float64{0, 1}, CubatureOptions{}); err == nil {
|
||||
t.Error("reversed edge accepted")
|
||||
}
|
||||
if _, err := IntegrateND(func(x []float64) float64 { return math.NaN() },
|
||||
[]float64{0}, []float64{1}, CubatureOptions{}); err == nil {
|
||||
t.Error("non-finite integrand accepted")
|
||||
}
|
||||
// The budget only bites when refinement is actually needed, so the
|
||||
// integrand must carry an error estimate a constant cannot.
|
||||
if _, err := IntegrateND(func(x []float64) float64 { return math.Sin(x[0] * x[1]) },
|
||||
[]float64{0, 0, 0}, []float64{1, 1, 1}, CubatureOptions{MaxEvals: 1}); err == nil {
|
||||
t.Error("exhausted budget accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package integrate solves differential equations, integrates functions
|
||||
// and evolves partial differential equations. It carries five families:
|
||||
// ordinary differential equations as initial value problems, with event
|
||||
// detection on the way; two-point boundary value problems by shooting
|
||||
// and by collocation, and Hamiltonian systems by symplectic schemes;
|
||||
// adaptive quadrature and cubature; turnkey heat, wave and advection
|
||||
// solvers in one and two space dimensions; and a piecewise-linear
|
||||
// finite element Poisson solver on triangular and tetrahedral meshes.
|
||||
//
|
||||
// # The state contract
|
||||
//
|
||||
// An ordinary differential equation is y' = f(t, y), where f returns the
|
||||
// derivative of the state at a time and a state. The state is a rank-1
|
||||
// array: a system of higher rank flattens to its leading-axis vector
|
||||
// first. The ODE family reads its elements one by one and widens them to
|
||||
// float64, so an int or float32 state integrates there; the symplectic
|
||||
// family accepts float64 and float32 positions and momenta only, and a
|
||||
// complex state is refused everywhere. The returned trajectories are
|
||||
// float64 arrays, freshly allocated, and the inputs are never written
|
||||
// to.
|
||||
//
|
||||
// Every solver refuses rather than guesses. An exhausted step budget, a
|
||||
// step size that has collapsed below the resolution of t, an f that
|
||||
// returns a wrongly shaped state, a non-finite value or a violated CFL
|
||||
// budget under an explicit stencil is an error naming itself, never a
|
||||
// silently truncated or silently wrong trajectory.
|
||||
//
|
||||
// # Initial value problems
|
||||
//
|
||||
// IntegrateODE is the adaptive workhorse (an embedded Dormand-Prince
|
||||
// 4(5) pair); IntegrateRK4 is the classical fixed-step scheme;
|
||||
// IntegrateBackwardEuler, IntegrateBDF2, IntegrateBDFVar and
|
||||
// IntegrateROS4 cover the stiff regime, from the entry-level implicit
|
||||
// Euler to variable-order BDF and an L-stable Rosenbrock-Wanner method.
|
||||
// IntegrateODEPath samples the trajectory on an even time grid,
|
||||
// IntegrateODESteps records every accepted step, and IntegrateODEEvents
|
||||
// additionally reports where a list of watches crosses zero, filtered by
|
||||
// direction. IntegrateDAE takes the semi-explicit index-1 mass-matrix
|
||||
// form M·y' = f(t, y). Backward integration works throughout: a t1 < t0
|
||||
// integrates in the negative direction.
|
||||
//
|
||||
// # Beyond the initial value problem
|
||||
//
|
||||
// IntegrateBoundary shoots a two-point boundary value problem, choosing
|
||||
// the free initial components so the trajectory lands on the prescribed
|
||||
// end values; SolveBoundaryCollocation solves the same problem by
|
||||
// three-point Lobatto IIIA collocation on an adaptively refined mesh and
|
||||
// returns the mesh with the nodal states and slopes. IntegrateVerlet and
|
||||
// IntegrateYoshida4 integrate a separable Hamiltonian system at a fixed
|
||||
// step, and IntegrateMidpoint does the same for a general, non-separable
|
||||
// one through an implicit stage.
|
||||
//
|
||||
// # Quadrature and cubature
|
||||
//
|
||||
// IntegrateFunction integrates a scalar function over a finite or
|
||||
// infinite interval and reports an estimate of its own absolute error;
|
||||
// GaussLegendreNodes hands out the nodes and weights of a fixed rule.
|
||||
// IntegrateFilon integrates a smooth amplitude against a high-frequency
|
||||
// sine or cosine carrier, whose cost tracks the amplitude alone rather
|
||||
// than the carrier a sampled rule must resolve. IntegrateND integrates
|
||||
// over a hyperrectangle by globally adaptive bisection with product
|
||||
// Gauss-Legendre rules.
|
||||
//
|
||||
// # PDE evolution
|
||||
//
|
||||
// IntegrateHeat1D, IntegrateWave1D, IntegrateUpwindAdvection1D,
|
||||
// IntegrateAdvection1D and IntegrateAdvectionDiffusion1D run on the
|
||||
// interior grid of a rank-1 initial state, while IntegrateHeat2D and
|
||||
// IntegrateWave2D run on the rank-2 grid of a rectangle. All of them
|
||||
// return the trajectory sampled on a time grid, endpoints included.
|
||||
//
|
||||
// # Finite elements
|
||||
//
|
||||
// GridTriangleMesh2D and BoxTetraMesh3D build structured meshes;
|
||||
// NewTriangleMesh2D and NewTetraMesh3D accept general conforming ones,
|
||||
// refusing degenerate elements. SolvePoissonFEM2D and SolvePoissonFEM3D
|
||||
// assemble and solve -∇·(κ∇u) = f with P1 elements, a conductivity that
|
||||
// may vary in space, Dirichlet values eliminated by lifting and Neumann
|
||||
// fluxes integrated on prescribed boundary edges or faces.
|
||||
//
|
||||
// # What it deliberately does not do
|
||||
//
|
||||
// There is no dense-output object: IntegrateODEPath and
|
||||
// IntegrateODESteps return the states a caller asked for, and event
|
||||
// times are narrowed by re-integrating the accepted step rather than
|
||||
// through a continuous extension. The symplectic family takes a fixed
|
||||
// step by design, because adaptivity would destroy the property the
|
||||
// methods exist for. IntegrateDAE is first order and does not project an
|
||||
// inconsistent start onto the constraint manifold; consistent initial
|
||||
// values are the caller's contract. The finite element surface is P1 on
|
||||
// conforming meshes only, and the collocation solver factors a dense
|
||||
// Newton matrix, so its mesh size is bounded by CollocationOptions. The
|
||||
// gradient of a trajectory with respect to its parameters is the grad
|
||||
// package's to compute.
|
||||
//
|
||||
// A tour:
|
||||
//
|
||||
// end, _ := integrate.IntegrateODE(f, 0, 1, y0, integrate.ODEOptions{})
|
||||
// hits, end, _ := integrate.IntegrateODEEvents(f, 0, 5, y0, watches, integrate.ODEOptions{})
|
||||
// area, _ := integrate.IntegrateFunction(g, 0, 1, integrate.QuadratureOptions{})
|
||||
// history, _ := integrate.IntegrateHeat1D(u0, 1, dx, 0.1, 1e-4, 5, 0, 0)
|
||||
// u, _ := integrate.SolvePoissonFEM2D(mesh, f, opts) // opts carries κ and the Dirichlet set
|
||||
//
|
||||
// The examples in this documentation are executable and checked by the
|
||||
// test suite.
|
||||
package integrate
|
||||
@@ -0,0 +1,536 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// The dtype census for integrate: the ODE and PDE drivers' state
|
||||
// arrays, the FEM mesh tables and the symplectic family, probed with
|
||||
// Bool, the narrow integers and the Int anchor against a float64
|
||||
// baseline carrying exactly the widened probe values. The ODE/PDE
|
||||
// state arrays widen through accessor walks by design, so the narrow
|
||||
// widths follow Int bit for bit; the symplectic family refuses every
|
||||
// integer-class state by name, narrow widths included, exactly as it
|
||||
// refuses Int; the mesh connectivity table keeps its standing Int-only
|
||||
// gate. Nothing panics or silently misreads.
|
||||
|
||||
var igDtypes = []core.Dtype{core.Bool, core.Int8, core.Uint8, core.Int16, core.Uint16, core.Int32, core.Uint32, core.Int}
|
||||
|
||||
type igMaker func(vals []float64, shape ...int) *core.Array
|
||||
|
||||
func igCast(dt core.Dtype, v float64) float64 {
|
||||
switch dt {
|
||||
case core.Bool:
|
||||
if v != 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case core.Int8:
|
||||
return float64(int8(int64(v)))
|
||||
case core.Uint8:
|
||||
return float64(uint8(int64(v)))
|
||||
case core.Int16:
|
||||
return float64(int16(int64(v)))
|
||||
case core.Uint16:
|
||||
return float64(uint16(int64(v)))
|
||||
case core.Int32:
|
||||
return float64(int32(int64(v)))
|
||||
case core.Uint32:
|
||||
return float64(uint32(int64(v)))
|
||||
case core.Int:
|
||||
return float64(int64(v))
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func igMakers(t *testing.T, dt core.Dtype) (probe, base igMaker) {
|
||||
t.Helper()
|
||||
castOf := func(vals []float64) []float64 {
|
||||
out := make([]float64, len(vals))
|
||||
for i, v := range vals {
|
||||
out[i] = igCast(dt, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
probe = func(vals []float64, shape ...int) *core.Array {
|
||||
cast := castOf(vals)
|
||||
var a *core.Array
|
||||
var err error
|
||||
switch dt {
|
||||
case core.Bool:
|
||||
bs := make([]bool, len(cast))
|
||||
for i, v := range cast {
|
||||
bs[i] = v != 0
|
||||
}
|
||||
a, err = core.FromBools(bs, shape...)
|
||||
case core.Int8:
|
||||
vs := make([]int8, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = int8(int64(v))
|
||||
}
|
||||
a, err = core.FromInt8s(vs, shape...)
|
||||
case core.Uint8:
|
||||
vs := make([]uint8, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = uint8(int64(v))
|
||||
}
|
||||
a, err = core.FromUint8s(vs, shape...)
|
||||
case core.Int16:
|
||||
vs := make([]int16, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = int16(int64(v))
|
||||
}
|
||||
a, err = core.FromInt16s(vs, shape...)
|
||||
case core.Uint16:
|
||||
vs := make([]uint16, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = uint16(int64(v))
|
||||
}
|
||||
a, err = core.FromUint16s(vs, shape...)
|
||||
case core.Int32:
|
||||
vs := make([]int32, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = int32(int64(v))
|
||||
}
|
||||
a, err = core.FromInt32s(vs, shape...)
|
||||
case core.Uint32:
|
||||
vs := make([]uint32, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = uint32(int64(v))
|
||||
}
|
||||
a, err = core.FromUint32s(vs, shape...)
|
||||
case core.Int:
|
||||
vs := make([]int64, len(cast))
|
||||
for i, v := range cast {
|
||||
vs[i] = int64(v)
|
||||
}
|
||||
a, err = core.FromInts(vs, shape...)
|
||||
default:
|
||||
a, err = core.FromFloats(cast, shape...)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("probe maker (%s): %v", dt, err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
base = func(vals []float64, shape ...int) *core.Array {
|
||||
a, err := core.FromFloats(castOf(vals), shape...)
|
||||
if err != nil {
|
||||
t.Fatalf("baseline maker: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
return probe, base
|
||||
}
|
||||
|
||||
func igElems(t *testing.T, a *core.Array) []float64 {
|
||||
t.Helper()
|
||||
out := make([]float64, a.Len())
|
||||
for i := range out {
|
||||
out[i] = a.FloatAt(i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func igArrays(t *testing.T, label string, dt core.Dtype, probe []*core.Array, perr error, base []*core.Array, berr error) {
|
||||
t.Helper()
|
||||
if berr != nil {
|
||||
if perr == nil {
|
||||
t.Fatalf("%s(%s): probe succeeded but the float baseline of the same values failed with %v", label, dt, berr)
|
||||
}
|
||||
if perr.Error() != berr.Error() {
|
||||
t.Fatalf("%s(%s): probe error %q differs from the baseline error %q", label, dt, perr, berr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if perr != nil {
|
||||
t.Fatalf("%s(%s): %v; the float baseline of the same values succeeded", label, dt, perr)
|
||||
}
|
||||
if len(probe) != len(base) {
|
||||
t.Fatalf("%s(%s): %d outputs against the baseline's %d", label, dt, len(probe), len(base))
|
||||
}
|
||||
for k := range probe {
|
||||
p, b := probe[k], base[k]
|
||||
if p == nil || b == nil {
|
||||
t.Fatalf("%s(%s): output %d nil (probe %v, base %v)", label, dt, k, p, b)
|
||||
}
|
||||
if p.Dtype() != b.Dtype() {
|
||||
t.Fatalf("%s(%s): output %d dtype %s, want the baseline dtype %s", label, dt, k, p.Dtype(), b.Dtype())
|
||||
}
|
||||
if p.Len() != b.Len() {
|
||||
t.Fatalf("%s(%s): output %d length %d, want %d", label, dt, k, p.Len(), b.Len())
|
||||
}
|
||||
pv, bv := igElems(t, p), igElems(t, b)
|
||||
for i := range pv {
|
||||
if pv[i] != bv[i] {
|
||||
t.Fatalf("%s(%s): output %d element %d = %v, want %v", label, dt, k, i, pv[i], bv[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func igFloats(t *testing.T, label string, dt core.Dtype, pv []float64, perr error, bv []float64, berr error) {
|
||||
t.Helper()
|
||||
if berr != nil {
|
||||
if perr == nil || perr.Error() != berr.Error() {
|
||||
t.Fatalf("%s(%s): probe error %v, want the baseline error %v", label, dt, perr, berr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if perr != nil {
|
||||
t.Fatalf("%s(%s): %v; the float baseline succeeded", label, dt, perr)
|
||||
}
|
||||
if len(pv) != len(bv) {
|
||||
t.Fatalf("%s(%s): %d values, want %d", label, dt, len(pv), len(bv))
|
||||
}
|
||||
for i := range pv {
|
||||
if pv[i] != bv[i] {
|
||||
t.Fatalf("%s(%s): value %d = %v, want %v", label, dt, i, pv[i], bv[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func igWantErr(t *testing.T, label string, err error, frags ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("%s: accepted; want a refusal carrying %v", label, frags)
|
||||
}
|
||||
for _, f := range frags {
|
||||
if !strings.Contains(err.Error(), f) {
|
||||
t.Fatalf("%s: error %q does not contain %q", label, err, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// igDecay is the ODE right-hand side the driver rows integrate: the
|
||||
// callback receives the solver's own float64 state views whatever the
|
||||
// caller's y0 dtype was.
|
||||
func igDecay(t float64, y *core.Array) (*core.Array, error) {
|
||||
out := make([]float64, y.Len())
|
||||
for i := range out {
|
||||
out[i] = -y.FloatAt(i)
|
||||
}
|
||||
return core.FromFloats(out, len(out))
|
||||
}
|
||||
|
||||
// TestDtypesCensusIntegrate probes every array-taking public entry.
|
||||
func TestDtypesCensusIntegrate(t *testing.T) {
|
||||
y0 := []float64{1, 2}
|
||||
u0 := []float64{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
u09 := []float64{1, 2, 3, 2, 4, 3, 3, 2, 1}
|
||||
rows := []struct {
|
||||
name string
|
||||
run func(t *testing.T, probe, base igMaker, dt core.Dtype)
|
||||
}{
|
||||
{"IntegrateODE", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateODE(igDecay, 0, 1, probe(y0, 2), ODEOptions{})
|
||||
b, berr := IntegrateODE(igDecay, 0, 1, base(y0, 2), ODEOptions{})
|
||||
igArrays(t, "IntegrateODE", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateODEPath", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
pt, ps, perr := IntegrateODEPath(igDecay, 0, 1, probe(y0, 2), 4, ODEOptions{})
|
||||
bt, bs, berr := IntegrateODEPath(igDecay, 0, 1, base(y0, 2), 4, ODEOptions{})
|
||||
if berr != nil || perr != nil {
|
||||
igArrays(t, "IntegrateODEPath", dt, nil, perr, nil, berr)
|
||||
return
|
||||
}
|
||||
igFloats(t, "IntegrateODEPath times", dt, pt, nil, bt, nil)
|
||||
igArrays(t, "IntegrateODEPath states", dt, ps, nil, bs, nil)
|
||||
}},
|
||||
{"IntegrateODESteps", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
pt, ps, perr := IntegrateODESteps(igDecay, 0, 1, probe(y0, 2), ODEOptions{})
|
||||
bt, bs, berr := IntegrateODESteps(igDecay, 0, 1, base(y0, 2), ODEOptions{})
|
||||
if berr != nil || perr != nil {
|
||||
igArrays(t, "IntegrateODESteps", dt, nil, perr, nil, berr)
|
||||
return
|
||||
}
|
||||
igFloats(t, "IntegrateODESteps times", dt, pt, nil, bt, nil)
|
||||
igArrays(t, "IntegrateODESteps states", dt, ps, nil, bs, nil)
|
||||
}},
|
||||
{"IntegrateRK4", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateRK4(igDecay, 0, 1, probe(y0, 2), 10)
|
||||
b, berr := IntegrateRK4(igDecay, 0, 1, base(y0, 2), 10)
|
||||
igArrays(t, "IntegrateRK4", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateBackwardEuler", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateBackwardEuler(igDecay, 0, 1, probe(y0, 2), 10, ODEOptions{})
|
||||
b, berr := IntegrateBackwardEuler(igDecay, 0, 1, base(y0, 2), 10, ODEOptions{})
|
||||
igArrays(t, "IntegrateBackwardEuler", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateBDF2", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateBDF2(igDecay, 0, 1, probe(y0, 2), ODEOptions{})
|
||||
b, berr := IntegrateBDF2(igDecay, 0, 1, base(y0, 2), ODEOptions{})
|
||||
igArrays(t, "IntegrateBDF2", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateBDFVar", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateBDFVar(igDecay, 0, 1, probe(y0, 2), BDFVarOptions{})
|
||||
b, berr := IntegrateBDFVar(igDecay, 0, 1, base(y0, 2), BDFVarOptions{})
|
||||
igArrays(t, "IntegrateBDFVar", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateROS4", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateROS4(igDecay, 0, 1, probe(y0, 2), ODEOptions{})
|
||||
b, berr := IntegrateROS4(igDecay, 0, 1, base(y0, 2), ODEOptions{})
|
||||
igArrays(t, "IntegrateROS4", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateDAE", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
daeF := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1)}, 2)
|
||||
}
|
||||
p, perr := IntegrateDAE(daeF, probe([]float64{1, 0, 0, 0}, 2, 2), 0, 1, probe([]float64{1, 0}, 2), 5, DAEOptions{})
|
||||
b, berr := IntegrateDAE(daeF, base([]float64{1, 0, 0, 0}, 2, 2), 0, 1, base([]float64{1, 0}, 2), 5, DAEOptions{})
|
||||
igArrays(t, "IntegrateDAE", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateODEEvents", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
watch := ODEWatch{Function: func(t float64, y *core.Array) (float64, error) {
|
||||
return y.FloatAt(0) - 0.5, nil
|
||||
}}
|
||||
ph, pf, perr := IntegrateODEEvents(igDecay, 0, 1, probe(y0, 2), []ODEWatch{watch}, ODEOptions{})
|
||||
bh, bf, berr := IntegrateODEEvents(igDecay, 0, 1, base(y0, 2), []ODEWatch{watch}, ODEOptions{})
|
||||
if berr != nil || perr != nil {
|
||||
igArrays(t, "IntegrateODEEvents", dt, nil, perr, nil, berr)
|
||||
return
|
||||
}
|
||||
igArrays(t, "IntegrateODEEvents final", dt, []*core.Array{pf}, nil, []*core.Array{bf}, nil)
|
||||
if len(ph) != len(bh) {
|
||||
t.Fatalf("IntegrateODEEvents(%s): %d hits, want %d", dt, len(ph), len(bh))
|
||||
}
|
||||
for i := range ph {
|
||||
if ph[i].Time != bh[i].Time || ph[i].Rising != bh[i].Rising {
|
||||
t.Fatalf("IntegrateODEEvents(%s): hit %d = (%v, %v), want (%v, %v)",
|
||||
dt, i, ph[i].Time, ph[i].Rising, bh[i].Time, bh[i].Rising)
|
||||
}
|
||||
}
|
||||
}},
|
||||
{"IntegrateBoundary", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
osc := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
bc := BoundaryConditions{Start: []int{0}, End: []int{1}, EndValues: []float64{3}}
|
||||
pt, ps, perr := IntegrateBoundary(osc, 0, 1, probe([]float64{2, 1}, 2), bc, 4, ODEOptions{})
|
||||
bt, bs, berr := IntegrateBoundary(osc, 0, 1, base([]float64{2, 1}, 2), bc, 4, ODEOptions{})
|
||||
if berr != nil || perr != nil {
|
||||
igArrays(t, "IntegrateBoundary", dt, nil, perr, nil, berr)
|
||||
return
|
||||
}
|
||||
igFloats(t, "IntegrateBoundary times", dt, pt, nil, bt, nil)
|
||||
igArrays(t, "IntegrateBoundary states", dt, ps, nil, bs, nil)
|
||||
}},
|
||||
{"SolveBoundaryCollocation", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
osc := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
bc := BoundaryConditions{Start: []int{0}, End: []int{1}, EndValues: []float64{3}}
|
||||
ps, perr := SolveBoundaryCollocation(osc, 0, 1, probe([]float64{2, 1}, 2), bc, CollocationOptions{})
|
||||
bs, berr := SolveBoundaryCollocation(osc, 0, 1, base([]float64{2, 1}, 2), bc, CollocationOptions{})
|
||||
if berr != nil || perr != nil {
|
||||
igArrays(t, "SolveBoundaryCollocation", dt, nil, perr, nil, berr)
|
||||
return
|
||||
}
|
||||
igFloats(t, "collocation mesh", dt, ps.Mesh, nil, bs.Mesh, nil)
|
||||
igArrays(t, "collocation values", dt, ps.Values, nil, bs.Values, nil)
|
||||
}},
|
||||
{"IntegrateHeat1D", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateHeat1D(probe(u0, 8), 1.0, 0.1, 0.01, 0.001, 4, 0, 0)
|
||||
b, berr := IntegrateHeat1D(base(u0, 8), 1.0, 0.1, 0.01, 0.001, 4, 0, 0)
|
||||
igArrays(t, "IntegrateHeat1D", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateWave1D", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateWave1D(probe(u0, 8), probe(make([]float64, 8), 8), 0.5, 0.1, 0.01, 0.001, 4)
|
||||
b, berr := IntegrateWave1D(base(u0, 8), base(make([]float64, 8), 8), 0.5, 0.1, 0.01, 0.001, 4)
|
||||
igArrays(t, "IntegrateWave1D", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateHeat2D", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateHeat2D(probe(u09, 3, 3), 1.0, 0.5, 0.5, 0.01, 0.001, 3, 0, 0, 0, 0)
|
||||
b, berr := IntegrateHeat2D(base(u09, 3, 3), 1.0, 0.5, 0.5, 0.01, 0.001, 3, 0, 0, 0, 0)
|
||||
igArrays(t, "IntegrateHeat2D", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateWave2D", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateWave2D(probe(u09, 3, 3), probe(make([]float64, 9), 3, 3), 0.5, 0.5, 0.5, 0.01, 0.002, 3)
|
||||
b, berr := IntegrateWave2D(base(u09, 3, 3), base(make([]float64, 9), 3, 3), 0.5, 0.5, 0.5, 0.01, 0.002, 3)
|
||||
igArrays(t, "IntegrateWave2D", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateAdvection1D", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateAdvection1D(probe(u0, 8), 0.5, 0.1, 0.01, 0.002, 3, 0, 0)
|
||||
b, berr := IntegrateAdvection1D(base(u0, 8), 0.5, 0.1, 0.01, 0.002, 3, 0, 0)
|
||||
igArrays(t, "IntegrateAdvection1D", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateUpwindAdvection1D", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateUpwindAdvection1D(probe(u0, 8), 0.5, 0.1, 0.01, 0.002, 3, 0, 0)
|
||||
b, berr := IntegrateUpwindAdvection1D(base(u0, 8), 0.5, 0.1, 0.01, 0.002, 3, 0, 0)
|
||||
igArrays(t, "IntegrateUpwindAdvection1D", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
{"IntegrateAdvectionDiffusion1D", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
p, perr := IntegrateAdvectionDiffusion1D(probe(u0, 8), 0.5, 0.1, 0.1, 0.01, 0.002, 3, 0, 0)
|
||||
b, berr := IntegrateAdvectionDiffusion1D(base(u0, 8), 0.5, 0.1, 0.1, 0.01, 0.002, 3, 0, 0)
|
||||
igArrays(t, "IntegrateAdvectionDiffusion1D", dt, []*core.Array{p}, perr, []*core.Array{b}, berr)
|
||||
}},
|
||||
// The symplectic family refuses integer-class states by name:
|
||||
// the narrow widths and bool follow Int into the standing
|
||||
// refusal exactly, the wording unchanged.
|
||||
{"IntegrateVerlet state gate", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
harmonic := func(q *core.Array) (*core.Array, error) { return core.MulF(q, -1), nil }
|
||||
_, _, perr := IntegrateVerlet(harmonic, 0, 1, probe([]float64{1}, 1), probe([]float64{0}, 1), 5)
|
||||
igWantErr(t, "IntegrateVerlet/"+dt.String(), perr,
|
||||
"IntegrateVerlet", "int states cannot integrate", "float or float32")
|
||||
}},
|
||||
{"IntegrateYoshida4 state gate", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
harmonic := func(q *core.Array) (*core.Array, error) { return core.MulF(q, -1), nil }
|
||||
_, _, perr := IntegrateYoshida4(harmonic, 0, 1, probe([]float64{1}, 1), probe([]float64{0}, 1), 5)
|
||||
igWantErr(t, "IntegrateYoshida4/"+dt.String(), perr,
|
||||
"IntegrateYoshida4", "int states cannot integrate", "float or float32")
|
||||
}},
|
||||
{"IntegrateMidpoint state gate", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
gradH := func(z *core.Array) (*core.Array, error) {
|
||||
out := make([]float64, z.Len())
|
||||
for i := range out {
|
||||
out[i] = z.FloatAt(i)
|
||||
}
|
||||
return core.FromFloats(out, len(out))
|
||||
}
|
||||
_, _, perr := IntegrateMidpoint(gradH, 0, 1, probe([]float64{1}, 1), probe([]float64{0}, 1), 5, MidpointOptions{})
|
||||
igWantErr(t, "IntegrateMidpoint/"+dt.String(), perr,
|
||||
"IntegrateMidpoint", "int states cannot integrate", "float or float32")
|
||||
}},
|
||||
// The callback surface: an acceleration that answers a narrow
|
||||
// array is read through the accessors exactly as an Int one
|
||||
// is, with float states the family computes bit-identically.
|
||||
{"IntegrateVerlet narrow accel output", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
return probe([]float64{-q.FloatAt(0)}, 1), nil
|
||||
}
|
||||
baseAccel := func(q *core.Array) (*core.Array, error) {
|
||||
return base([]float64{-q.FloatAt(0)}, 1), nil
|
||||
}
|
||||
q0f, _ := core.FromFloats([]float64{1}, 1)
|
||||
p0f, _ := core.FromFloats([]float64{0}, 1)
|
||||
pp, pm, perr := IntegrateVerlet(accel, 0, 1, q0f, p0f, 20)
|
||||
bp, bm, berr := IntegrateVerlet(baseAccel, 0, 1, q0f, p0f, 20)
|
||||
if berr != nil || perr != nil {
|
||||
igArrays(t, "Verlet accel output", dt, nil, perr, nil, berr)
|
||||
return
|
||||
}
|
||||
igArrays(t, "Verlet accel positions", dt, pp, nil, bp, nil)
|
||||
igArrays(t, "Verlet accel momenta", dt, pm, nil, bm, nil)
|
||||
}},
|
||||
// FEM mesh tables: vertices widen through accessors (narrow
|
||||
// follows Int), connectivity keeps the standing Int-only gate.
|
||||
{"NewTriangleMesh2D vertices widen", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
verts := []float64{0, 0, 1, 0, 0, 1}
|
||||
tri, terr := core.FromInts([]int64{0, 1, 2}, 1, 3)
|
||||
if terr != nil {
|
||||
t.Fatal(terr)
|
||||
}
|
||||
p, perr := NewTriangleMesh2D(probe(verts, 3, 2), tri)
|
||||
b, berr := NewTriangleMesh2D(base(verts, 3, 2), tri)
|
||||
if berr != nil || perr != nil {
|
||||
igArrays(t, "NewTriangleMesh2D vertices", dt, nil, perr, nil, berr)
|
||||
return
|
||||
}
|
||||
igFloats(t, "TriangleMesh2D vertices", dt, p.Vertices, nil, b.Vertices, nil)
|
||||
for i := range p.Triangles {
|
||||
if p.Triangles[i] != b.Triangles[i] {
|
||||
t.Fatalf("TriangleMesh2D(%s): triangle %d = %d, want %d", dt, i, p.Triangles[i], b.Triangles[i])
|
||||
}
|
||||
}
|
||||
}},
|
||||
{"NewTriangleMesh2D connectivity gate", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
if dt == core.Int {
|
||||
// Int connectivity computes; the gate row covers the
|
||||
// narrow widths and bool.
|
||||
return
|
||||
}
|
||||
verts, verr := core.FromFloats([]float64{0, 0, 1, 0, 0, 1}, 3, 2)
|
||||
if verr != nil {
|
||||
t.Fatal(verr)
|
||||
}
|
||||
_, perr := NewTriangleMesh2D(verts, probe([]float64{0, 1, 2}, 1, 3))
|
||||
igWantErr(t, "NewTriangleMesh2D/"+dt.String(), perr,
|
||||
"NewTriangleMesh2D", "the triangle table must hold integers", dt.String())
|
||||
}},
|
||||
{"NewTetraMesh3D vertices widen", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
verts := []float64{0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1}
|
||||
tet, terr := core.FromInts([]int64{0, 1, 2, 3}, 1, 4)
|
||||
if terr != nil {
|
||||
t.Fatal(terr)
|
||||
}
|
||||
p, perr := NewTetraMesh3D(probe(verts, 4, 3), tet)
|
||||
b, berr := NewTetraMesh3D(base(verts, 4, 3), tet)
|
||||
if berr != nil || perr != nil {
|
||||
igArrays(t, "NewTetraMesh3D vertices", dt, nil, perr, nil, berr)
|
||||
return
|
||||
}
|
||||
igFloats(t, "TetraMesh3D vertices", dt, p.Vertices, nil, b.Vertices, nil)
|
||||
}},
|
||||
{"NewTetraMesh3D connectivity gate", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
if dt == core.Int {
|
||||
return
|
||||
}
|
||||
verts, verr := core.FromFloats([]float64{0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1}, 4, 3)
|
||||
if verr != nil {
|
||||
t.Fatal(verr)
|
||||
}
|
||||
_, perr := NewTetraMesh3D(verts, probe([]float64{0, 1, 2, 3}, 1, 4))
|
||||
igWantErr(t, "NewTetraMesh3D/"+dt.String(), perr,
|
||||
"NewTetraMesh3D", "the tetrahedron table must hold integers", dt.String())
|
||||
}},
|
||||
// The FEM Poisson solvers take no arrays directly: the mesh
|
||||
// constructor widens the vertex table and gates the connectivity
|
||||
// table, so a mesh whose vertices carried any probe dtype
|
||||
// computes identically to the float baseline's.
|
||||
{"SolvePoissonFEM2D narrow vertices", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
verts := []float64{0, 0, 1, 0, 0, 1}
|
||||
tri, terr := core.FromInts([]int64{0, 1, 2}, 1, 3)
|
||||
if terr != nil {
|
||||
t.Fatal(terr)
|
||||
}
|
||||
opts := FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}}
|
||||
unity := func(x, y float64) float64 { return 1 }
|
||||
pm, merr := NewTriangleMesh2D(probe(verts, 3, 2), tri)
|
||||
if merr != nil {
|
||||
t.Fatalf("NewTriangleMesh2D probe (%s): %v", dt, merr)
|
||||
}
|
||||
bm, berr := NewTriangleMesh2D(base(verts, 3, 2), tri)
|
||||
if berr != nil {
|
||||
t.Fatalf("NewTriangleMesh2D baseline: %v", berr)
|
||||
}
|
||||
p, perr := SolvePoissonFEM2D(pm, unity, opts)
|
||||
b, serr := SolvePoissonFEM2D(bm, unity, opts)
|
||||
igArrays(t, "SolvePoissonFEM2D", dt, []*core.Array{p}, perr, []*core.Array{b}, serr)
|
||||
}},
|
||||
{"SolvePoissonFEM3D narrow vertices", func(t *testing.T, probe, base igMaker, dt core.Dtype) {
|
||||
verts := []float64{0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1}
|
||||
tet, terr := core.FromInts([]int64{0, 1, 2, 3}, 1, 4)
|
||||
if terr != nil {
|
||||
t.Fatal(terr)
|
||||
}
|
||||
opts := FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}}
|
||||
unity := func(x, y, z float64) float64 { return 1 }
|
||||
pm, merr := NewTetraMesh3D(probe(verts, 4, 3), tet)
|
||||
if merr != nil {
|
||||
t.Fatalf("NewTetraMesh3D probe (%s): %v", dt, merr)
|
||||
}
|
||||
bm, berr := NewTetraMesh3D(base(verts, 4, 3), tet)
|
||||
if berr != nil {
|
||||
t.Fatalf("NewTetraMesh3D baseline: %v", berr)
|
||||
}
|
||||
p, perr := SolvePoissonFEM3D(pm, unity, opts)
|
||||
b, serr := SolvePoissonFEM3D(bm, unity, opts)
|
||||
igArrays(t, "SolvePoissonFEM3D", dt, []*core.Array{p}, perr, []*core.Array{b}, serr)
|
||||
}},
|
||||
}
|
||||
for _, row := range rows {
|
||||
for _, dt := range igDtypes {
|
||||
t.Run(row.name+"/"+dt.String(), func(t *testing.T) {
|
||||
probe, base := igMakers(t, dt)
|
||||
row.run(t, probe, base, dt)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Regression pins for the event and boundary contracts: a watch landing
|
||||
// exactly on the final boundary, backward event search, the RK4 and
|
||||
// Verlet refusals of non-finite states, the grid mesh origin screen and
|
||||
// the cubature budget's true cost.
|
||||
|
||||
// TestEventExactlyOnFinalBoundary pins the hit a watch landing
|
||||
// exactly on zero at the final accepted boundary produces, which the
|
||||
// sign walk used to swallow.
|
||||
func TestEventExactlyOnFinalBoundary(t *testing.T) {
|
||||
f := func(_ float64, y *core.Array) (*core.Array, error) {
|
||||
return mustFloats(t, []float64{1}, 1), nil
|
||||
}
|
||||
y0 := mustFloats(t, []float64{0}, 1)
|
||||
watch := ODEWatch{
|
||||
Function: func(tt float64, _ *core.Array) (float64, error) { return tt - 1, nil },
|
||||
Direction: 1,
|
||||
}
|
||||
hits, _, err := IntegrateODEEvents(f, 0, 1, y0, []ODEWatch{watch}, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODEEvents: %v", err)
|
||||
}
|
||||
if len(hits) != 1 || !hits[0].Rising || math.Abs(hits[0].Time-1) > 1e-12 {
|
||||
t.Fatalf("hits = %+v, want one rising hit at t = 1", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventsBackward pins the event machinery in the backward
|
||||
// direction: the watch g = t − 0.5 falls through zero at t = 0.5, and
|
||||
// the backward run must report that hit with the time refined to the
|
||||
// integrator's accuracy.
|
||||
func TestEventsBackward(t *testing.T) {
|
||||
f := func(_ float64, y *core.Array) (*core.Array, error) {
|
||||
return mustFloats(t, []float64{1}, 1), nil
|
||||
}
|
||||
y0 := mustFloats(t, []float64{0}, 1)
|
||||
watch := ODEWatch{
|
||||
Function: func(tt float64, _ *core.Array) (float64, error) { return tt - 0.5, nil },
|
||||
Direction: -1,
|
||||
}
|
||||
hits, _, err := IntegrateODEEvents(f, 1, 0, y0, []ODEWatch{watch}, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODEEvents backward: %v", err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Rising {
|
||||
t.Fatalf("hits = %+v, want one falling hit", hits)
|
||||
}
|
||||
if math.Abs(hits[0].Time-0.5) > 1e-9 {
|
||||
t.Fatalf("hit time = %g, want 0.5", hits[0].Time)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRK4AndVerletRefuseNonFinite pins the loud refusals on
|
||||
// the fixed-step integrators, which published NaN states with nil
|
||||
// errors before.
|
||||
func TestRK4AndVerletRefuseNonFinite(t *testing.T) {
|
||||
bad := func(_ float64, _ *core.Array) (*core.Array, error) {
|
||||
return mustFloats(t, []float64{math.NaN()}, 1), nil
|
||||
}
|
||||
y0 := mustFloats(t, []float64{0}, 1)
|
||||
if _, err := IntegrateRK4(bad, 0, 1, y0, 4); err == nil {
|
||||
t.Fatal("IntegrateRK4: expected an error for a NaN derivative")
|
||||
}
|
||||
accel := func(_ *core.Array) (*core.Array, error) {
|
||||
return mustFloats(t, []float64{math.Inf(1)}, 1), nil
|
||||
}
|
||||
q0 := mustFloats(t, []float64{0}, 1)
|
||||
p0 := mustFloats(t, []float64{1}, 1)
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 1, q0, p0, 4); err == nil {
|
||||
t.Fatal("IntegrateVerlet: expected an error for an Inf acceleration")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGridMeshRejectsNonFiniteOrigin pins the origin guard.
|
||||
func TestGridMeshRejectsNonFiniteOrigin(t *testing.T) {
|
||||
if _, err := GridTriangleMesh2D(math.NaN(), 0, 1, 1, 2, 2); err == nil {
|
||||
t.Fatal("GridTriangleMesh2D: expected an error for a NaN origin")
|
||||
}
|
||||
if _, err := GridTriangleMesh2D(0, math.Inf(1), 1, 1, 2, 2); err == nil {
|
||||
t.Fatal("GridTriangleMesh2D: expected an error for an Inf origin")
|
||||
}
|
||||
// An empty triangle table is refused at construction.
|
||||
v, _ := core.FromFloats([]float64{0, 0, 1, 0, 0, 1}, 3, 2)
|
||||
tri, _ := core.FromInts([]int64{}, 0, 3)
|
||||
if _, err := NewTriangleMesh2D(v, tri); err == nil {
|
||||
t.Fatal("NewTriangleMesh2D: expected an error for an empty triangle table")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCubatureBudgetAccountsTrueCost pins the true bisection
|
||||
// cost 2·(5^d + 3^d): a budget that admits the root box and exactly
|
||||
// one bisection must complete, and the dimension guard still refuses
|
||||
// the twenties under any budget.
|
||||
func TestCubatureBudgetAccountsTrueCost(t *testing.T) {
|
||||
f := func(x []float64) float64 { return x[0] * x[0] }
|
||||
lower := []float64{0}
|
||||
upper := []float64{2}
|
||||
// Root box: 5 + 3 = 8; one bisection: 2·8 = 16. A budget of 24
|
||||
// admits the box and one bisection; the old 8^1 + 6^1 = 14
|
||||
// accounting let the loop overshoot it by two evaluations.
|
||||
if _, err := IntegrateND(f, lower, upper, CubatureOptions{MaxEvals: 24}); err != nil && !strings.Contains(err.Error(), "converge") {
|
||||
t.Fatalf("budget 24: err = %v", err)
|
||||
}
|
||||
lower25 := make([]float64, 25)
|
||||
upper25 := make([]float64, 25)
|
||||
for i := range upper25 {
|
||||
upper25[i] = 1
|
||||
}
|
||||
one := func([]float64) float64 { return 1 }
|
||||
if _, err := IntegrateND(one, lower25, upper25, CubatureOptions{MaxEvals: math.MaxInt}); err == nil || !strings.Contains(err.Error(), "budget") {
|
||||
t.Fatalf("d = 25 under a MaxInt budget: err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Regression pins: the event detector's blind first step, watch
|
||||
// values that signed themselves across zero, PDE parameters that were
|
||||
// only half guarded, the step recorder's rounded endpoint, and a
|
||||
// cubature budget the dimension powers could switch off.
|
||||
|
||||
// TestEventFirstAcceptedStep: the detector seeded its comparison from
|
||||
// the END of the first accepted step, so a crossing inside that step
|
||||
// went unnoticed; the seed is now the watch value at the step's start
|
||||
// state, and the crossing is refined like any other.
|
||||
func TestEventFirstAcceptedStep(t *testing.T) {
|
||||
zero := func(now float64, y *core.Array) (*core.Array, error) {
|
||||
return mustFloats(t, []float64{0}, 1), nil
|
||||
}
|
||||
cross := func(now float64, y *core.Array) (float64, error) {
|
||||
return now - 0.0005, nil
|
||||
}
|
||||
hits, _, err := IntegrateODEEvents(zero, 0, 1, mustFloats(t, []float64{1}, 1),
|
||||
[]ODEWatch{{Function: cross}}, ODEOptions{RelTol: 1e-12, AbsTol: 1e-14})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODEEvents: %v", err)
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("hits = %d, want exactly the crossing near 0.0005", len(hits))
|
||||
}
|
||||
if math.Abs(hits[0].Time-0.0005) > 1e-9 {
|
||||
t.Fatalf("hit at %.14g, want 0.0005", hits[0].Time)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventWatchNonFiniteRefused: a NaN from a watch compared false in
|
||||
// the sign test and manufactured a crossing (or swallowed one); a
|
||||
// non-finite watch value is now an error naming the value.
|
||||
func TestEventWatchNonFiniteRefused(t *testing.T) {
|
||||
zero := func(now float64, y *core.Array) (*core.Array, error) {
|
||||
return mustFloats(t, []float64{0}, 1), nil
|
||||
}
|
||||
calls := 0
|
||||
nanFirst := func(now float64, y *core.Array) (float64, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return math.NaN(), nil
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
_, _, err := IntegrateODEEvents(zero, 0, 1, mustFloats(t, []float64{1}, 1),
|
||||
[]ODEWatch{{Function: nanFirst}}, ODEOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||||
t.Fatalf("a NaN watch value: err = %v", err)
|
||||
}
|
||||
infLater := func(now float64, y *core.Array) (float64, error) {
|
||||
if now > 0.2 {
|
||||
return math.Inf(-1), nil
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
_, _, err = IntegrateODEEvents(zero, 0, 1, mustFloats(t, []float64{1}, 1),
|
||||
[]ODEWatch{{Function: infLater}}, ODEOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "non-finite") {
|
||||
t.Fatalf("an infinite watch value: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGridMeshFiniteExtents: a NaN or Inf extent passed the old
|
||||
// positivity test (a NaN compares false against <= 0) and laid out a
|
||||
// mesh of non-finite vertices.
|
||||
func TestGridMeshFiniteExtents(t *testing.T) {
|
||||
for name, extents := range map[string][2]float64{
|
||||
"NaN width": {math.NaN(), 1},
|
||||
"NaN height": {1, math.NaN()},
|
||||
"Inf width": {math.Inf(1), 1},
|
||||
"Inf height": {1, math.Inf(-1)},
|
||||
} {
|
||||
mesh, err := GridTriangleMesh2D(0, 0, extents[0], extents[1], 2, 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "finite") {
|
||||
t.Fatalf("%s: err = %v, mesh = %v", name, err, mesh != nil)
|
||||
}
|
||||
}
|
||||
// A valid grid still builds.
|
||||
if _, err := GridTriangleMesh2D(0, 0, 1, 1, 2, 2); err != nil {
|
||||
t.Fatalf("a valid grid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFEMConstantKappaNonFinite: a +Inf constant conductivity slipped
|
||||
// through the positivity test and died mid-factorisation.
|
||||
func TestFEMConstantKappaNonFinite(t *testing.T) {
|
||||
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, 4, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("GridTriangleMesh2D: %v", err)
|
||||
}
|
||||
opts := FEMPoissonOptions{Kappa: math.Inf(1), DirichletNodes: []int{0}, DirichletValues: []float64{0}}
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, opts); err == nil || !strings.Contains(err.Error(), "positive") {
|
||||
t.Fatalf("a +Inf constant conductivity: err = %v", err)
|
||||
}
|
||||
// With the conductivity field set, a non-finite placeholder for
|
||||
// the constant is refused all the same.
|
||||
opts.KappaFunc = func(x, y float64) float64 { return 1 }
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, opts); err == nil || !strings.Contains(err.Error(), "positive") {
|
||||
t.Fatalf("a +Inf placeholder conductivity beside KappaFunc: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPDEParameterNonFiniteRefusals walks the solvers' numeric
|
||||
// parameters: each one used to slip a NaN or Inf past a comparison
|
||||
// that reads false against NaN and publish an all-NaN history.
|
||||
func TestPDEParameterNonFiniteRefusals(t *testing.T) {
|
||||
u1, v1 := mustFloats(t, []float64{0, 1, 0, 1, 0}, 5), mustFloats(t, []float64{0, 0, 0, 0, 0}, 5)
|
||||
u2 := mustFloats(t, []float64{0, 1, 0, 0, 1, 0, 0, 1, 0}, 3, 3)
|
||||
cases := []struct {
|
||||
name string
|
||||
run func() (*core.Array, error)
|
||||
}{
|
||||
{"Heat1D kappa +Inf", func() (*core.Array, error) {
|
||||
return IntegrateHeat1D(u1, math.Inf(1), 0.1, 0.1, 0.01, 2, 0, 0)
|
||||
}},
|
||||
{"Heat1D kappa NaN", func() (*core.Array, error) {
|
||||
return IntegrateHeat1D(u1, math.NaN(), 0.1, 0.1, 0.01, 2, 0, 0)
|
||||
}},
|
||||
{"Heat1D NaN bound", func() (*core.Array, error) {
|
||||
return IntegrateHeat1D(u1, 1, 0.1, 0.1, 0.01, 2, math.NaN(), 0)
|
||||
}},
|
||||
{"Heat1D Inf bound", func() (*core.Array, error) {
|
||||
return IntegrateHeat1D(u1, 1, 0.1, 0.1, 0.01, 2, 0, math.Inf(1))
|
||||
}},
|
||||
{"Wave1D c NaN", func() (*core.Array, error) {
|
||||
return IntegrateWave1D(u1, v1, math.NaN(), 0.1, 0.1, 0.01, 2)
|
||||
}},
|
||||
{"Wave1D c Inf", func() (*core.Array, error) {
|
||||
return IntegrateWave1D(u1, v1, math.Inf(1), 0.1, 0.1, 0.01, 2)
|
||||
}},
|
||||
{"Wave1D v0 NaN", func() (*core.Array, error) {
|
||||
return IntegrateWave1D(u1, mustFloats(t, []float64{0, math.NaN(), 0, 0, 0}, 5), 1, 0.1, 0.1, 0.01, 2)
|
||||
}},
|
||||
{"Heat2D kappa +Inf", func() (*core.Array, error) {
|
||||
return IntegrateHeat2D(u2, math.Inf(1), 0.1, 0.1, 0.1, 0.01, 2, 0, 0, 0, 0)
|
||||
}},
|
||||
{"Heat2D NaN boundary", func() (*core.Array, error) {
|
||||
return IntegrateHeat2D(u2, 1, 0.1, 0.1, 0.1, 0.01, 2, 0, 0, math.NaN(), 0)
|
||||
}},
|
||||
{"Heat2D Inf boundary", func() (*core.Array, error) {
|
||||
return IntegrateHeat2D(u2, 1, 0.1, 0.1, 0.1, 0.01, 2, 0, 0, 0, math.Inf(1))
|
||||
}},
|
||||
{"Wave2D v0 NaN", func() (*core.Array, error) {
|
||||
return IntegrateWave2D(u2, mustFloats(t, []float64{0, 0, 0, 0, math.NaN(), 0, 0, 0, 0}, 3, 3), 1, 0.1, 0.1, 0.1, 0.01, 2)
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := c.run(); err == nil || !strings.Contains(err.Error(), "finite") && !strings.Contains(err.Error(), "positive") {
|
||||
t.Fatalf("%s: err = %v, want a finite/positive refusal", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestODEStepsEndpointExact: the recorder's last time was the run's
|
||||
// accumulated t+h, a few ulps off t1; it is now t1 exactly.
|
||||
func TestODEStepsEndpointExact(t *testing.T) {
|
||||
decayF := func(now float64, y *core.Array) (*core.Array, error) {
|
||||
return core.MulF(y, -1), nil
|
||||
}
|
||||
times, states, err := IntegrateODESteps(decayF, 0, 0.3, mustFloats(t, []float64{1}, 1), ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODESteps: %v", err)
|
||||
}
|
||||
if last := times[len(times)-1]; last != 0.3 {
|
||||
t.Fatalf("last recorded time = %.17g, want 0.3 exactly", last)
|
||||
}
|
||||
// The pinned endpoint still closes on the analytic curve.
|
||||
if last := states[len(states)-1].FloatAt(0); math.Abs(last-math.Exp(-0.3)) > 1e-6 {
|
||||
t.Fatalf("y(0.3) = %.14g, want %.14g", last, math.Exp(-0.3))
|
||||
}
|
||||
// Across magnitudes the run's own boundary misses t1 by whole
|
||||
// ulps (t0 = 1e16 has an ulp of 2 and the span is 2): the endpoint
|
||||
// is pinned regardless, and the recorded state there is the
|
||||
// answer IntegrateODE itself returns for t1.
|
||||
flat := func(now float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(0)}, 1)
|
||||
}
|
||||
const (
|
||||
big = 1e16
|
||||
span = 2.0
|
||||
)
|
||||
times, states, err = IntegrateODESteps(flat, big, big+span, mustFloats(t, []float64{3}, 1), ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODESteps across magnitudes: %v", err)
|
||||
}
|
||||
if last := times[len(times)-1]; last != big+span {
|
||||
t.Fatalf("last recorded time = %.17g, want %.17g exactly", last, big+span)
|
||||
}
|
||||
if got := states[len(states)-1].FloatAt(0); got != 3 {
|
||||
t.Fatalf("state at the endpoint = %g, want the constant 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCubatureDimensionPowerSaturates: past the twenties the straight
|
||||
// int powers wrapped, the bisection cost went negative and every
|
||||
// budget check with it; the powers now saturate and a saturated power
|
||||
// reads as above the budget.
|
||||
func TestCubatureDimensionPowerSaturates(t *testing.T) {
|
||||
lower := make([]float64, 25)
|
||||
upper := make([]float64, 25)
|
||||
for i := range upper {
|
||||
upper[i] = 1
|
||||
}
|
||||
f := func(x []float64) float64 { return 1 }
|
||||
// A small budget is refused on the single-bisection cost alone.
|
||||
if _, err := IntegrateND(f, lower, upper, CubatureOptions{MaxEvals: 1024}); err == nil || !strings.Contains(err.Error(), "budget") {
|
||||
t.Fatalf("d = 25 under a 1024-evaluation budget: err = %v", err)
|
||||
}
|
||||
// A budget of MaxInt used to walk the powers straight into the
|
||||
// wrap and then evaluate the 5^25-point root box: the saturated
|
||||
// computation refuses it before the first evaluation.
|
||||
if _, err := IntegrateND(f, lower, upper, CubatureOptions{MaxEvals: math.MaxInt}); err == nil || !strings.Contains(err.Error(), "budget") {
|
||||
t.Fatalf("d = 25 under a MaxInt budget: err = %v", err)
|
||||
}
|
||||
// A sane dimension and budget still integrate.
|
||||
got, err := IntegrateND(f, lower[:3], upper[:3], CubatureOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("d = 3 under the default budget: %v", err)
|
||||
}
|
||||
if math.Abs(got-1) > 1e-10 {
|
||||
t.Fatalf("integral of 1 over the unit cube = %.17g, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate_test
|
||||
|
||||
// Runnable examples for the package: the flagship workflows, each
|
||||
// with a fixed output that `go test` checks, so the printed
|
||||
// documentation cannot drift from the code.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
tensor "sourcedock.dev/petrbalvin/tensor"
|
||||
"sourcedock.dev/petrbalvin/tensor/integrate"
|
||||
)
|
||||
|
||||
// The stiff scalar problem y' = −1000·(y − cos t) − sin t with
|
||||
// y(0) = 1, whose exact solution is y = cos t. The variable-order,
|
||||
// variable-step BDF scheme takes the long steps the solution's
|
||||
// smoothness allows where a fixed small step would be forced by the
|
||||
// fast transient, and BDFVarStats reports what it did.
|
||||
func ExampleIntegrateBDFVar() {
|
||||
f := func(t float64, y *tensor.Array) (*tensor.Array, error) {
|
||||
return tensor.FromFloats([]float64{-1000*(y.FloatAt(0)-math.Cos(t)) - math.Sin(t)}, 1)
|
||||
}
|
||||
y0, _ := tensor.FromFloats([]float64{1}, 1)
|
||||
var stats integrate.BDFVarStats
|
||||
y, err := integrate.IntegrateBDFVar(f, 0, 1, y0, integrate.BDFVarOptions{Stats: &stats})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("y(1) = %.6f, exact %.6f\n", y.FloatAt(0), math.Cos(1))
|
||||
fmt.Printf("accepted %d steps, rejected %d, highest order %d\n", stats.Steps, stats.Rejected, stats.MaxOrder)
|
||||
// Output:
|
||||
// y(1) = 0.540302, exact 0.540302
|
||||
// accepted 21 steps, rejected 1, highest order 5
|
||||
}
|
||||
|
||||
// Event detection along a trajectory: the oscillator y″ = −y started
|
||||
// at y = (1, 0) passes the level y = 0.5 falling at t = π/3 and rising
|
||||
// at t = 5π/3. Each watch carries its own direction filter, and
|
||||
// IntegrateODEEvents returns the crossings sorted by time alongside
|
||||
// the final state.
|
||||
func ExampleIntegrateODEEvents() {
|
||||
f := func(t float64, y *tensor.Array) (*tensor.Array, error) {
|
||||
return tensor.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
y0, _ := tensor.FromFloats([]float64{1, 0}, 2)
|
||||
// The same level function twice, with opposite direction filters;
|
||||
// Direction 0 would record both crossings on one watch.
|
||||
level := func(t float64, y *tensor.Array) (float64, error) { return y.FloatAt(0) - 0.5, nil }
|
||||
watches := []integrate.ODEWatch{
|
||||
{Function: level, Direction: -1},
|
||||
{Function: level, Direction: +1},
|
||||
}
|
||||
hits, final, err := integrate.IntegrateODEEvents(f, 0, 7, y0, watches, integrate.ODEOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, h := range hits {
|
||||
direction := "falling"
|
||||
if h.Rising {
|
||||
direction = "rising"
|
||||
}
|
||||
fmt.Printf("watch %d fired at t = %.4f (%s), y = %.4f\n", h.Watch, h.Time, direction, h.State.FloatAt(0))
|
||||
}
|
||||
fmt.Printf("y(7) = %.4f\n", final.FloatAt(0))
|
||||
// Output:
|
||||
// watch 0 fired at t = 1.0472 (falling), y = 0.5000
|
||||
// watch 1 fired at t = 5.2360 (rising), y = 0.5000
|
||||
// y(7) = 0.7539
|
||||
}
|
||||
|
||||
// A symplectic integrator on a separable Hamiltonian: the harmonic
|
||||
// oscillator q″ = −q with unit mass, q(0) = 1 and p(0) = 0, whose
|
||||
// energy ½(p² + q²) stays in a bounded band instead of drifting.
|
||||
// The step stays fixed by design; only the number of steps is chosen.
|
||||
func ExampleIntegrateVerlet() {
|
||||
accel := func(q *tensor.Array) (*tensor.Array, error) {
|
||||
return tensor.FromFloats([]float64{-q.FloatAt(0)}, 1)
|
||||
}
|
||||
q0, _ := tensor.FromFloats([]float64{1}, 1)
|
||||
p0, _ := tensor.FromFloats([]float64{0}, 1)
|
||||
const steps = 4000
|
||||
positions, momenta, err := integrate.IntegrateVerlet(accel, 0, 2*math.Pi, q0, p0, steps)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
worst := 0.0
|
||||
for s := range steps + 1 {
|
||||
q, p := positions[s].FloatAt(0), momenta[s].FloatAt(0)
|
||||
drift := math.Abs(0.5*(p*p+q*q) - 0.5)
|
||||
worst = math.Max(worst, drift)
|
||||
}
|
||||
fmt.Printf("q(2π) = %.6f, p(2π) = %.2e\n", positions[steps].FloatAt(0), momenta[steps].FloatAt(0))
|
||||
fmt.Printf("worst energy deviation over the period: %.2e\n", worst)
|
||||
// Output:
|
||||
// q(2π) = 1.000000, p(2π) = -6.46e-07
|
||||
// worst energy deviation over the period: 3.08e-07
|
||||
}
|
||||
|
||||
// Quadrature and cubature: a Gauss-Legendre rule read from the
|
||||
// package's cache, an adaptive integral over an infinite range, and a
|
||||
// two-dimensional integral by globally adaptive bisection.
|
||||
func Example_quadratureAndCubature() {
|
||||
nodes, weights, err := integrate.GaussLegendreNodes(3)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for i := range nodes {
|
||||
fmt.Printf("node %.6f, weight %.6f\n", nodes[i], weights[i])
|
||||
}
|
||||
|
||||
value, errEst, err := integrate.IntegrateFunction(func(x float64) (float64, error) {
|
||||
return math.Exp(-x * x), nil
|
||||
}, 0, math.Inf(1), integrate.QuadratureOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("the Gaussian tail integrates to %.6f (error estimate %.1e)\n", value, errEst)
|
||||
|
||||
area, err := integrate.IntegrateND(func(x []float64) float64 {
|
||||
return x[0] * x[1]
|
||||
}, []float64{0, 0}, []float64{1, 1}, integrate.CubatureOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("x·y over the unit square integrates to %.6f\n", area)
|
||||
// Output:
|
||||
// node -0.774597, weight 0.555556
|
||||
// node 0.000000, weight 0.888889
|
||||
// node 0.774597, weight 0.555556
|
||||
// the Gaussian tail integrates to 0.886227 (error estimate 9.8e-12)
|
||||
// x·y over the unit square integrates to 0.250000
|
||||
}
|
||||
|
||||
// Heat evolution in one dimension: u_t = u_xx on [0, 1] from
|
||||
// u = sin(πx), Dirichlet ends held at zero. The sampled history is a
|
||||
// (samples, n) array of interior states, and the centre decays as the
|
||||
// exact e^(−π²t)·sin(π/2) predicts.
|
||||
func ExampleIntegrateHeat1D() {
|
||||
const n, samples = 399, 5
|
||||
const dx, tFinal = 1.0 / 400, 0.1
|
||||
u0 := make([]float64, n)
|
||||
for i := range u0 {
|
||||
u0[i] = math.Sin(math.Pi * float64(i+1) * dx)
|
||||
}
|
||||
state, err := tensor.FromFloats(u0, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
history, err := integrate.IntegrateHeat1D(state, 1, dx, tFinal, 1e-4, samples, 0, 0)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
centre := history.FloatAt((samples-1)*n + n/2)
|
||||
exact := math.Exp(-math.Pi * math.Pi * tFinal)
|
||||
fmt.Printf("history shape %v\n", history.Shape())
|
||||
fmt.Printf("u(1/2, 0.1) = %.6f, exact %.6f\n", centre, exact)
|
||||
// Output:
|
||||
// history shape [5 399]
|
||||
// u(1/2, 0.1) = 0.372710, exact 0.372708
|
||||
}
|
||||
|
||||
// The finite element Poisson solve: −∇·(κ∇u) = f on the unit square
|
||||
// with κ = 1 and Dirichlet data on the boundary ring. The manufactured
|
||||
// solution u = sin(πx)·sin(πy) makes f = 2π²·sin(πx)·sin(πy), and the
|
||||
// P1 solution reproduces it to the mesh's accuracy at the centre.
|
||||
func ExampleSolvePoissonFEM2D() {
|
||||
const cells = 16
|
||||
mesh, err := integrate.GridTriangleMesh2D(0, 0, 1, 1, cells, cells)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var nodes []int
|
||||
var values []float64
|
||||
for v := range mesh.Vertices2() {
|
||||
x, y := mesh.Vertices[2*v], mesh.Vertices[2*v+1]
|
||||
onEdge := x == 0 || x == 1 || y == 0 || y == 1
|
||||
if onEdge {
|
||||
nodes = append(nodes, v)
|
||||
values = append(values, math.Sin(math.Pi*x)*math.Sin(math.Pi*y))
|
||||
}
|
||||
}
|
||||
u, err := integrate.SolvePoissonFEM2D(mesh, func(x, y float64) float64 {
|
||||
return 2 * math.Pi * math.Pi * math.Sin(math.Pi*x) * math.Sin(math.Pi*y)
|
||||
}, integrate.FEMPoissonOptions{Kappa: 1, DirichletNodes: nodes, DirichletValues: values})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
centre := (cells/2)*(cells+1) + cells/2
|
||||
fmt.Printf("mesh of %d vertices, %d triangles, %d boundary edges\n",
|
||||
mesh.Vertices2(), mesh.Triangles3(), len(mesh.BoundaryEdges())/2)
|
||||
fmt.Printf("u(1/2, 1/2) = %.4f on this mesh, exact 1.0000\n", u.FloatAt(centre))
|
||||
// Output:
|
||||
// mesh of 289 vertices, 512 triangles, 64 boundary edges
|
||||
// u(1/2, 1/2) = 0.9946 on this mesh, exact 1.0000
|
||||
}
|
||||
|
||||
// The two-point boundary value problem: y″ = −y with y(0) = 0 and
|
||||
// y(π/2) = 1, solved by shooting on the free initial slope. The slope
|
||||
// comes out as 1 and the sampled trajectory traces y = sin t.
|
||||
func ExampleIntegrateBoundary() {
|
||||
f := func(t float64, y *tensor.Array) (*tensor.Array, error) {
|
||||
return tensor.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
y0, _ := tensor.FromFloats([]float64{0, 0.5}, 2)
|
||||
bc := integrate.BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{1}}
|
||||
times, states, err := integrate.IntegrateBoundary(f, 0, math.Pi/2, y0, bc, 3,
|
||||
integrate.ODEOptions{RelTol: 1e-10, AbsTol: 1e-13})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("shooting slope y'(0) = %.6f\n", states[0].FloatAt(1))
|
||||
for i := range times {
|
||||
fmt.Printf("y(%.4f) = %.6f, exact %.6f\n", times[i], states[i].FloatAt(0), math.Sin(times[i]))
|
||||
}
|
||||
// Output:
|
||||
// shooting slope y'(0) = 1.000000
|
||||
// y(0.0000) = 0.000000, exact 0.000000
|
||||
// y(0.7854) = 0.707107, exact 0.707107
|
||||
// y(1.5708) = 1.000000, exact 1.000000
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
linalg "sourcedock.dev/petrbalvin/tensor/linalg"
|
||||
)
|
||||
|
||||
// The finite element surface for second-order problems on general
|
||||
// two-dimensional domains: piecewise-linear (P1) elements on a
|
||||
// conforming triangular mesh, the stiffness matrix assembled straight
|
||||
// into the sparse triple format, Dirichlet values eliminated by
|
||||
// lifting, Neumann boundaries free of charge, and the reduced system
|
||||
// handed to the sparse Cholesky factorisation the direct-solvers
|
||||
// surface provides.
|
||||
|
||||
// TriangleMesh2D carries a conforming triangular mesh: vertex
|
||||
// coordinates as x,y pairs and triangles as triples of vertex
|
||||
// indices. The orientation of a triangle does not matter; a triangle
|
||||
// with zero area does and is refused at construction.
|
||||
type TriangleMesh2D struct {
|
||||
// Vertices holds x,y for every vertex: two entries per vertex.
|
||||
Vertices []float64
|
||||
// Triangles holds three vertex indices per triangle.
|
||||
Triangles []int64
|
||||
}
|
||||
|
||||
// NewTriangleMesh2D builds a mesh from a vertex table with two
|
||||
// columns and a triangle table with three columns of vertex indices.
|
||||
// Indices must lie in range and a degenerate triangle (three
|
||||
// collinear vertices) is an error: its stiffness contribution is
|
||||
// undefined.
|
||||
func NewTriangleMesh2D(vertices *core.Array, triangles *core.Array) (*TriangleMesh2D, error) {
|
||||
const name = "NewTriangleMesh2D"
|
||||
if vertices.Dtype() == core.Complex || triangles.Dtype() == core.Complex {
|
||||
return nil, base.Errf("%s: complex mesh data is not supported", name)
|
||||
}
|
||||
if vertices.NDim() != 2 || vertices.Shape()[1] != 2 {
|
||||
return nil, base.Errf("%s: the vertex table must be rank 2 with two columns, got shape %s", name, base.ShapeText(vertices.Shape()))
|
||||
}
|
||||
if triangles.Dtype() != core.Int {
|
||||
return nil, base.Errf("%s: the triangle table must hold integers, got %s", name, triangles.Dtype())
|
||||
}
|
||||
if triangles.NDim() != 2 || triangles.Shape()[1] != 3 {
|
||||
return nil, base.Errf("%s: the triangle table must be rank 2 with three columns, got shape %s", name, base.ShapeText(triangles.Shape()))
|
||||
}
|
||||
n := vertices.Shape()[0]
|
||||
m := triangles.Shape()[0]
|
||||
if n < 3 {
|
||||
return nil, base.Errf("%s: a mesh needs at least three vertices, got %d", name, n)
|
||||
}
|
||||
if m == 0 {
|
||||
// An empty triangle table would surface deep in the sparse
|
||||
// factorisation on the zero rows of the free nodes, far from
|
||||
// the mesh that caused it.
|
||||
return nil, base.Errf("%s: the triangle table must not be empty", name)
|
||||
}
|
||||
mesh := &TriangleMesh2D{Vertices: make([]float64, 2*n), Triangles: make([]int64, 3*m)}
|
||||
for i := range 2 * n {
|
||||
v := vertices.FloatAt(i)
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil, base.Errf("%s: vertex coordinate %d is not finite", name, i)
|
||||
}
|
||||
mesh.Vertices[i] = v
|
||||
}
|
||||
for p := range 3 * m {
|
||||
idx := triangles.RawInts()[p]
|
||||
if idx < 0 || idx >= int64(n) {
|
||||
return nil, base.Errf("%s: triangle vertex index %d out of range for %d vertices", name, idx, n)
|
||||
}
|
||||
mesh.Triangles[p] = idx
|
||||
}
|
||||
// A triangle with zero area carries no stiffness: refuse it here
|
||||
// where the caller can name the triangle, not mid-assembly.
|
||||
for t := range m {
|
||||
a, b, c := mesh.Triangles[3*t], mesh.Triangles[3*t+1], mesh.Triangles[3*t+2]
|
||||
ax, ay := mesh.Vertices[2*a], mesh.Vertices[2*a+1]
|
||||
bx, by := mesh.Vertices[2*b], mesh.Vertices[2*b+1]
|
||||
cx, cy := mesh.Vertices[2*c], mesh.Vertices[2*c+1]
|
||||
if area := math.Abs((bx-ax)*(cy-ay)-(cx-ax)*(by-ay)) / 2; area == 0 {
|
||||
return nil, base.Errf("%s: triangle %d is degenerate (zero area)", name, t)
|
||||
}
|
||||
}
|
||||
return mesh, nil
|
||||
}
|
||||
|
||||
// Vertices2 returns the vertex count.
|
||||
func (m *TriangleMesh2D) Vertices2() int { return len(m.Vertices) / 2 }
|
||||
|
||||
// Triangles3 returns the triangle count.
|
||||
func (m *TriangleMesh2D) Triangles3() int { return len(m.Triangles) / 3 }
|
||||
|
||||
// BoundaryEdges returns the mesh's boundary edges as flat pairs of
|
||||
// vertex indices: an edge belongs to the boundary when exactly one
|
||||
// triangle carries it. The pairs are sorted, so the result is a pure
|
||||
// function of the mesh.
|
||||
func (m *TriangleMesh2D) BoundaryEdges() []int {
|
||||
count := make(map[[2]int]int, len(m.Triangles))
|
||||
key := func(a, b int) [2]int {
|
||||
if a < b {
|
||||
return [2]int{a, b}
|
||||
}
|
||||
return [2]int{b, a}
|
||||
}
|
||||
for t := 0; t < m.Triangles3(); t++ {
|
||||
a, b, c := int(m.Triangles[3*t]), int(m.Triangles[3*t+1]), int(m.Triangles[3*t+2])
|
||||
count[key(a, b)]++
|
||||
count[key(b, c)]++
|
||||
count[key(c, a)]++
|
||||
}
|
||||
edges := make([]int, 0, 8)
|
||||
for e, n := range count {
|
||||
if n == 1 {
|
||||
edges = append(edges, e[0], e[1])
|
||||
}
|
||||
}
|
||||
slices.Sort(edges)
|
||||
return edges
|
||||
}
|
||||
|
||||
// GridTriangleMesh2D builds the structured triangulation of the
|
||||
// axis-aligned rectangle [x0, x0+width] × [y0, y0+height] with m by n
|
||||
// cells, two triangles per cell. m and n must both be positive.
|
||||
func GridTriangleMesh2D(x0, y0, width, height float64, m, n int) (*TriangleMesh2D, error) {
|
||||
const name = "GridTriangleMesh2D"
|
||||
if m <= 0 || n <= 0 {
|
||||
return nil, base.Errf("%s: the cell counts must be positive, got %d by %d", name, m, n)
|
||||
}
|
||||
// The same guard NewTriangleMesh2D applies to its vertex table: a
|
||||
// non-finite extent or origin would lay out vertices at NaN or Inf
|
||||
// and only surface mid-factorisation, far from the cause.
|
||||
if !(width > 0) || !(height > 0) || math.IsInf(width, 0) || math.IsInf(height, 0) ||
|
||||
math.IsNaN(x0) || math.IsInf(x0, 0) || math.IsNaN(y0) || math.IsInf(y0, 0) {
|
||||
return nil, base.Errf("%s: the extents must be finite and positive and the origin finite, got origin (%g, %g), extents %g by %g",
|
||||
name, x0, y0, width, height)
|
||||
}
|
||||
vertices := make([]float64, 2*(m+1)*(n+1))
|
||||
for j := range n + 1 {
|
||||
for i := range m + 1 {
|
||||
vertices[2*(j*(m+1)+i)] = x0 + width*float64(i)/float64(m)
|
||||
vertices[2*(j*(m+1)+i)+1] = y0 + height*float64(j)/float64(n)
|
||||
}
|
||||
}
|
||||
at := func(i, j int) int64 { return int64(j*(m+1) + i) }
|
||||
triangles := make([]int64, 0, 6*m*n)
|
||||
for j := range n {
|
||||
for i := range m {
|
||||
triangles = append(triangles,
|
||||
at(i, j), at(i+1, j), at(i+1, j+1),
|
||||
at(i, j), at(i+1, j+1), at(i, j+1))
|
||||
}
|
||||
}
|
||||
return &TriangleMesh2D{Vertices: vertices, Triangles: triangles}, nil
|
||||
}
|
||||
|
||||
// FEMPoissonOptions carries the data SolvePoissonFEM2D needs beside
|
||||
// the mesh and the source: the conductivity, the prescribed boundary
|
||||
// values, and the optional flux boundary.
|
||||
type FEMPoissonOptions struct {
|
||||
// Kappa is the constant conductivity when KappaFunc is nil. It
|
||||
// must be positive.
|
||||
Kappa float64
|
||||
// KappaFunc, when set, gives the conductivity at a point. It is
|
||||
// evaluated at the triangle centroids and must be positive there
|
||||
// for every triangle; a non-positive value names the triangle.
|
||||
KappaFunc func(x, y float64) float64
|
||||
// DirichletNodes lists the vertices with prescribed values and
|
||||
// DirichletValues the values in the same order. The nodes leave
|
||||
// the system with their rows and columns; at least one is
|
||||
// required, because a purely Neumann problem has no unique
|
||||
// solution.
|
||||
DirichletNodes []int
|
||||
DirichletValues []float64
|
||||
// NeumannEdges lists boundary edges as flat pairs of vertex
|
||||
// indices and NeumannFlux gives the flux κ∂u/∂n along each edge's
|
||||
// outward normal: each edge receives half of length·flux at its
|
||||
// midpoint into both endpoints. A nil flux means zero.
|
||||
NeumannEdges []int
|
||||
NeumannFlux func(x, y float64) float64
|
||||
// Ordering selects the fill-reducing permutation for the sparse
|
||||
// Cholesky factorisation. The zero value is the natural order;
|
||||
// meshes usually want SparseOrderingReverseCuthillMcKee.
|
||||
Ordering linalg.SparseOrdering
|
||||
}
|
||||
|
||||
// SolvePoissonFEM2D solves −∇·(κ∇u) = f on the mesh with
|
||||
// piecewise-linear elements: the stiffness matrix is assembled per
|
||||
// triangle (the conductivity evaluated at the centroids when it
|
||||
// varies), the load is lumped at the vertices from f at the
|
||||
// centroids, Neumann fluxes are integrated along their edges, and
|
||||
// Dirichlet values are eliminated by lifting. f may be nil for the
|
||||
// homogeneous equation.
|
||||
func SolvePoissonFEM2D(mesh *TriangleMesh2D, f func(x, y float64) float64, opts FEMPoissonOptions) (*core.Array, error) {
|
||||
const name = "SolvePoissonFEM2D"
|
||||
if mesh == nil {
|
||||
return nil, base.Errf("%s: the mesh must not be nil", name)
|
||||
}
|
||||
n := mesh.Vertices2()
|
||||
// With KappaFunc nil the constant conductivity is the value used,
|
||||
// so it must be positive and finite; with the field set the
|
||||
// constant is a placeholder, but a non-finite one is still refused
|
||||
// rather than silently ignored.
|
||||
if opts.KappaFunc == nil {
|
||||
if !(opts.Kappa > 0) || math.IsInf(opts.Kappa, 0) {
|
||||
return nil, base.Errf("%s: the conductivity must be positive, got %g", name, opts.Kappa)
|
||||
}
|
||||
} else if math.IsNaN(opts.Kappa) || math.IsInf(opts.Kappa, 0) {
|
||||
return nil, base.Errf("%s: the conductivity must be positive, got %g", name, opts.Kappa)
|
||||
}
|
||||
if len(opts.DirichletNodes) != len(opts.DirichletValues) {
|
||||
return nil, base.Errf("%s: %d Dirichlet nodes but %d values", name, len(opts.DirichletNodes), len(opts.DirichletValues))
|
||||
}
|
||||
if len(opts.DirichletNodes) == 0 {
|
||||
return nil, base.Errf("%s: a purely Neumann problem has no unique solution; prescribe at least one Dirichlet value", name)
|
||||
}
|
||||
// The Dirichlet nodes as a dense marker with their prescribed
|
||||
// values: the lifting and the unit rows below each visit every
|
||||
// assembled entry, and a marker answers those visits in constant
|
||||
// time where a set of nodes answered with a hash. A node listed
|
||||
// twice keeps its last value and appears once, as it did in the
|
||||
// set; the appended order does not reach the assembled system,
|
||||
// whose coordinate entries the sparse conversion sorts and merges
|
||||
// by coordinate.
|
||||
dirichletMark := make([]bool, n)
|
||||
dirichletVal := make([]float64, n)
|
||||
dirichletNodes := make([]int, 0, len(opts.DirichletNodes))
|
||||
for p, d := range opts.DirichletNodes {
|
||||
if d < 0 || d >= n {
|
||||
return nil, base.Errf("%s: Dirichlet node %d out of range for %d vertices", name, d, n)
|
||||
}
|
||||
v := opts.DirichletValues[p]
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil, base.Errf("%s: Dirichlet value at node %d is not finite", name, d)
|
||||
}
|
||||
if !dirichletMark[d] {
|
||||
dirichletNodes = append(dirichletNodes, d)
|
||||
}
|
||||
dirichletMark[d] = true
|
||||
dirichletVal[d] = v
|
||||
}
|
||||
if len(opts.NeumannEdges)%2 != 0 {
|
||||
return nil, base.Errf("%s: %d Neumann edge indices, want pairs", name, len(opts.NeumannEdges))
|
||||
}
|
||||
for p := 0; p < len(opts.NeumannEdges); p += 2 {
|
||||
a, b := opts.NeumannEdges[p], opts.NeumannEdges[p+1]
|
||||
if a < 0 || a >= n || b < 0 || b >= n || a == b {
|
||||
return nil, base.Errf("%s: Neumann edge [%d,%d] is not a valid vertex pair", name, a, b)
|
||||
}
|
||||
}
|
||||
// Assembly: nine entries per triangle, symmetric by construction;
|
||||
// the load is lumped one third of the triangle area to each of
|
||||
// its vertices, with the conductivity evaluated at the centroid
|
||||
// when it varies.
|
||||
entries := make([]float64, 0, 9*mesh.Triangles3())
|
||||
rows := make([]int, 0, 9*mesh.Triangles3())
|
||||
cols := make([]int, 0, 9*mesh.Triangles3())
|
||||
load := make([]float64, n)
|
||||
for t := 0; t < mesh.Triangles3(); t++ {
|
||||
a, b, c := int(mesh.Triangles[3*t]), int(mesh.Triangles[3*t+1]), int(mesh.Triangles[3*t+2])
|
||||
ax, ay := mesh.Vertices[2*a], mesh.Vertices[2*a+1]
|
||||
bx, by := mesh.Vertices[2*b], mesh.Vertices[2*b+1]
|
||||
cx, cy := mesh.Vertices[2*c], mesh.Vertices[2*c+1]
|
||||
area := math.Abs((bx-ax)*(cy-ay)-(cx-ax)*(by-ay)) / 2
|
||||
kappa := opts.Kappa
|
||||
if opts.KappaFunc != nil {
|
||||
kappa = opts.KappaFunc((ax+bx+cx)/3, (ay+by+cy)/3)
|
||||
if !(kappa > 0) || math.IsNaN(kappa) || math.IsInf(kappa, 0) {
|
||||
return nil, base.Errf("%s: the conductivity at triangle %d is %g, want positive", name, t, kappa)
|
||||
}
|
||||
}
|
||||
// The gradient basis: b are the y differences, c the x
|
||||
// differences, and K = κ/(4A)·(b⊗b + c⊗c).
|
||||
bb := [3]float64{by - cy, cy - ay, ay - by}
|
||||
cc := [3]float64{cx - bx, ax - cx, bx - ax}
|
||||
nodes := [3]int{a, b, c}
|
||||
for i := range 3 {
|
||||
for j := range 3 {
|
||||
v := kappa * (bb[i]*bb[j] + cc[i]*cc[j]) / (4 * area)
|
||||
rows = append(rows, nodes[i])
|
||||
cols = append(cols, nodes[j])
|
||||
entries = append(entries, v)
|
||||
}
|
||||
}
|
||||
if f != nil {
|
||||
fv := f((ax+bx+cx)/3, (ay+by+cy)/3)
|
||||
// A non-finite source value would flow into the load and the
|
||||
// solve would publish an all-NaN solution with a nil error,
|
||||
// the breach every other integrator here refuses up front.
|
||||
if math.IsNaN(fv) || math.IsInf(fv, 0) {
|
||||
return nil, base.Errf("%s: the source returned the non-finite value %g at triangle %d", name, fv, t)
|
||||
}
|
||||
contribution := area / 3 * fv
|
||||
load[a] += contribution
|
||||
load[b] += contribution
|
||||
load[c] += contribution
|
||||
}
|
||||
}
|
||||
// Neumann fluxes: half of length·flux into each endpoint of every
|
||||
// listed edge, the flux evaluated at the edge midpoint.
|
||||
if len(opts.NeumannEdges) > 0 && opts.NeumannFlux != nil {
|
||||
for p := 0; p < len(opts.NeumannEdges); p += 2 {
|
||||
a, b := opts.NeumannEdges[p], opts.NeumannEdges[p+1]
|
||||
ax, ay := mesh.Vertices[2*a], mesh.Vertices[2*a+1]
|
||||
bx, by := mesh.Vertices[2*b], mesh.Vertices[2*b+1]
|
||||
length := math.Hypot(bx-ax, by-ay)
|
||||
fv := opts.NeumannFlux((ax+bx)/2, (ay+by)/2)
|
||||
// A non-finite flux lands in the load like a non-finite
|
||||
// source, so the same refusal answers it.
|
||||
if math.IsNaN(fv) || math.IsInf(fv, 0) {
|
||||
return nil, base.Errf("%s: the Neumann flux returned the non-finite value %g on edge [%d, %d]", name, fv, a, b)
|
||||
}
|
||||
flux := length / 2 * fv
|
||||
load[a] += flux
|
||||
load[b] += flux
|
||||
}
|
||||
}
|
||||
// Dirichlet lifting: the known boundary values move to the right
|
||||
// hand side, then their rows and columns leave the system as
|
||||
// unit rows.
|
||||
for p, i := range rows {
|
||||
if j := cols[p]; dirichletMark[j] {
|
||||
load[i] -= entries[p] * dirichletVal[j]
|
||||
}
|
||||
}
|
||||
keptRows := make([]int64, 0, len(rows))
|
||||
keptCols := make([]int64, 0, len(rows))
|
||||
keptVals := make([]float64, 0, len(rows))
|
||||
for p := range rows {
|
||||
i, j := rows[p], cols[p]
|
||||
if dirichletMark[i] || dirichletMark[j] {
|
||||
continue
|
||||
}
|
||||
keptRows = append(keptRows, int64(i))
|
||||
keptCols = append(keptCols, int64(j))
|
||||
keptVals = append(keptVals, entries[p])
|
||||
}
|
||||
for _, d := range dirichletNodes {
|
||||
keptRows = append(keptRows, int64(d))
|
||||
keptCols = append(keptCols, int64(d))
|
||||
keptVals = append(keptVals, 1)
|
||||
load[d] = dirichletVal[d]
|
||||
}
|
||||
indices, err := core.FromInts(pairInts(keptRows, keptCols), len(keptVals), 2)
|
||||
if err != nil {
|
||||
return nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
coo, err := core.NewSparseCOO(indices, fromSlice(keptVals, len(keptVals)), []int{n, n})
|
||||
if err != nil {
|
||||
return nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
order := opts.Ordering
|
||||
factor, err := linalg.NewSparseCholesky(coo, order)
|
||||
if err != nil {
|
||||
return nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
rhs := core.New(core.Float, []int{n}...)
|
||||
copy(rhs.RawFloats(), load)
|
||||
return factor.Solve(rhs)
|
||||
}
|
||||
|
||||
// pairInts interleaves row and column indices into the index table
|
||||
// the sparse coordinate format expects.
|
||||
func pairInts(rows, cols []int64) []int64 {
|
||||
out := make([]int64, 2*len(rows))
|
||||
for p := range rows {
|
||||
out[2*p] = rows[p]
|
||||
out[2*p+1] = cols[p]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
linalg "sourcedock.dev/petrbalvin/tensor/linalg"
|
||||
)
|
||||
|
||||
// gridMesh builds the structured triangulation of the unit square
|
||||
// with m cells per side, two triangles per cell, and returns the mesh
|
||||
// plus the list of boundary vertices in the order (bottom row, top
|
||||
// row, left column, right column), duplicates removed.
|
||||
func gridMesh(t *testing.T, m int) (*TriangleMesh2D, []int) {
|
||||
t.Helper()
|
||||
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, m, m)
|
||||
if err != nil {
|
||||
t.Fatalf("GridTriangleMesh2D: %v", err)
|
||||
}
|
||||
boundary := make([]int, 0, 4*m)
|
||||
for i := range m + 1 {
|
||||
boundary = append(boundary, i, m*(m+1)+i)
|
||||
}
|
||||
for j := 1; j < m; j++ {
|
||||
boundary = append(boundary, j*(m+1), j*(m+1)+m)
|
||||
}
|
||||
return mesh, boundary
|
||||
}
|
||||
|
||||
func TestSolvePoissonFEM2DConvergence(t *testing.T) {
|
||||
// The manufactured solution u = sin(πx)·sin(πy) on the unit
|
||||
// square drives f = 2π²·u; with the boundary lifted the P1 error
|
||||
// must halve twice when the mesh is refined, the O(h²) the
|
||||
// piecewise-linear theory promises.
|
||||
solution := func(x, y float64) float64 { return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) }
|
||||
source := func(x, y float64) float64 { return 2 * math.Pi * math.Pi * solution(x, y) }
|
||||
previous := 0.0
|
||||
for _, m := range []int{8, 16, 32} {
|
||||
mesh, boundary := gridMesh(t, m)
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = solution(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
|
||||
}
|
||||
u, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM2D(m=%d): %v", m, err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices2() {
|
||||
if d := math.Abs(u.FloatAt(i) - solution(mesh.Vertices[2*i], mesh.Vertices[2*i+1])); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("m=%2d: max nodal error %.3g", m, worst)
|
||||
if previous > 0 && previous/worst < 2.5 {
|
||||
t.Fatalf("m=%d: refinement ratio %.2f, want the O(h²) rate (previous %.3g, now %.3g)",
|
||||
m, previous/worst, previous, worst)
|
||||
}
|
||||
if m == 32 && worst > 2e-3 {
|
||||
t.Fatalf("m=32: error %.3g too large for the asymptotic range", worst)
|
||||
}
|
||||
previous = worst
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM2DLinearExactness is the patch test the P1
|
||||
// elements must pass without compromise: a linear field lies in the
|
||||
// approximation space, so with f = 0 and the boundary lifted the
|
||||
// interior solution must equal the field to machine precision.
|
||||
func TestSolvePoissonFEM2DLinearExactness(t *testing.T) {
|
||||
mesh, boundary := gridMesh(t, 12)
|
||||
field := func(x, y float64) float64 { return 1 + 2*x - 3*y }
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = field(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
|
||||
}
|
||||
u, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM2D: %v", err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices2() {
|
||||
if d := math.Abs(u.FloatAt(i) - field(mesh.Vertices[2*i], mesh.Vertices[2*i+1])); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
if worst > 1e-12 {
|
||||
t.Fatalf("linear patch test error %.3g, want machine precision", worst)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM2DNeumannNatural pins the natural boundary: a
|
||||
// constant field with f = 0 satisfies the homogeneous Neumann
|
||||
// condition everywhere, so pinning the constant at a single vertex
|
||||
// must reproduce it across the whole mesh.
|
||||
func TestSolvePoissonFEM2DNeumannNatural(t *testing.T) {
|
||||
mesh, _ := gridMesh(t, 10)
|
||||
u, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{5}})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM2D: %v", err)
|
||||
}
|
||||
for i := range mesh.Vertices2() {
|
||||
if math.Abs(u.FloatAt(i)-5) > 1e-10 {
|
||||
t.Fatalf("node %d: solution %.12g, want the constant 5", i, u.FloatAt(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM2DOrderings runs the manufactured-solution solve
|
||||
// under every ordering the factor offers: the ordering changes the
|
||||
// fill, never the answer.
|
||||
func TestSolvePoissonFEM2DOrderings(t *testing.T) {
|
||||
solution := func(x, y float64) float64 { return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) }
|
||||
mesh, boundary := gridMesh(t, 10)
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = solution(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
|
||||
}
|
||||
source := func(x, y float64) float64 { return 2 * math.Pi * math.Pi * solution(x, y) }
|
||||
reference, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM2D(natural): %v", err)
|
||||
}
|
||||
for _, ordering := range []linalg.SparseOrdering{
|
||||
linalg.SparseOrderingReverseCuthillMcKee,
|
||||
linalg.SparseOrderingMinimumDegree,
|
||||
} {
|
||||
u, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values, Ordering: ordering})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM2D(%d): %v", ordering, err)
|
||||
}
|
||||
for i := range mesh.Vertices2() {
|
||||
if math.Abs(u.FloatAt(i)-reference.FloatAt(i)) > 1e-9 {
|
||||
t.Fatalf("ordering %d: node %d differs from the natural run", ordering, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolvePoissonFEM2DRefusals(t *testing.T) {
|
||||
mesh, boundary := gridMesh(t, 5)
|
||||
// Degenerate triangle: three collinear vertices.
|
||||
if _, err := NewTriangleMesh2D(
|
||||
floatsToArrayFEM(t, []float64{0, 0, 1, 0, 2, 0}, 3, 2),
|
||||
intsToArrayFEM(t, []int64{0, 1, 2}, 1, 3)); err == nil || !stringsContains(err, "degenerate") {
|
||||
t.Fatalf("a degenerate triangle: %v", err)
|
||||
}
|
||||
// Triangle index out of range.
|
||||
if _, err := NewTriangleMesh2D(
|
||||
floatsToArrayFEM(t, []float64{0, 0, 1, 0, 0, 1}, 3, 2),
|
||||
intsToArrayFEM(t, []int64{0, 1, 3}, 1, 3)); err == nil || !stringsContains(err, "out of range") {
|
||||
t.Fatalf("out of range index: %v", err)
|
||||
}
|
||||
// A float triangle table: the triangles must be integer indices.
|
||||
if _, err := NewTriangleMesh2D(
|
||||
floatsToArrayFEM(t, []float64{0, 0, 1, 0, 0, 1}, 3, 2),
|
||||
floatsToArrayFEM(t, []float64{0, 1, 2}, 1, 3)); err == nil || !stringsContains(err, "integers") {
|
||||
t.Fatalf("a float triangle table: %v", err)
|
||||
}
|
||||
// Dirichlet node out of range and a length mismatch.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{99}, DirichletValues: []float64{1}}); err == nil || !stringsContains(err, "out of range") {
|
||||
t.Fatalf("an out of range Dirichlet node: %v", err)
|
||||
}
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0, 1}, DirichletValues: []float64{1}}); err == nil {
|
||||
t.Fatal("a Dirichlet length mismatch was accepted")
|
||||
}
|
||||
// Non-positive conductivity.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 0, DirichletNodes: boundary, DirichletValues: make([]float64, len(boundary))}); err == nil || !stringsContains(err, "positive") {
|
||||
t.Fatalf("zero conductivity: %v", err)
|
||||
}
|
||||
// Non-finite Dirichlet value.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{math.NaN()}}); err == nil || !stringsContains(err, "finite") {
|
||||
t.Fatalf("a NaN Dirichlet value: %v", err)
|
||||
}
|
||||
// A NaN vertex coordinate in the mesh table.
|
||||
if _, err := NewTriangleMesh2D(
|
||||
floatsToArrayFEM(t, []float64{math.NaN(), 0, 1, 0, 0, 1}, 3, 2),
|
||||
intsToArrayFEM(t, []int64{0, 1, 2}, 1, 3)); err == nil || !stringsContains(err, "not finite") {
|
||||
t.Fatalf("a NaN vertex coordinate: %v", err)
|
||||
}
|
||||
// An odd number of Neumann edge indices: no complete pairs.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannEdges: []int{0, 1, 2}}); err == nil || !stringsContains(err, "pairs") {
|
||||
t.Fatalf("an odd Neumann edge count: %v", err)
|
||||
}
|
||||
// A degenerate Neumann edge a == b.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannEdges: []int{3, 3}}); err == nil || !stringsContains(err, "valid vertex pair") {
|
||||
t.Fatalf("a degenerate Neumann edge: %v", err)
|
||||
}
|
||||
// A Neumann edge index out of range.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannEdges: []int{0, 999}}); err == nil || !stringsContains(err, "valid vertex pair") {
|
||||
t.Fatalf("an out of range Neumann edge: %v", err)
|
||||
}
|
||||
// A KappaFunc returning a non-positive conductivity names the
|
||||
// triangle instead of assembling a singular stiffness matrix.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{
|
||||
KappaFunc: func(float64, float64) float64 { return -1 },
|
||||
DirichletNodes: []int{0},
|
||||
DirichletValues: []float64{0},
|
||||
}); err == nil || !stringsContains(err, "positive") {
|
||||
t.Fatalf("a non-positive KappaFunc value: %v", err)
|
||||
}
|
||||
// A KappaFunc returning an infinite conductivity names the triangle
|
||||
// the way the constant field's gate names itself, instead of
|
||||
// surfacing as a factorisation failure far from the cause.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{
|
||||
KappaFunc: func(float64, float64) float64 { return math.Inf(1) },
|
||||
DirichletNodes: []int{0},
|
||||
DirichletValues: []float64{0},
|
||||
}); err == nil || !stringsContains(err, "positive") {
|
||||
t.Fatalf("an infinite KappaFunc value: %v", err)
|
||||
}
|
||||
// A non-finite source value refuses the solve: it used to land in
|
||||
// the load and publish an all-NaN solution with a nil error.
|
||||
if _, err := SolvePoissonFEM2D(mesh, func(x, y float64) float64 { return math.NaN() },
|
||||
FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: make([]float64, len(boundary))}); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatalf("a NaN source value: %v", err)
|
||||
}
|
||||
// A non-finite Neumann flux refuses the solve the same way.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{
|
||||
Kappa: 1,
|
||||
DirichletNodes: boundary,
|
||||
DirichletValues: make([]float64, len(boundary)),
|
||||
NeumannEdges: []int{0, mesh.Vertices2() - 1},
|
||||
NeumannFlux: func(x, y float64) float64 { return math.Inf(1) },
|
||||
}); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatalf("an infinite Neumann flux: %v", err)
|
||||
}
|
||||
// An ordering that does not exist.
|
||||
if _, err := SolvePoissonFEM2D(mesh, nil, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: make([]float64, len(boundary)), Ordering: linalg.SparseOrdering(7)}); err == nil {
|
||||
t.Fatal("an unknown ordering was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM2DIsDeterministic solves the same problem twice
|
||||
// and requires bit-identical nodal values, the contract every Tensor
|
||||
// entry point carries.
|
||||
func TestSolvePoissonFEM2DIsDeterministic(t *testing.T) {
|
||||
solution := func(x, y float64) float64 { return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) }
|
||||
mesh, boundary := gridMesh(t, 10)
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = solution(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
|
||||
}
|
||||
source := func(x, y float64) float64 { return 2 * math.Pi * math.Pi * solution(x, y) }
|
||||
u1, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("first solve: %v", err)
|
||||
}
|
||||
u2, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("second solve: %v", err)
|
||||
}
|
||||
for i := range mesh.Vertices2() {
|
||||
if u1.FloatAt(i) != u2.FloatAt(i) {
|
||||
t.Fatalf("node %d differs: %.17g vs %.17g", i, u1.FloatAt(i), u2.FloatAt(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stringsContains(err error, fragment string) bool {
|
||||
return err != nil && len(err.Error()) >= len(fragment) && indexOf(err.Error(), fragment) >= 0
|
||||
}
|
||||
|
||||
func indexOf(s, fragment string) int {
|
||||
for i := 0; i+len(fragment) <= len(s); i++ {
|
||||
if s[i:i+len(fragment)] == fragment {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func floatsToArrayFEM(t *testing.T, vals []float64, shape ...int) *core.Array {
|
||||
t.Helper()
|
||||
a, err := core.FromFloats(vals, shape...)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func intsToArrayFEM(t *testing.T, vals []int64, shape ...int) *core.Array {
|
||||
t.Helper()
|
||||
a, err := core.FromInts(vals, shape...)
|
||||
if err != nil {
|
||||
t.Fatalf("FromInts: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM2DNeumannFlux pins the boundary-edge integrals:
|
||||
// u = (x²+y²)/2 has −Δu = −2 and the flux κ∂u/∂n = 1 along the right
|
||||
// and top edges' outward normals (0 along the bottom and left), so
|
||||
// prescribing those fluxes with a single pinned vertex must
|
||||
// reproduce the quadratic field. The midpoint edge rule is
|
||||
// first-order consistent, so the error must halve with the mesh.
|
||||
func TestSolvePoissonFEM2DNeumannFlux(t *testing.T) {
|
||||
field := func(x, y float64) float64 { return (x*x + y*y) / 2 }
|
||||
previous := 0.0
|
||||
for _, m := range []int{10, 20} {
|
||||
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, m, m)
|
||||
if err != nil {
|
||||
t.Fatalf("GridTriangleMesh2D: %v", err)
|
||||
}
|
||||
// Boundary edges: pairs of neighbouring boundary vertices.
|
||||
var edges []int
|
||||
at := func(i, j int) int { return j*(m+1) + i }
|
||||
for j := range m {
|
||||
edges = append(edges, at(j, 0), at(j+1, 0)) // bottom: flux 0
|
||||
edges = append(edges, at(j, m), at(j+1, m)) // top: flux 1
|
||||
edges = append(edges, at(m, j), at(m, j+1)) // right: flux 1
|
||||
edges = append(edges, at(0, j), at(0, j+1)) // left: flux 0
|
||||
}
|
||||
flux := func(x, y float64) float64 {
|
||||
if x == 1 || y == 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
u, err := SolvePoissonFEM2D(mesh, func(float64, float64) float64 { return -2 },
|
||||
FEMPoissonOptions{
|
||||
Kappa: 1,
|
||||
DirichletNodes: []int{at(0, 0)},
|
||||
DirichletValues: []float64{0},
|
||||
NeumannEdges: edges,
|
||||
NeumannFlux: flux,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("m=%d: %v", m, err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices2() {
|
||||
x := mesh.Vertices[2*i]
|
||||
y := mesh.Vertices[2*i+1]
|
||||
if d := math.Abs(u.FloatAt(i) - field(x, y)); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("m=%2d: max nodal error %.3g", m, worst)
|
||||
if previous > 0 && previous/worst < 1.4 {
|
||||
t.Fatalf("m=%d: refinement ratio %.2f, want the first-order flux rate", m, previous/worst)
|
||||
}
|
||||
if m == 20 && worst > 5e-3 {
|
||||
t.Fatalf("m=20: error %.3g too large", worst)
|
||||
}
|
||||
previous = worst
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM2DVariableKappa runs the manufactured solution
|
||||
// with a spatially varying conductivity evaluated at the element
|
||||
// centroids: f must carry the analytic divergence terms, and the
|
||||
// P1 convergence rate must survive the varying coefficient.
|
||||
func TestSolvePoissonFEM2DVariableKappa(t *testing.T) {
|
||||
sin, cos := math.Pi, math.Pi
|
||||
u := func(x, y float64) float64 { return math.Sin(sin*x) * math.Sin(sin*y) }
|
||||
kappaF := func(x, y float64) float64 { return 1 + x*y }
|
||||
ux := func(x, y float64) float64 { return cos * math.Cos(cos*x) * math.Sin(cos*y) }
|
||||
uy := func(x, y float64) float64 { return cos * math.Sin(cos*x) * math.Cos(cos*y) }
|
||||
lap := func(x, y float64) float64 { return -2 * math.Pi * math.Pi * u(x, y) }
|
||||
source := func(x, y float64) float64 {
|
||||
k := kappaF(x, y)
|
||||
return -(y*ux(x, y) + x*uy(x, y) + k*lap(x, y))
|
||||
}
|
||||
previous := 0.0
|
||||
for _, m := range []int{8, 16, 32} {
|
||||
mesh, boundary := gridMesh(t, m)
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = u(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
|
||||
}
|
||||
uk, err := SolvePoissonFEM2D(mesh, source,
|
||||
FEMPoissonOptions{KappaFunc: kappaF, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM2D(m=%d): %v", m, err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices2() {
|
||||
if d := math.Abs(uk.FloatAt(i) - u(mesh.Vertices[2*i], mesh.Vertices[2*i+1])); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("m=%2d: max nodal error %.3g", m, worst)
|
||||
if previous > 0 && previous/worst < 2.5 {
|
||||
t.Fatalf("m=%d: refinement ratio %.2f, want the O(h²) rate", m, previous/worst)
|
||||
}
|
||||
previous = worst
|
||||
}
|
||||
}
|
||||
|
||||
// TestTriangleMesh2DBoundaryEdges pins the boundary-edge detection:
|
||||
// the m by n grid carries exactly 2(m+n) boundary edges, every one of
|
||||
// them with both endpoints on the boundary vertex ring.
|
||||
func TestTriangleMesh2DBoundaryEdges(t *testing.T) {
|
||||
mesh, err := GridTriangleMesh2D(0, 0, 1, 1, 5, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GridTriangleMesh2D: %v", err)
|
||||
}
|
||||
edges := mesh.BoundaryEdges()
|
||||
if len(edges) != 2*2*(5+3) {
|
||||
t.Fatalf("boundary edge count %d, want %d", len(edges), 2*(5+3))
|
||||
}
|
||||
onBoundary := func(v int) bool {
|
||||
i := v % 6
|
||||
j := v / 6
|
||||
return i == 0 || i == 5 || j == 0 || j == 3
|
||||
}
|
||||
for p := 0; p < len(edges); p += 2 {
|
||||
if !onBoundary(edges[p]) || !onBoundary(edges[p+1]) {
|
||||
t.Fatalf("edge [%d,%d] is not on the boundary", edges[p], edges[p+1])
|
||||
}
|
||||
}
|
||||
// The generator's vertex positions are exact.
|
||||
mesh2, err := GridTriangleMesh2D(-1, 2, 2, 4, 2, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("GridTriangleMesh2D: %v", err)
|
||||
}
|
||||
if mesh2.Vertices[0] != -1 || mesh2.Vertices[1] != 2 {
|
||||
t.Fatalf("vertex 0 = [%g %g], want [-1 2]", mesh2.Vertices[0], mesh2.Vertices[1])
|
||||
}
|
||||
if mesh2.Vertices[2*(2*3+2)] != 1 || mesh2.Vertices[2*(2*3+2)+1] != 6 {
|
||||
t.Fatalf("vertex (2,2) = [%g %g], want [1 6]",
|
||||
mesh2.Vertices[2*(2*3+2)], mesh2.Vertices[2*(2*3+2)+1])
|
||||
}
|
||||
if _, err := GridTriangleMesh2D(0, 0, 1, 1, 0, 3); err == nil {
|
||||
t.Fatal("a zero cell count was accepted")
|
||||
}
|
||||
if _, err := GridTriangleMesh2D(0, 0, -1, 1, 2, 2); err == nil {
|
||||
t.Fatal("a negative extent was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM2DDuplicateDirichletNode pins the documented rule
|
||||
// for a node listed more than once: the last value is the prescribed
|
||||
// one and the node enters the assembled system exactly once, so the
|
||||
// repeated listing answers what the single listing with that value
|
||||
// answers. Recording it twice appends a second unit row at the same
|
||||
// coordinate, which the sparse conversion merges by summing, so the
|
||||
// node's diagonal doubles and the solve halves its prescribed value.
|
||||
func TestSolvePoissonFEM2DDuplicateDirichletNode(t *testing.T) {
|
||||
solution := func(x, y float64) float64 { return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) }
|
||||
source := func(x, y float64) float64 { return 2 * math.Pi * math.Pi * solution(x, y) }
|
||||
mesh, boundary := gridMesh(t, 4)
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = solution(mesh.Vertices[2*node], mesh.Vertices[2*node+1])
|
||||
}
|
||||
// The list names the second boundary node again at the end, with a
|
||||
// different value: the last one wins and the node stays single.
|
||||
const extra = 0.5
|
||||
nodes := append(append([]int(nil), boundary...), boundary[1])
|
||||
dupValues := append(append([]float64(nil), values...), values[1]+extra)
|
||||
u, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: nodes, DirichletValues: dupValues})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM2D with a repeated node: %v", err)
|
||||
}
|
||||
single := append([]float64(nil), values...)
|
||||
single[1] += extra
|
||||
want, err := SolvePoissonFEM2D(mesh, source, FEMPoissonOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: single})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM2D with the node once: %v", err)
|
||||
}
|
||||
if got := u.FloatAt(boundary[1]); math.Abs(got-(values[1]+extra)) > 1e-12 {
|
||||
t.Fatalf("the repeated node answered %g, want the last prescribed value %g", got, values[1]+extra)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices2() {
|
||||
worst = math.Max(worst, math.Abs(u.FloatAt(i)-want.FloatAt(i)))
|
||||
}
|
||||
if worst > 1e-12 {
|
||||
t.Fatalf("the repeated listing differs from the single listing by %g, want the node recorded once", worst)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSolvePoissonFEM2DNilMeshRefused pins the nil-mesh refusal: the
|
||||
// three-dimensional solve reports a nil mesh as an error, so the
|
||||
// two-dimensional one answers the same way instead of dereferencing
|
||||
// it and panicking.
|
||||
func TestSolvePoissonFEM2DNilMeshRefused(t *testing.T) {
|
||||
_, err := SolvePoissonFEM2D(nil, nil, FEMPoissonOptions{
|
||||
Kappa: 1,
|
||||
DirichletNodes: []int{0},
|
||||
DirichletValues: []float64{0},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("SolvePoissonFEM2D: expected an error for a nil mesh")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
linalg "sourcedock.dev/petrbalvin/tensor/linalg"
|
||||
)
|
||||
|
||||
// The finite element groundwork for second-order problems in three
|
||||
// dimensions, the volumetric sibling of the triangular surface in
|
||||
// fem2d.go: piecewise-linear (P1) elements on a conforming
|
||||
// tetrahedral mesh, the stiffness matrix assembled per tetrahedron
|
||||
// from the gradient-of-basis formula over the element's edge vectors,
|
||||
// the load integrated per element with a collapsed Gauss rule,
|
||||
// Dirichlet values eliminated by lifting, Neumann fluxes integrated
|
||||
// on prescribed boundary faces, and the reduced system handed to the
|
||||
// same sparse Cholesky factorisation the two-dimensional path uses.
|
||||
|
||||
// TetraMesh3D carries a conforming tetrahedral mesh: vertex
|
||||
// coordinates as x,y,z triples and tetrahedra as quadruples of vertex
|
||||
// indices in positive orientation, meaning the signed volume
|
||||
// (b−a)·((c−a)×(d−a)) of every stored tetrahedron is positive. A
|
||||
// tetrahedron with zero volume or negative orientation does matter
|
||||
// and is refused at construction.
|
||||
type TetraMesh3D struct {
|
||||
// Vertices holds x,y,z for every vertex: three entries per vertex.
|
||||
Vertices []float64
|
||||
// Tetrahedra holds four vertex indices per tetrahedron.
|
||||
Tetrahedra []int64
|
||||
}
|
||||
|
||||
// NewTetraMesh3D builds a mesh from a vertex table with three columns
|
||||
// and a tetrahedron table with four columns of vertex indices.
|
||||
// Indices must lie in range, every coordinate must be finite, and a
|
||||
// degenerate (zero-volume) or inverted (negative-orientation)
|
||||
// tetrahedron is an error naming the element and its vertices: its
|
||||
// stiffness contribution is undefined.
|
||||
func NewTetraMesh3D(vertices *core.Array, tetrahedra *core.Array) (*TetraMesh3D, error) {
|
||||
const name = "NewTetraMesh3D"
|
||||
if vertices.Dtype() == core.Complex || tetrahedra.Dtype() == core.Complex {
|
||||
return nil, base.Errf("%s: complex mesh data is not supported", name)
|
||||
}
|
||||
if vertices.NDim() != 2 || vertices.Shape()[1] != 3 {
|
||||
return nil, base.Errf("%s: the vertex table must be rank 2 with three columns, got shape %s",
|
||||
name, base.ShapeText(vertices.Shape()))
|
||||
}
|
||||
if tetrahedra.Dtype() != core.Int {
|
||||
return nil, base.Errf("%s: the tetrahedron table must hold integers, got %s", name, tetrahedra.Dtype())
|
||||
}
|
||||
if tetrahedra.NDim() != 2 || tetrahedra.Shape()[1] != 4 {
|
||||
return nil, base.Errf("%s: the tetrahedron table must be rank 2 with four columns, got shape %s",
|
||||
name, base.ShapeText(tetrahedra.Shape()))
|
||||
}
|
||||
n := vertices.Shape()[0]
|
||||
m := tetrahedra.Shape()[0]
|
||||
if n < 4 {
|
||||
return nil, base.Errf("%s: a mesh needs at least four vertices, got %d", name, n)
|
||||
}
|
||||
if m == 0 {
|
||||
// An empty tetrahedron table would surface deep in the sparse
|
||||
// factorisation on the zero rows of the free nodes, far from
|
||||
// the mesh that caused it.
|
||||
return nil, base.Errf("%s: the tetrahedron table must not be empty", name)
|
||||
}
|
||||
mesh := &TetraMesh3D{Vertices: make([]float64, 3*n), Tetrahedra: make([]int64, 4*m)}
|
||||
for i := range 3 * n {
|
||||
v := vertices.FloatAt(i)
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil, base.Errf("%s: vertex coordinate %d is not finite", name, i)
|
||||
}
|
||||
mesh.Vertices[i] = v
|
||||
}
|
||||
for q := range 4 * m {
|
||||
idx := tetrahedra.RawInts()[q]
|
||||
if idx < 0 || idx >= int64(n) {
|
||||
return nil, base.Errf("%s: tetrahedron vertex index %d out of range for %d vertices", name, idx, n)
|
||||
}
|
||||
mesh.Tetrahedra[q] = idx
|
||||
}
|
||||
// Orientation and volume are checked where the caller can name the
|
||||
// tetrahedron and its vertices, not mid-assembly. Both messages
|
||||
// carry the coordinates, so a mis-ordered table can be fixed
|
||||
// without reopening a mesh debugger.
|
||||
for t := range m {
|
||||
a, b, c, d := int(mesh.Tetrahedra[4*t]), int(mesh.Tetrahedra[4*t+1]), int(mesh.Tetrahedra[4*t+2]), int(mesh.Tetrahedra[4*t+3])
|
||||
ax, ay, az := mesh.Vertices[3*a], mesh.Vertices[3*a+1], mesh.Vertices[3*a+2]
|
||||
bx, by, bz := mesh.Vertices[3*b], mesh.Vertices[3*b+1], mesh.Vertices[3*b+2]
|
||||
cx, cy, cz := mesh.Vertices[3*c], mesh.Vertices[3*c+1], mesh.Vertices[3*c+2]
|
||||
dx, dy, dz := mesh.Vertices[3*d], mesh.Vertices[3*d+1], mesh.Vertices[3*d+2]
|
||||
signed6 := signedTetraVolume(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz)
|
||||
at := func(v int) string {
|
||||
return fmt.Sprintf("(%g, %g, %g)", mesh.Vertices[3*v], mesh.Vertices[3*v+1], mesh.Vertices[3*v+2])
|
||||
}
|
||||
verts := fmt.Sprintf("vertices %d %s, %d %s, %d %s, %d %s", a, at(a), b, at(b), c, at(c), d, at(d))
|
||||
if signed6 == 0 {
|
||||
return nil, base.Errf("%s: tetrahedron %d is degenerate (zero volume), %s", name, t, verts)
|
||||
}
|
||||
if signed6 < 0 {
|
||||
return nil, base.Errf("%s: tetrahedron %d is inverted (signed volume %g), %s", name, t, signed6/6, verts)
|
||||
}
|
||||
}
|
||||
return mesh, nil
|
||||
}
|
||||
|
||||
// Vertices3 returns the vertex count.
|
||||
func (m *TetraMesh3D) Vertices3() int { return len(m.Vertices) / 3 }
|
||||
|
||||
// Tetrahedra4 returns the tetrahedron count.
|
||||
func (m *TetraMesh3D) Tetrahedra4() int { return len(m.Tetrahedra) / 4 }
|
||||
|
||||
// signedTetraVolume returns six times the signed volume of the
|
||||
// tetrahedron (a, b, c, d): positive for the orientation the mesh
|
||||
// stores, negative when the last two vertices are swapped, zero when
|
||||
// the four points are coplanar.
|
||||
func signedTetraVolume(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz float64) float64 {
|
||||
u := [3]float64{bx - ax, by - ay, bz - az}
|
||||
v := [3]float64{cx - ax, cy - ay, cz - az}
|
||||
w := [3]float64{dx - ax, dy - ay, dz - az}
|
||||
cross := [3]float64{v[1]*w[2] - v[2]*w[1], v[2]*w[0] - v[0]*w[2], v[0]*w[1] - v[1]*w[0]}
|
||||
return u[0]*cross[0] + u[1]*cross[1] + u[2]*cross[2]
|
||||
}
|
||||
|
||||
// BoundaryFaces returns the mesh's boundary faces as flat triples of
|
||||
// vertex indices: a face belongs to the boundary when exactly one
|
||||
// tetrahedron carries it. The triples are sorted lexicographically,
|
||||
// so the result is a pure function of the mesh.
|
||||
func (m *TetraMesh3D) BoundaryFaces() []int {
|
||||
count := make(map[[3]int]int, len(m.Tetrahedra))
|
||||
key := func(a, b, c int) [3]int {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
if b > c {
|
||||
b, c = c, b
|
||||
}
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return [3]int{a, b, c}
|
||||
}
|
||||
for t := 0; t < m.Tetrahedra4(); t++ {
|
||||
a, b, c, d := int(m.Tetrahedra[4*t]), int(m.Tetrahedra[4*t+1]), int(m.Tetrahedra[4*t+2]), int(m.Tetrahedra[4*t+3])
|
||||
count[key(a, b, c)]++
|
||||
count[key(a, b, d)]++
|
||||
count[key(a, c, d)]++
|
||||
count[key(b, c, d)]++
|
||||
}
|
||||
sets := make([][3]int, 0, len(count))
|
||||
for f, n := range count {
|
||||
if n == 1 {
|
||||
sets = append(sets, f)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(sets, func(x, y [3]int) int {
|
||||
for k := range 3 {
|
||||
if x[k] != y[k] {
|
||||
return x[k] - y[k]
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
faces := make([]int, 0, 3*len(sets))
|
||||
for _, f := range sets {
|
||||
faces = append(faces, f[0], f[1], f[2])
|
||||
}
|
||||
return faces
|
||||
}
|
||||
|
||||
// BoxTetraMesh3D builds the structured tetrahedralisation of the
|
||||
// axis-aligned box [x0, x0+width] × [y0, y0+height] × [z0, z0+depth]
|
||||
// with m by n by p cells, six tetrahedra per cell (the Kuhn
|
||||
// subdivision along the cell diagonal, oriented positively). m, n and
|
||||
// p must all be positive. The subdivision is conforming across cell
|
||||
// faces, which makes the mesher the first port of call for tests and
|
||||
// for boxes in general.
|
||||
func BoxTetraMesh3D(x0, y0, z0, width, height, depth float64, m, n, p int) (*TetraMesh3D, error) {
|
||||
const name = "BoxTetraMesh3D"
|
||||
if m <= 0 || n <= 0 || p <= 0 {
|
||||
return nil, base.Errf("%s: the cell counts must be positive, got %d by %d by %d", name, m, n, p)
|
||||
}
|
||||
// The same guard the triangle mesher applies: a non-finite extent
|
||||
// or origin would lay out vertices at NaN or Inf and only surface
|
||||
// mid-factorisation, far from the cause.
|
||||
if !(width > 0) || !(height > 0) || !(depth > 0) ||
|
||||
math.IsInf(width, 0) || math.IsInf(height, 0) || math.IsInf(depth, 0) ||
|
||||
math.IsNaN(x0) || math.IsInf(x0, 0) || math.IsNaN(y0) || math.IsInf(y0, 0) || math.IsNaN(z0) || math.IsInf(z0, 0) {
|
||||
return nil, base.Errf("%s: the extents must be finite and positive and the origin finite, got origin (%g, %g, %g), extents %g by %g by %g",
|
||||
name, x0, y0, z0, width, height, depth)
|
||||
}
|
||||
vertices := make([]float64, 3*(m+1)*(n+1)*(p+1))
|
||||
for k := range p + 1 {
|
||||
for j := range n + 1 {
|
||||
for i := range m + 1 {
|
||||
v := 3 * ((k*(n+1)+j)*(m+1) + i)
|
||||
vertices[v] = x0 + width*float64(i)/float64(m)
|
||||
vertices[v+1] = y0 + height*float64(j)/float64(n)
|
||||
vertices[v+2] = z0 + depth*float64(k)/float64(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
at := func(i, j, k int) int64 { return int64((k*(n+1)+j)*(m+1) + i) }
|
||||
// The six Kuhn paths from one cell corner to the opposite one,
|
||||
// given as axis orders. An odd permutation reaches the far corner
|
||||
// with negative orientation, so its last two vertices swap.
|
||||
perms := [6][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
|
||||
tetrahedra := make([]int64, 0, 6*m*n*p)
|
||||
for k := range p {
|
||||
for j := range n {
|
||||
for i := range m {
|
||||
for _, pm := range perms {
|
||||
// The path walks from the cell corner to the far
|
||||
// corner, each vertex one axis-step beyond the
|
||||
// previous one.
|
||||
ox := [4]int{i, i, i, i}
|
||||
oy := [4]int{j, j, j, j}
|
||||
oz := [4]int{k, k, k, k}
|
||||
for s := range 3 {
|
||||
ox[s+1], oy[s+1], oz[s+1] = ox[s], oy[s], oz[s]
|
||||
switch pm[s] {
|
||||
case 0:
|
||||
ox[s+1]++
|
||||
case 1:
|
||||
oy[s+1]++
|
||||
default:
|
||||
oz[s+1]++
|
||||
}
|
||||
}
|
||||
odd := 0
|
||||
for s1 := range 3 {
|
||||
for s2 := s1 + 1; s2 < 3; s2++ {
|
||||
if pm[s1] > pm[s2] {
|
||||
odd++
|
||||
}
|
||||
}
|
||||
}
|
||||
v := [4]int64{at(ox[0], oy[0], oz[0]), at(ox[1], oy[1], oz[1]), at(ox[2], oy[2], oz[2]), at(ox[3], oy[3], oz[3])}
|
||||
if odd%2 == 1 {
|
||||
v[2], v[3] = v[3], v[2]
|
||||
}
|
||||
tetrahedra = append(tetrahedra, v[0], v[1], v[2], v[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &TetraMesh3D{Vertices: vertices, Tetrahedra: tetrahedra}, nil
|
||||
}
|
||||
|
||||
// tetraGradients returns the gradients of the four P1 basis functions
|
||||
// on the tetrahedron (a, b, c, d) and its volume. The gradients are
|
||||
// the columns of the inverse of the edge matrix whose rows are the
|
||||
// vectors from d to a, b and c, which is the standard
|
||||
// gradient-of-basis formula over the element's edge vectors.
|
||||
func tetraGradients(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz float64) (g [4][3]float64, volume float64) {
|
||||
// Rows of the edge matrix relative to d.
|
||||
r0 := [3]float64{ax - dx, ay - dy, az - dz}
|
||||
r1 := [3]float64{bx - dx, by - dy, bz - dz}
|
||||
r2 := [3]float64{cx - dx, cy - dy, cz - dz}
|
||||
// Cofactors of the edge matrix; the inverse is their transpose
|
||||
// over the determinant, so column j of the inverse is row j of the
|
||||
// cofactor matrix over det.
|
||||
c00 := r1[1]*r2[2] - r1[2]*r2[1]
|
||||
c01 := -(r1[0]*r2[2] - r1[2]*r2[0])
|
||||
c02 := r1[0]*r2[1] - r1[1]*r2[0]
|
||||
c10 := -(r0[1]*r2[2] - r0[2]*r2[1])
|
||||
c11 := r0[0]*r2[2] - r0[2]*r2[0]
|
||||
c12 := -(r0[0]*r2[1] - r0[1]*r2[0])
|
||||
c20 := r0[1]*r1[2] - r0[2]*r1[1]
|
||||
c21 := -(r0[0]*r1[2] - r0[2]*r1[0])
|
||||
c22 := r0[0]*r1[1] - r0[1]*r1[0]
|
||||
det := r0[0]*c00 + r0[1]*c01 + r0[2]*c02
|
||||
g[0] = [3]float64{c00 / det, c01 / det, c02 / det}
|
||||
g[1] = [3]float64{c10 / det, c11 / det, c12 / det}
|
||||
g[2] = [3]float64{c20 / det, c21 / det, c22 / det}
|
||||
for i := range 3 {
|
||||
for k := range 3 {
|
||||
g[3][k] -= g[i][k]
|
||||
}
|
||||
}
|
||||
volume = math.Abs(signedTetraVolume(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz)) / 6
|
||||
return g, volume
|
||||
}
|
||||
|
||||
// tetraStiffness returns the P1 stiffness matrix of one tetrahedron:
|
||||
// K[i][j] = κ·V·(∇λᵢ·∇λⱼ), the gradient-of-basis formula integrated
|
||||
// over the element, where the gradients are constant on a linear
|
||||
// element.
|
||||
func tetraStiffness(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz, kappa float64) [4][4]float64 {
|
||||
g, volume := tetraGradients(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz)
|
||||
var k [4][4]float64
|
||||
for i := range 4 {
|
||||
for j := range 4 {
|
||||
k[i][j] = kappa * volume * (g[i][0]*g[j][0] + g[i][1]*g[j][1] + g[i][2]*g[j][2])
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// FEMPoisson3DOptions carries the data SolvePoissonFEM3D needs beside
|
||||
// the mesh and the source: the conductivity, the prescribed boundary
|
||||
// values, and the optional flux boundary.
|
||||
type FEMPoisson3DOptions struct {
|
||||
// Kappa is the constant conductivity when KappaFunc is nil. It
|
||||
// must be positive.
|
||||
Kappa float64
|
||||
// KappaFunc, when set, gives the conductivity at a point. It is
|
||||
// evaluated at the tetrahedron centroids and must be positive
|
||||
// there for every element; a non-positive value names the element.
|
||||
KappaFunc func(x, y, z float64) float64
|
||||
// DirichletNodes lists the vertices with prescribed values and
|
||||
// DirichletValues the values in the same order. The nodes leave
|
||||
// the system with their rows and columns; at least one is
|
||||
// required, because a purely Neumann problem has no unique
|
||||
// solution.
|
||||
DirichletNodes []int
|
||||
DirichletValues []float64
|
||||
// NeumannFaces lists boundary faces as flat triples of vertex
|
||||
// indices and NeumannFlux gives the flux κ∂u/∂n along each face's
|
||||
// outward normal: each face's integral is built from the degree-2
|
||||
// edge-midpoint rule, a third of area·flux at each edge midpoint
|
||||
// shared by that edge's two vertices. A nil flux means zero.
|
||||
NeumannFaces []int
|
||||
NeumannFlux func(x, y, z float64) float64
|
||||
// Ordering selects the fill-reducing permutation for the sparse
|
||||
// Cholesky factorisation. The zero value is the natural order;
|
||||
// meshes usually want SparseOrderingReverseCuthillMcKee.
|
||||
Ordering linalg.SparseOrdering
|
||||
}
|
||||
|
||||
// SolvePoissonFEM3D solves −∇·(κ∇u) = f on the tetrahedral mesh with
|
||||
// piecewise-linear elements: the stiffness matrix is assembled per
|
||||
// tetrahedron (the conductivity evaluated at the centroids when it
|
||||
// varies), the load is integrated per tetrahedron with the 3×3×3
|
||||
// collapsed Gauss rule (exact through degree 5; the centroid lump
|
||||
// does not hold the O(h²) rate on the structured Kuhn mesh), Neumann
|
||||
// fluxes are integrated on their boundary faces with the degree-2
|
||||
// edge-midpoint rule, and Dirichlet values are eliminated by lifting.
|
||||
// f may be nil for the homogeneous equation. The error contract
|
||||
// mirrors SolvePoissonFEM2D.
|
||||
func SolvePoissonFEM3D(mesh *TetraMesh3D, f func(x, y, z float64) float64, opts FEMPoisson3DOptions) (*core.Array, error) {
|
||||
const name = "SolvePoissonFEM3D"
|
||||
if mesh == nil {
|
||||
return nil, base.Errf("%s: the mesh must not be nil", name)
|
||||
}
|
||||
// The same conductivity gate as the two-dimensional solve: with
|
||||
// KappaFunc nil the constant is the value used, so it must be
|
||||
// positive and finite; with the field set the constant is a
|
||||
// placeholder, but a non-finite one is still refused.
|
||||
if opts.KappaFunc == nil {
|
||||
if !(opts.Kappa > 0) || math.IsInf(opts.Kappa, 0) {
|
||||
return nil, base.Errf("%s: the conductivity must be positive, got %g", name, opts.Kappa)
|
||||
}
|
||||
} else if math.IsNaN(opts.Kappa) || math.IsInf(opts.Kappa, 0) {
|
||||
return nil, base.Errf("%s: the conductivity must be positive, got %g", name, opts.Kappa)
|
||||
}
|
||||
if len(opts.DirichletNodes) != len(opts.DirichletValues) {
|
||||
return nil, base.Errf("%s: %d Dirichlet nodes but %d values", name, len(opts.DirichletNodes), len(opts.DirichletValues))
|
||||
}
|
||||
if len(opts.DirichletNodes) == 0 {
|
||||
return nil, base.Errf("%s: a purely Neumann problem has no unique solution; prescribe at least one Dirichlet value", name)
|
||||
}
|
||||
n := mesh.Vertices3()
|
||||
// The Dirichlet nodes as a dense marker with their prescribed
|
||||
// values, exactly as the two-dimensional solve carries them: the
|
||||
// lifting and the unit rows each visit every assembled entry, and a
|
||||
// marker answers those visits in constant time where a set of nodes
|
||||
// answered with a hash. A node listed twice keeps its last value
|
||||
// and appears once, as it did in the set; the appended order does
|
||||
// not reach the assembled system, whose coordinate entries the
|
||||
// sparse conversion sorts and merges by coordinate.
|
||||
dirichletMark := make([]bool, n)
|
||||
dirichletVal := make([]float64, n)
|
||||
dirichletNodes := make([]int, 0, len(opts.DirichletNodes))
|
||||
for p, d := range opts.DirichletNodes {
|
||||
if d < 0 || d >= n {
|
||||
return nil, base.Errf("%s: Dirichlet node %d out of range for %d vertices", name, d, n)
|
||||
}
|
||||
v := opts.DirichletValues[p]
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil, base.Errf("%s: Dirichlet value at node %d is not finite", name, d)
|
||||
}
|
||||
if !dirichletMark[d] {
|
||||
dirichletNodes = append(dirichletNodes, d)
|
||||
}
|
||||
dirichletMark[d] = true
|
||||
dirichletVal[d] = v
|
||||
}
|
||||
if len(opts.NeumannFaces)%3 != 0 {
|
||||
return nil, base.Errf("%s: %d Neumann face indices, want triples", name, len(opts.NeumannFaces))
|
||||
}
|
||||
for p := 0; p < len(opts.NeumannFaces); p += 3 {
|
||||
for _, v := range opts.NeumannFaces[p : p+3] {
|
||||
if v < 0 || v >= n {
|
||||
return nil, base.Errf("%s: Neumann face [%d %d %d] holds the out-of-range vertex %d",
|
||||
name, opts.NeumannFaces[p], opts.NeumannFaces[p+1], opts.NeumannFaces[p+2], v)
|
||||
}
|
||||
}
|
||||
if opts.NeumannFaces[p] == opts.NeumannFaces[p+1] ||
|
||||
opts.NeumannFaces[p] == opts.NeumannFaces[p+2] ||
|
||||
opts.NeumannFaces[p+1] == opts.NeumannFaces[p+2] {
|
||||
return nil, base.Errf("%s: Neumann face [%d %d %d] repeats a vertex",
|
||||
name, opts.NeumannFaces[p], opts.NeumannFaces[p+1], opts.NeumannFaces[p+2])
|
||||
}
|
||||
}
|
||||
// Assembly: sixteen entries per tetrahedron, symmetric by
|
||||
// construction, with the conductivity evaluated at the centroid
|
||||
// when it varies.
|
||||
entries := make([]float64, 0, 16*mesh.Tetrahedra4())
|
||||
rows := make([]int, 0, 16*mesh.Tetrahedra4())
|
||||
cols := make([]int, 0, 16*mesh.Tetrahedra4())
|
||||
load := make([]float64, n)
|
||||
// The collapsed Gauss rule's abscissae and weights are constants of
|
||||
// the scheme: built once here, not per tetrahedron.
|
||||
gl := [3]float64{(1 - math.Sqrt(3.0/5)) / 2, 0.5, (1 + math.Sqrt(3.0/5)) / 2}
|
||||
gw := [3]float64{5.0 / 18, 4.0 / 9, 5.0 / 18}
|
||||
for t := 0; t < mesh.Tetrahedra4(); t++ {
|
||||
a, b, c, d := int(mesh.Tetrahedra[4*t]), int(mesh.Tetrahedra[4*t+1]), int(mesh.Tetrahedra[4*t+2]), int(mesh.Tetrahedra[4*t+3])
|
||||
ax, ay, az := mesh.Vertices[3*a], mesh.Vertices[3*a+1], mesh.Vertices[3*a+2]
|
||||
bx, by, bz := mesh.Vertices[3*b], mesh.Vertices[3*b+1], mesh.Vertices[3*b+2]
|
||||
cx, cy, cz := mesh.Vertices[3*c], mesh.Vertices[3*c+1], mesh.Vertices[3*c+2]
|
||||
dx, dy, dz := mesh.Vertices[3*d], mesh.Vertices[3*d+1], mesh.Vertices[3*d+2]
|
||||
volume := math.Abs(signedTetraVolume(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz)) / 6
|
||||
if volume == 0 {
|
||||
return nil, base.Errf("%s: tetrahedron %d is degenerate (zero volume)", name, t)
|
||||
}
|
||||
kappa := opts.Kappa
|
||||
if opts.KappaFunc != nil {
|
||||
kappa = opts.KappaFunc((ax+bx+cx+dx)/4, (ay+by+cy+dy)/4, (az+bz+cz+dz)/4)
|
||||
if !(kappa > 0) || math.IsNaN(kappa) || math.IsInf(kappa, 0) {
|
||||
return nil, base.Errf("%s: the conductivity at tetrahedron %d is %g, want positive", name, t, kappa)
|
||||
}
|
||||
}
|
||||
k := tetraStiffness(ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz, kappa)
|
||||
nodes := [4]int{a, b, c, d}
|
||||
for i := range 4 {
|
||||
for j := range 4 {
|
||||
rows = append(rows, nodes[i])
|
||||
cols = append(cols, nodes[j])
|
||||
entries = append(entries, k[i][j])
|
||||
}
|
||||
}
|
||||
// The load on this element, integrated with the 3×3×3
|
||||
// collapsed Gauss rule: λ weights follow the Duffy collapse
|
||||
// toward vertex a, and the Jacobian of the map from the unit
|
||||
// cube is (1−r)²(1−s)·6V.
|
||||
if f != nil {
|
||||
for ir := range 3 {
|
||||
for is := range 3 {
|
||||
for it := range 3 {
|
||||
r, s, t := gl[ir], gl[is], gl[it]
|
||||
la := (1 - r) * (1 - s) * (1 - t)
|
||||
lb := (1 - r) * (1 - s) * t
|
||||
lc := (1 - r) * s
|
||||
ld := r
|
||||
x := la*ax + lb*bx + lc*cx + ld*dx
|
||||
y := la*ay + lb*by + lc*cy + ld*dy
|
||||
z := la*az + lb*bz + lc*cz + ld*dz
|
||||
w := gw[ir] * gw[is] * gw[it] * (1 - r) * (1 - r) * (1 - s) * 6 * volume
|
||||
fv := f(x, y, z)
|
||||
// A non-finite source value would flow into the
|
||||
// load and the solve would publish an all-NaN
|
||||
// solution with a nil error, the breach every
|
||||
// other integrator here refuses up front.
|
||||
if math.IsNaN(fv) || math.IsInf(fv, 0) {
|
||||
return nil, base.Errf("%s: the source returned the non-finite value %g at tetrahedron %d", name, fv, t)
|
||||
}
|
||||
load[a] += w * fv * la
|
||||
load[b] += w * fv * lb
|
||||
load[c] += w * fv * lc
|
||||
load[d] += w * fv * ld
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Neumann fluxes: the degree-2 edge-midpoint rule on every listed
|
||||
// face, a third of area·flux at each edge midpoint into that
|
||||
// edge's two vertices.
|
||||
if len(opts.NeumannFaces) > 0 && opts.NeumannFlux != nil {
|
||||
for p := 0; p < len(opts.NeumannFaces); p += 3 {
|
||||
a, b, c := opts.NeumannFaces[p], opts.NeumannFaces[p+1], opts.NeumannFaces[p+2]
|
||||
ax, ay, az := mesh.Vertices[3*a], mesh.Vertices[3*a+1], mesh.Vertices[3*a+2]
|
||||
bx, by, bz := mesh.Vertices[3*b], mesh.Vertices[3*b+1], mesh.Vertices[3*b+2]
|
||||
cx, cy, cz := mesh.Vertices[3*c], mesh.Vertices[3*c+1], mesh.Vertices[3*c+2]
|
||||
u := [3]float64{bx - ax, by - ay, bz - az}
|
||||
v := [3]float64{cx - ax, cy - ay, cz - az}
|
||||
cross := [3]float64{u[1]*v[2] - u[2]*v[1], u[2]*v[0] - u[0]*v[2], u[0]*v[1] - u[1]*v[0]}
|
||||
area := math.Sqrt(cross[0]*cross[0]+cross[1]*cross[1]+cross[2]*cross[2]) / 2
|
||||
w := area / 3
|
||||
// A non-finite flux lands in the load like a non-finite
|
||||
// source, so the same refusal answers it, naming the face.
|
||||
fab := w * opts.NeumannFlux((ax+bx)/2, (ay+by)/2, (az+bz)/2)
|
||||
fbc := w * opts.NeumannFlux((bx+cx)/2, (by+cy)/2, (bz+cz)/2)
|
||||
fca := w * opts.NeumannFlux((cx+ax)/2, (cy+ay)/2, (cz+az)/2)
|
||||
for _, fv := range []float64{fab, fbc, fca} {
|
||||
if math.IsNaN(fv) || math.IsInf(fv, 0) {
|
||||
return nil, base.Errf("%s: the Neumann flux returned a non-finite value on face [%d %d %d]", name, a, b, c)
|
||||
}
|
||||
}
|
||||
load[a] += fab/2 + fca/2
|
||||
load[b] += fab/2 + fbc/2
|
||||
load[c] += fbc/2 + fca/2
|
||||
}
|
||||
}
|
||||
// Dirichlet lifting: the known boundary values move to the right
|
||||
// hand side, then their rows and columns leave the system as unit
|
||||
// rows, exactly as in the two-dimensional solve.
|
||||
for p, i := range rows {
|
||||
if j := cols[p]; dirichletMark[j] {
|
||||
load[i] -= entries[p] * dirichletVal[j]
|
||||
}
|
||||
}
|
||||
keptRows := make([]int64, 0, len(rows))
|
||||
keptCols := make([]int64, 0, len(rows))
|
||||
keptVals := make([]float64, 0, len(rows))
|
||||
for p := range rows {
|
||||
i, j := rows[p], cols[p]
|
||||
if dirichletMark[i] || dirichletMark[j] {
|
||||
continue
|
||||
}
|
||||
keptRows = append(keptRows, int64(i))
|
||||
keptCols = append(keptCols, int64(j))
|
||||
keptVals = append(keptVals, entries[p])
|
||||
}
|
||||
for _, d := range dirichletNodes {
|
||||
keptRows = append(keptRows, int64(d))
|
||||
keptCols = append(keptCols, int64(d))
|
||||
keptVals = append(keptVals, 1)
|
||||
load[d] = dirichletVal[d]
|
||||
}
|
||||
indices, err := core.FromInts(pairInts(keptRows, keptCols), len(keptVals), 2)
|
||||
if err != nil {
|
||||
return nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
coo, err := core.NewSparseCOO(indices, fromSlice(keptVals, len(keptVals)), []int{n, n})
|
||||
if err != nil {
|
||||
return nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
factor, err := linalg.NewSparseCholesky(coo, opts.Ordering)
|
||||
if err != nil {
|
||||
return nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
rhs := core.New(core.Float, []int{n}...)
|
||||
copy(rhs.RawFloats(), load)
|
||||
return factor.Solve(rhs)
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
linalg "sourcedock.dev/petrbalvin/tensor/linalg"
|
||||
)
|
||||
|
||||
// boxMesh3D builds the structured tetrahedralisation of the unit box
|
||||
// with m cells per side and returns the mesh plus the list of
|
||||
// boundary vertices, in mesh order.
|
||||
func boxMesh3D(t *testing.T, m int) (*TetraMesh3D, []int) {
|
||||
t.Helper()
|
||||
mesh, err := BoxTetraMesh3D(0, 0, 0, 1, 1, 1, m, m, m)
|
||||
if err != nil {
|
||||
t.Fatalf("BoxTetraMesh3D: %v", err)
|
||||
}
|
||||
boundary := make([]int, 0, 6*(m+1)*(m+1))
|
||||
for k := range m + 1 {
|
||||
for j := range m + 1 {
|
||||
for i := range m + 1 {
|
||||
if i == 0 || i == m || j == 0 || j == m || k == 0 || k == m {
|
||||
boundary = append(boundary, (k*(m+1)+j)*(m+1)+i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return mesh, boundary
|
||||
}
|
||||
|
||||
// TestTetraStiffnessReference pins the element stiffness matrix
|
||||
// against the hand-computed 4x4 for the reference tetrahedron
|
||||
// (0,0,0), (1,0,0), (0,1,0), (0,0,1): with κ = 1 the matrix is
|
||||
// κ/6·[[3,−1,−1,−1],[−1,1,0,0],[−1,0,1,0],[−1,0,0,1]].
|
||||
func TestTetraStiffnessReference(t *testing.T) {
|
||||
hand := [4][4]float64{
|
||||
{3, -1, -1, -1},
|
||||
{-1, 1, 0, 0},
|
||||
{-1, 0, 1, 0},
|
||||
{-1, 0, 0, 1},
|
||||
}
|
||||
k := tetraStiffness(0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1)
|
||||
for i := range 4 {
|
||||
for j := range 4 {
|
||||
want := hand[i][j] / 6
|
||||
if math.Abs(k[i][j]-want) > 1e-15 {
|
||||
t.Fatalf("K[%d][%d] = %.17g, want %.17g", i, j, k[i][j], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The conductivity scales the matrix, nothing else.
|
||||
k2 := tetraStiffness(0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 2.5)
|
||||
for i := range 4 {
|
||||
for j := range 4 {
|
||||
if math.Abs(k2[i][j]-2.5*k[i][j]) > 1e-15 {
|
||||
t.Fatalf("K[%d][%d] did not scale with κ", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A tetrahedron scaled by two in every direction: the basis
|
||||
// gradients halve and the volume grows eightfold, so each entry
|
||||
// doubles.
|
||||
ks := tetraStiffness(0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 2, 1)
|
||||
for i := range 4 {
|
||||
for j := range 4 {
|
||||
if math.Abs(ks[i][j]-2*k[i][j]) > 1e-14 {
|
||||
t.Fatalf("scaled K[%d][%d] = %.17g, want %.17g", i, j, ks[i][j], 2*k[i][j])
|
||||
}
|
||||
}
|
||||
}
|
||||
// The matrix is symmetric with positive diagonals and zero row
|
||||
// sums off the constant mode: the P1 rigid-body mode has no
|
||||
// stiffness.
|
||||
for i := range 4 {
|
||||
sum := 0.0
|
||||
for j := range 4 {
|
||||
if math.Abs(k[i][j]-k[j][i]) > 1e-15 {
|
||||
t.Fatalf("K[%d][%d] != K[%d][%d]", i, j, j, i)
|
||||
}
|
||||
sum += k[i][j]
|
||||
}
|
||||
if math.Abs(sum) > 1e-14 {
|
||||
t.Fatalf("row %d sums to %.3g, want 0", i, sum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoxTetraMesh3DStructure pins the structured mesher: the vertex
|
||||
// and tetrahedron counts, exact corner coordinates, positive
|
||||
// orientation everywhere, unit total volume, and the boundary face
|
||||
// count of the box surface.
|
||||
func TestBoxTetraMesh3DStructure(t *testing.T) {
|
||||
m, n, p := 3, 2, 4
|
||||
mesh, err := BoxTetraMesh3D(0.5, -1, 2, 1.5, 1, 2, m, n, p)
|
||||
if err != nil {
|
||||
t.Fatalf("BoxTetraMesh3D: %v", err)
|
||||
}
|
||||
if mesh.Vertices3() != (m+1)*(n+1)*(p+1) {
|
||||
t.Fatalf("vertex count %d, want %d", mesh.Vertices3(), (m+1)*(n+1)*(p+1))
|
||||
}
|
||||
if mesh.Tetrahedra4() != 6*m*n*p {
|
||||
t.Fatalf("tetrahedron count %d, want %d", mesh.Tetrahedra4(), 6*m*n*p)
|
||||
}
|
||||
// Exact corner coordinates of the box.
|
||||
at := func(i, j, k int) int { return (k*(n+1)+j)*(m+1) + i }
|
||||
checkCorner := func(label string, i, j, k int, want [3]float64) {
|
||||
t.Helper()
|
||||
v := 3 * at(i, j, k)
|
||||
for d := range 3 {
|
||||
if mesh.Vertices[v+d] != want[d] {
|
||||
t.Fatalf("%s = (%g, %g, %g), want (%g, %g, %g)",
|
||||
label, mesh.Vertices[v], mesh.Vertices[v+1], mesh.Vertices[v+2], want[0], want[1], want[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
checkCorner("origin", 0, 0, 0, [3]float64{0.5, -1, 2})
|
||||
checkCorner("far corner", m, n, p, [3]float64{2, 0, 4})
|
||||
// Every tetrahedron positively oriented, and the volumes sum to
|
||||
// the box volume: 1.5 · 1 · 2 = 3.
|
||||
total := 0.0
|
||||
cell := 3.0 / float64(6*m*n*p)
|
||||
for t4 := range mesh.Tetrahedra4() {
|
||||
a, b, c, d := int(mesh.Tetrahedra[4*t4]), int(mesh.Tetrahedra[4*t4+1]), int(mesh.Tetrahedra[4*t4+2]), int(mesh.Tetrahedra[4*t4+3])
|
||||
s6 := signedTetraVolume(
|
||||
mesh.Vertices[3*a], mesh.Vertices[3*a+1], mesh.Vertices[3*a+2],
|
||||
mesh.Vertices[3*b], mesh.Vertices[3*b+1], mesh.Vertices[3*b+2],
|
||||
mesh.Vertices[3*c], mesh.Vertices[3*c+1], mesh.Vertices[3*c+2],
|
||||
mesh.Vertices[3*d], mesh.Vertices[3*d+1], mesh.Vertices[3*d+2])
|
||||
if s6 <= 0 {
|
||||
t.Fatalf("tetrahedron %d has signed volume %g", t4, s6/6)
|
||||
}
|
||||
if d := math.Abs(s6/6 - cell); d > 1e-12 {
|
||||
t.Fatalf("tetrahedron %d has volume %.6g, want %.6g", t4, s6/6, cell)
|
||||
}
|
||||
total += s6 / 6
|
||||
}
|
||||
if math.Abs(total-3) > 1e-12 {
|
||||
t.Fatalf("total volume %.6g, want 3", total)
|
||||
}
|
||||
// The box surface carries two triangles per unit square face.
|
||||
faces := mesh.BoundaryFaces()
|
||||
if len(faces) != 3*2*2*(m*n+n*p+m*p) {
|
||||
t.Fatalf("boundary face triples %d, want %d", len(faces)/3, 2*2*(m*n+n*p+m*p))
|
||||
}
|
||||
// Every listed face holds three distinct vertices, all on the box
|
||||
// surface.
|
||||
onSurface := func(v int) bool {
|
||||
i := v % (m + 1)
|
||||
j := (v / (m + 1)) % (n + 1)
|
||||
k := v / ((m + 1) * (n + 1))
|
||||
return i == 0 || i == m || j == 0 || j == n || k == 0 || k == p
|
||||
}
|
||||
for q := 0; q < len(faces); q += 3 {
|
||||
if faces[q] == faces[q+1] || faces[q] == faces[q+2] || faces[q+1] == faces[q+2] {
|
||||
t.Fatalf("boundary face [%d %d %d] repeats a vertex", faces[q], faces[q+1], faces[q+2])
|
||||
}
|
||||
for r := range 3 {
|
||||
if !onSurface(faces[q+r]) {
|
||||
t.Fatalf("boundary face vertex %d is interior", faces[q+r])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxTetraMesh3DRefusals(t *testing.T) {
|
||||
if _, err := BoxTetraMesh3D(0, 0, 0, 1, 1, 1, 0, 2, 2); err == nil {
|
||||
t.Fatal("a zero cell count was accepted")
|
||||
}
|
||||
if _, err := BoxTetraMesh3D(0, 0, 0, 1, -1, 1, 2, 2, 2); err == nil {
|
||||
t.Fatal("a negative extent was accepted")
|
||||
}
|
||||
if _, err := BoxTetraMesh3D(math.NaN(), 0, 0, 1, 1, 1, 2, 2, 2); err == nil {
|
||||
t.Fatal("a NaN origin was accepted")
|
||||
}
|
||||
if _, err := BoxTetraMesh3D(0, 0, 0, math.Inf(1), 1, 1, 2, 2, 2); err == nil {
|
||||
t.Fatal("an infinite extent was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTetraMesh3DRefusals pins the construction contract: shapes,
|
||||
// dtypes, ranges, finiteness, and the refusal of degenerate and
|
||||
// inverted tetrahedra with the offending coordinates named.
|
||||
func TestTetraMesh3DRefusals(t *testing.T) {
|
||||
good := []float64{0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1}
|
||||
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good, 4, 3), floatsToArrayFEM(t, []float64{0, 1, 2, 3}, 1, 4)); err == nil || !stringsContains(err, "integers") {
|
||||
t.Fatalf("a float tetrahedron table: %v", err)
|
||||
}
|
||||
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good[:9], 3, 3), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4)); err == nil || !stringsContains(err, "at least four vertices") {
|
||||
t.Fatalf("three vertices: %v", err)
|
||||
}
|
||||
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good, 4, 3), intsToArrayFEM(t, []int64{}, 0, 4)); err == nil || !stringsContains(err, "must not be empty") {
|
||||
t.Fatalf("an empty tetrahedron table: %v", err)
|
||||
}
|
||||
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2, 9}, 1, 4)); err == nil || !stringsContains(err, "out of range") {
|
||||
t.Fatalf("an out-of-range index: %v", err)
|
||||
}
|
||||
bad := append([]float64{}, good...)
|
||||
bad[0] = math.NaN()
|
||||
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, bad, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4)); err == nil || !stringsContains(err, "not finite") {
|
||||
t.Fatalf("a NaN coordinate: %v", err)
|
||||
}
|
||||
// Degenerate: four coplanar points.
|
||||
degenerate := []float64{0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0}
|
||||
_, err := NewTetraMesh3D(floatsToArrayFEM(t, degenerate, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4))
|
||||
if err == nil || !stringsContains(err, "degenerate") {
|
||||
t.Fatalf("a coplanar tetrahedron: %v", err)
|
||||
}
|
||||
// Inverted: the reference tetrahedron with its last two vertices
|
||||
// swapped; the message names the coordinates.
|
||||
inverted := []float64{0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0}
|
||||
_, err = NewTetraMesh3D(floatsToArrayFEM(t, inverted, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4))
|
||||
if err == nil || !stringsContains(err, "inverted") {
|
||||
t.Fatalf("an inverted tetrahedron: %v", err)
|
||||
}
|
||||
if indexOf(err.Error(), "-0.1666") < 0 {
|
||||
t.Fatalf("the inverted message should name the negative signed volume: %v", err)
|
||||
}
|
||||
if !stringsContains(err, "(1, 0, 0)") {
|
||||
t.Fatalf("the inverted message should name the coordinates: %v", err)
|
||||
}
|
||||
// Wrong vertex table shape.
|
||||
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good[:8], 4, 2), intsToArrayFEM(t, []int64{0, 1, 2, 3}, 1, 4)); err == nil || !stringsContains(err, "three columns") {
|
||||
t.Fatalf("a two-column vertex table: %v", err)
|
||||
}
|
||||
if _, err := NewTetraMesh3D(floatsToArrayFEM(t, good, 4, 3), intsToArrayFEM(t, []int64{0, 1, 2}, 1, 3)); err == nil || !stringsContains(err, "four columns") {
|
||||
t.Fatalf("a three-column tetrahedron table: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM3DConvergence runs the manufactured solution
|
||||
// u = sin(πx)·sin(πy)·sin(πz) on the unit box, driven by
|
||||
// f = 3π²·u: the P1 nodal error must keep the O(h²) rate, roughly
|
||||
// quadrupling per mesh doubling, exactly as the two-dimensional solve
|
||||
// pins.
|
||||
func TestSolvePoissonFEM3DConvergence(t *testing.T) {
|
||||
solution := func(x, y, z float64) float64 {
|
||||
return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) * math.Sin(math.Pi*z)
|
||||
}
|
||||
source := func(x, y, z float64) float64 { return 3 * math.Pi * math.Pi * solution(x, y, z) }
|
||||
previous := 0.0
|
||||
for _, m := range []int{4, 8, 16} {
|
||||
mesh, boundary := boxMesh3D(t, m)
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = solution(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
|
||||
}
|
||||
u, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM3D(m=%d): %v", m, err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices3() {
|
||||
d := math.Abs(u.FloatAt(i) - solution(mesh.Vertices[3*i], mesh.Vertices[3*i+1], mesh.Vertices[3*i+2]))
|
||||
if d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("m=%2d: max nodal error %.3g", m, worst)
|
||||
if previous > 0 && previous/worst < 2.5 {
|
||||
t.Fatalf("m=%d: refinement ratio %.2f, want the O(h²) rate (previous %.3g, now %.3g)",
|
||||
m, previous/worst, previous, worst)
|
||||
}
|
||||
previous = worst
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM3DVariableKappa mirrors the two-dimensional
|
||||
// variable-conductivity pin on the axis a centroid typo once
|
||||
// corrupted: with κ = 1 + y the conductivity sample each element sees
|
||||
// comes from its own y centroid, and the P1 convergence rate must
|
||||
// survive the varying coefficient.
|
||||
func TestSolvePoissonFEM3DVariableKappa(t *testing.T) {
|
||||
solution := func(x, y, z float64) float64 {
|
||||
return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) * math.Sin(math.Pi*z)
|
||||
}
|
||||
kappaF := func(x, y, z float64) float64 { return 1 + y }
|
||||
uy := func(x, y, z float64) float64 {
|
||||
return math.Pi * math.Sin(math.Pi*x) * math.Cos(math.Pi*y) * math.Sin(math.Pi*z)
|
||||
}
|
||||
source := func(x, y, z float64) float64 {
|
||||
return 3*math.Pi*math.Pi*(1+y)*solution(x, y, z) - uy(x, y, z)
|
||||
}
|
||||
previous := 0.0
|
||||
for _, m := range []int{4, 8, 16} {
|
||||
mesh, boundary := boxMesh3D(t, m)
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = solution(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
|
||||
}
|
||||
u, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{KappaFunc: kappaF, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM3D(m=%d): %v", m, err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices3() {
|
||||
d := math.Abs(u.FloatAt(i) - solution(mesh.Vertices[3*i], mesh.Vertices[3*i+1], mesh.Vertices[3*i+2]))
|
||||
if d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("m=%2d: max nodal error %.3g", m, worst)
|
||||
if previous > 0 && previous/worst < 2.5 {
|
||||
t.Fatalf("m=%d: refinement ratio %.2f, want the O(h²) rate (previous %.3g, now %.3g)",
|
||||
m, previous/worst, previous, worst)
|
||||
}
|
||||
previous = worst
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM3DPatchLinear is the patch test: a linear field
|
||||
// lies in the P1 space, so with f = 0 and the boundary lifted the
|
||||
// interior solution must equal the field to machine precision.
|
||||
func TestSolvePoissonFEM3DPatchLinear(t *testing.T) {
|
||||
mesh, boundary := boxMesh3D(t, 6)
|
||||
field := func(x, y, z float64) float64 { return 1 + 2*x - 3*y + 4*z }
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = field(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
|
||||
}
|
||||
u, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM3D: %v", err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices3() {
|
||||
d := math.Abs(u.FloatAt(i) - field(mesh.Vertices[3*i], mesh.Vertices[3*i+1], mesh.Vertices[3*i+2]))
|
||||
if d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
if worst > 1e-11 {
|
||||
t.Fatalf("linear patch test error %.3g, want machine precision", worst)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM3DNeumannNatural pins the natural boundary: a
|
||||
// constant field with f = 0 satisfies the homogeneous Neumann
|
||||
// condition everywhere, so pinning the constant at a single vertex
|
||||
// must reproduce it across the whole mesh.
|
||||
func TestSolvePoissonFEM3DNeumannNatural(t *testing.T) {
|
||||
mesh, _ := boxMesh3D(t, 5)
|
||||
u, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{4}})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM3D: %v", err)
|
||||
}
|
||||
for i := range mesh.Vertices3() {
|
||||
if math.Abs(u.FloatAt(i)-4) > 1e-10 {
|
||||
t.Fatalf("node %d: solution %.12g, want the constant 4", i, u.FloatAt(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM3DNeumannFlux pins the boundary-face integrals:
|
||||
// u = (x²+y²+z²)/2 has −Δu = −3 and the flux κ∂u/∂n = 1 on the three
|
||||
// faces at x = 1, y = 1 and z = 1 (0 on the coordinate planes), so
|
||||
// prescribing those fluxes with a single pinned vertex must
|
||||
// reproduce the quadratic field to the accuracy of the edge-midpoint
|
||||
// face rule, improving as the mesh refines.
|
||||
func TestSolvePoissonFEM3DNeumannFlux(t *testing.T) {
|
||||
field := func(x, y, z float64) float64 { return (x*x + y*y + z*z) / 2 }
|
||||
previous := 0.0
|
||||
for _, m := range []int{8, 16} {
|
||||
mesh, err := BoxTetraMesh3D(0, 0, 0, 1, 1, 1, m, m, m)
|
||||
if err != nil {
|
||||
t.Fatalf("BoxTetraMesh3D: %v", err)
|
||||
}
|
||||
var faces []int
|
||||
bf := mesh.BoundaryFaces()
|
||||
for p := 0; p < len(bf); p += 3 {
|
||||
f := bf[p : p+3]
|
||||
mx := (mesh.Vertices[3*f[0]] + mesh.Vertices[3*f[1]] + mesh.Vertices[3*f[2]]) / 3
|
||||
my := (mesh.Vertices[3*f[0]+1] + mesh.Vertices[3*f[1]+1] + mesh.Vertices[3*f[2]+1]) / 3
|
||||
mz := (mesh.Vertices[3*f[0]+2] + mesh.Vertices[3*f[1]+2] + mesh.Vertices[3*f[2]+2]) / 3
|
||||
if mx == 1 || my == 1 || mz == 1 {
|
||||
faces = append(faces, f[0], f[1], f[2])
|
||||
}
|
||||
}
|
||||
flux := func(x, y, z float64) float64 {
|
||||
if x == 1 || y == 1 || z == 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
u, err := SolvePoissonFEM3D(mesh, func(float64, float64, float64) float64 { return -3 },
|
||||
FEMPoisson3DOptions{
|
||||
Kappa: 1,
|
||||
DirichletNodes: []int{0},
|
||||
DirichletValues: []float64{0},
|
||||
NeumannFaces: faces,
|
||||
NeumannFlux: flux,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("m=%d: %v", m, err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices3() {
|
||||
d := math.Abs(u.FloatAt(i) - field(mesh.Vertices[3*i], mesh.Vertices[3*i+1], mesh.Vertices[3*i+2]))
|
||||
if d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("m=%2d: max nodal error %.3g", m, worst)
|
||||
if previous > 0 && previous/worst < 1.3 {
|
||||
t.Fatalf("m=%d: refinement ratio %.2f, want the face-rule error to shrink under refinement", m, previous/worst)
|
||||
}
|
||||
previous = worst
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM3DOrderings runs the manufactured-solution solve
|
||||
// under every ordering the factor offers: the ordering changes the
|
||||
// fill, never the answer.
|
||||
func TestSolvePoissonFEM3DOrderings(t *testing.T) {
|
||||
solution := func(x, y, z float64) float64 {
|
||||
return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) * math.Sin(math.Pi*z)
|
||||
}
|
||||
mesh, boundary := boxMesh3D(t, 5)
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = solution(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
|
||||
}
|
||||
source := func(x, y, z float64) float64 { return 3 * math.Pi * math.Pi * solution(x, y, z) }
|
||||
reference, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM3D(natural): %v", err)
|
||||
}
|
||||
for _, ordering := range []linalg.SparseOrdering{
|
||||
linalg.SparseOrderingReverseCuthillMcKee,
|
||||
linalg.SparseOrderingMinimumDegree,
|
||||
} {
|
||||
u, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: values, Ordering: ordering})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM3D(%d): %v", ordering, err)
|
||||
}
|
||||
for i := range mesh.Vertices3() {
|
||||
if math.Abs(u.FloatAt(i)-reference.FloatAt(i)) > 1e-9 {
|
||||
t.Fatalf("ordering %d: node %d differs from the natural run", ordering, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolvePoissonFEM3DRefusals(t *testing.T) {
|
||||
mesh, boundary := boxMesh3D(t, 3)
|
||||
zero := make([]float64, len(boundary))
|
||||
if _, err := SolvePoissonFEM3D(nil, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}}); err == nil || !stringsContains(err, "nil") {
|
||||
t.Fatalf("a nil mesh: %v", err)
|
||||
}
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 0, DirichletNodes: boundary, DirichletValues: zero}); err == nil || !stringsContains(err, "positive") {
|
||||
t.Fatalf("zero conductivity: %v", err)
|
||||
}
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0, 1}, DirichletValues: []float64{1}}); err == nil {
|
||||
t.Fatal("a Dirichlet length mismatch was accepted")
|
||||
}
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1}); err == nil || !stringsContains(err, "purely Neumann") {
|
||||
t.Fatalf("a purely Neumann problem: %v", err)
|
||||
}
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{99}, DirichletValues: []float64{1}}); err == nil || !stringsContains(err, "out of range") {
|
||||
t.Fatalf("an out-of-range Dirichlet node: %v", err)
|
||||
}
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{math.NaN()}}); err == nil || !stringsContains(err, "not finite") {
|
||||
t.Fatalf("a NaN Dirichlet value: %v", err)
|
||||
}
|
||||
// Neumann face tables: not triples, out-of-range and repeated
|
||||
// vertices.
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannFaces: []int{0, 1, 2, 3}}); err == nil || !stringsContains(err, "triples") {
|
||||
t.Fatalf("a Neumann face count not divisible by three: %v", err)
|
||||
}
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannFaces: []int{0, 1, 77}}); err == nil || !stringsContains(err, "out-of-range") {
|
||||
t.Fatalf("an out-of-range Neumann vertex: %v", err)
|
||||
}
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}, NeumannFaces: []int{0, 0, 1}}); err == nil || !stringsContains(err, "repeats") {
|
||||
t.Fatalf("a degenerate Neumann face: %v", err)
|
||||
}
|
||||
// A KappaFunc returning a non-positive conductivity names the
|
||||
// tetrahedron.
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{
|
||||
KappaFunc: func(float64, float64, float64) float64 { return -1 },
|
||||
DirichletNodes: []int{0},
|
||||
DirichletValues: []float64{0},
|
||||
}); err == nil || !stringsContains(err, "positive") {
|
||||
t.Fatalf("a non-positive KappaFunc value: %v", err)
|
||||
}
|
||||
// A KappaFunc returning an infinite conductivity names the
|
||||
// tetrahedron the way the constant field's gate names itself.
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{
|
||||
KappaFunc: func(float64, float64, float64) float64 { return math.Inf(1) },
|
||||
DirichletNodes: []int{0},
|
||||
DirichletValues: []float64{0},
|
||||
}); err == nil || !stringsContains(err, "positive") {
|
||||
t.Fatalf("an infinite KappaFunc value: %v", err)
|
||||
}
|
||||
// A non-finite source value refuses the solve: it used to land in
|
||||
// the load and publish an all-NaN solution with a nil error.
|
||||
if _, err := SolvePoissonFEM3D(mesh, func(x, y, z float64) float64 { return math.NaN() },
|
||||
FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: zero}); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatalf("a NaN source value: %v", err)
|
||||
}
|
||||
// A non-finite Neumann flux refuses the solve the same way.
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{
|
||||
Kappa: 1,
|
||||
DirichletNodes: []int{0},
|
||||
DirichletValues: []float64{0},
|
||||
NeumannFaces: []int{0, 1, 2},
|
||||
NeumannFlux: func(x, y, z float64) float64 { return math.Inf(1) },
|
||||
}); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatalf("an infinite Neumann flux: %v", err)
|
||||
}
|
||||
// An ordering that does not exist.
|
||||
if _, err := SolvePoissonFEM3D(mesh, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: zero, Ordering: linalg.SparseOrdering(7)}); err == nil {
|
||||
t.Fatal("an unknown ordering was accepted")
|
||||
}
|
||||
// A hand-built mesh with a degenerate tetrahedron is refused by
|
||||
// the solver, which checks the volume itself.
|
||||
hollow := &TetraMesh3D{
|
||||
Vertices: []float64{0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0},
|
||||
Tetrahedra: []int64{0, 1, 2, 3},
|
||||
}
|
||||
if _, err := SolvePoissonFEM3D(hollow, nil, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: []int{0}, DirichletValues: []float64{0}}); err == nil || !stringsContains(err, "degenerate") {
|
||||
t.Fatalf("a hand-built degenerate mesh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolvePoissonFEM3DDuplicateDirichletNode pins the three-dimensional
|
||||
// side of the same rule as the two-dimensional test: a node listed
|
||||
// twice keeps its last value and is recorded once, so the repeated
|
||||
// listing answers what the single listing with that value answers.
|
||||
func TestSolvePoissonFEM3DDuplicateDirichletNode(t *testing.T) {
|
||||
solution := func(x, y, z float64) float64 {
|
||||
return math.Sin(math.Pi*x) * math.Sin(math.Pi*y) * math.Sin(math.Pi*z)
|
||||
}
|
||||
source := func(x, y, z float64) float64 { return 3 * math.Pi * math.Pi * solution(x, y, z) }
|
||||
mesh, boundary := boxMesh3D(t, 2)
|
||||
values := make([]float64, len(boundary))
|
||||
for p, node := range boundary {
|
||||
values[p] = solution(mesh.Vertices[3*node], mesh.Vertices[3*node+1], mesh.Vertices[3*node+2])
|
||||
}
|
||||
const extra = 0.5
|
||||
nodes := append(append([]int(nil), boundary...), boundary[1])
|
||||
dupValues := append(append([]float64(nil), values...), values[1]+extra)
|
||||
u, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: nodes, DirichletValues: dupValues})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM3D with a repeated node: %v", err)
|
||||
}
|
||||
single := append([]float64(nil), values...)
|
||||
single[1] += extra
|
||||
want, err := SolvePoissonFEM3D(mesh, source, FEMPoisson3DOptions{Kappa: 1, DirichletNodes: boundary, DirichletValues: single})
|
||||
if err != nil {
|
||||
t.Fatalf("SolvePoissonFEM3D with the node once: %v", err)
|
||||
}
|
||||
if got := u.FloatAt(boundary[1]); math.Abs(got-(values[1]+extra)) > 1e-12 {
|
||||
t.Fatalf("the repeated node answered %g, want the last prescribed value %g", got, values[1]+extra)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range mesh.Vertices3() {
|
||||
worst = math.Max(worst, math.Abs(u.FloatAt(i)-want.FloatAt(i)))
|
||||
}
|
||||
if worst > 1e-12 {
|
||||
t.Fatalf("the repeated listing differs from the single listing by %g, want the node recorded once", worst)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
)
|
||||
|
||||
// Oscillatory quadrature: the integral of a smooth amplitude against a
|
||||
// sine or cosine of a high frequency, the shape every spectral
|
||||
// reduction produces and one a plain adaptive rule pays for double: it
|
||||
// must resolve the carrier, not the amplitude, so the evaluation count
|
||||
// grows with the frequency and the per-panel rules start aliasing.
|
||||
//
|
||||
// The scheme is Filon-type. The interval splits into equal panels, the
|
||||
// amplitude f is interpolated on each panel by a polynomial through
|
||||
// Gauss-Legendre nodes, and the product of that polynomial with the
|
||||
// oscillatory kernel is carried out exactly through per-panel weights.
|
||||
// The error therefore tracks the smoothness of f alone and falls like
|
||||
// the panel width to the interpolation order, no matter how large the
|
||||
// frequency grows, while the plain adaptive rule must spend roughly
|
||||
// twenty evaluations per carrier wavelength to see it at all.
|
||||
|
||||
// FilonOptions tunes IntegrateFilon. Nodes ≤ 0 means 16, the
|
||||
// polynomial degree of the amplitude interpolant per panel is Nodes−1.
|
||||
// Panels ≤ 0 means automatic: the count that keeps each panel at most
|
||||
// about Nodes half-wavelengths of the carrier, the range where the
|
||||
// moment construction below is exact to the rounding floor.
|
||||
type FilonOptions struct {
|
||||
Panels int
|
||||
Nodes int
|
||||
}
|
||||
|
||||
// filonAlphaCap bounds the forced-panel moment phase: a panel may
|
||||
// carry at most this many half-wavelengths of the carrier before the
|
||||
// auxiliary rule that builds the weights would have to grow without
|
||||
// bound. The automatic panel count never reaches it.
|
||||
const filonAlphaCap = 4096.0
|
||||
|
||||
// IntegrateFilon returns the two definite integrals
|
||||
//
|
||||
// cosIntegral = ∫ f(x)·cos(kx) dx, sinIntegral = ∫ f(x)·sin(kx) dx
|
||||
//
|
||||
// over [a, b], the real and imaginary parts of ∫ f(x)·e^{ikx} dx. A
|
||||
// reversed interval integrates in the negative direction and k = 0
|
||||
// degenerates to the plain integral of f with a zero sine part. The
|
||||
// construction is exact whenever f is a polynomial of degree below
|
||||
// Nodes, so on smooth amplitudes the answer sits at the rounding floor
|
||||
// even for frequencies whose carrier a sampled rule cannot see.
|
||||
//
|
||||
// Errors: NaN or infinite bounds, an infinite frequency, a NaN
|
||||
// frequency, Nodes outside [2, 32], a forced Panels whose panels would
|
||||
// carry more than filonAlphaCap half-wavelengths of the carrier, a span
|
||||
// that overflows the float64 range, a frequency whose span product
|
||||
// leaves no representable panel count, and an f that fails or returns
|
||||
// a non-finite value.
|
||||
func IntegrateFilon(f func(x float64) (float64, error), a, b, k float64, opts FilonOptions) (float64, float64, error) {
|
||||
if opts.Nodes <= 0 {
|
||||
opts.Nodes = 16
|
||||
}
|
||||
if opts.Nodes < 2 || opts.Nodes > 32 {
|
||||
return 0, 0, base.Errf("IntegrateFilon: Nodes must be between 2 and 32, got %d", opts.Nodes)
|
||||
}
|
||||
if math.IsNaN(a) || math.IsNaN(b) || math.IsNaN(k) {
|
||||
return 0, 0, base.Errf("IntegrateFilon: bounds and frequency must not be NaN")
|
||||
}
|
||||
if math.IsInf(a, 0) || math.IsInf(b, 0) {
|
||||
return 0, 0, base.Errf("IntegrateFilon: bounds must be finite, got [%g, %g]", a, b)
|
||||
}
|
||||
if math.IsInf(k, 0) {
|
||||
return 0, 0, base.Errf("IntegrateFilon: the frequency must be finite, got %g", k)
|
||||
}
|
||||
sign := 1.0
|
||||
if b < a {
|
||||
a, b = b, a
|
||||
sign = -1
|
||||
}
|
||||
if a == b {
|
||||
return 0, 0, nil
|
||||
}
|
||||
// Two finite bounds can still sit so far apart that their span
|
||||
// overflows: the panel width would be infinite and the carrier's
|
||||
// phase at the panel centre 0·Inf or k·Inf, a quiet NaN pair.
|
||||
if span := b - a; math.IsInf(span, 0) {
|
||||
return 0, 0, base.Errf("IntegrateFilon: the span from %g to %g overflows, leaving no representable panel width", a, b)
|
||||
}
|
||||
if opts.Panels > 0 {
|
||||
if alpha := math.Abs(k) * (b - a) / (2 * float64(opts.Panels)); alpha > filonAlphaCap {
|
||||
return 0, 0, base.Errf("IntegrateFilon: %d panels leave %g half-wavelengths of the carrier per panel, above the %g the weights can be built within; raise Panels or leave them automatic",
|
||||
opts.Panels, alpha, filonAlphaCap)
|
||||
}
|
||||
}
|
||||
panels := opts.Panels
|
||||
if panels <= 0 {
|
||||
panels = 1
|
||||
if k != 0 {
|
||||
// A panel of h carries |k|h/2 half-wavelengths; the cap at
|
||||
// Nodes keeps the moment construction in its exact range
|
||||
// and the interpolation error far under the floor. The
|
||||
// estimate can also leave the int range while still
|
||||
// finite, and the conversion of such a ceiling is
|
||||
// implementation-dependent garbage: on saturation it asks
|
||||
// for an unending loop, elsewhere it wraps negative and
|
||||
// the empty loop reports a quiet zero. Refuse anything
|
||||
// the platform's int cannot represent.
|
||||
est := math.Abs(k) * (b - a) / (2 * float64(opts.Nodes))
|
||||
if est >= math.MaxInt {
|
||||
return 0, 0, base.Errf("IntegrateFilon: the frequency %g over the span %g leaves no representable panel count", k, b-a)
|
||||
}
|
||||
panels = int(math.Ceil(est))
|
||||
}
|
||||
}
|
||||
h := (b - a) / float64(panels)
|
||||
alpha := k * h / 2
|
||||
|
||||
nodes, _, err := GaussLegendreNodes(opts.Nodes)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
wCos, wSin, err := filonWeights(nodes, alpha)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// One sweep over the panels: sample the amplitude at the nodes,
|
||||
// contract it with the weights into the panel's two amplitudes C
|
||||
// and S, and rotate them into place by the carrier's phase at the
|
||||
// panel centre.
|
||||
var cosTotal, sinTotal float64
|
||||
for p := range panels {
|
||||
centre := a + (float64(p)+0.5)*h
|
||||
half := h / 2
|
||||
var c, s float64
|
||||
for i := range nodes {
|
||||
fx, ferr := f(centre + half*nodes[i])
|
||||
if ferr != nil {
|
||||
return 0, 0, base.Errf("IntegrateFilon: %w", ferr)
|
||||
}
|
||||
if math.IsNaN(fx) || math.IsInf(fx, 0) {
|
||||
return 0, 0, base.Errf("IntegrateFilon: the amplitude returned the non-finite value %g on panel %d", fx, p)
|
||||
}
|
||||
c += wCos[i] * fx
|
||||
s += wSin[i] * fx
|
||||
}
|
||||
phase := k * centre
|
||||
cosP, sinP := math.Cos(phase), math.Sin(phase)
|
||||
cosTotal += cosP*c - sinP*s
|
||||
sinTotal += sinP*c + cosP*s
|
||||
}
|
||||
return sign * cosTotal * h / 2, sign * sinTotal * h / 2, nil
|
||||
}
|
||||
|
||||
// filonWeights returns, for the Gauss-Legendre nodes of [-1, 1], the
|
||||
// Filon weights: the exact integrals of each Lagrange basis polynomial
|
||||
// against cos(αy) and sin(αy). With these the panel integral of the
|
||||
// interpolating polynomial times the carrier is one dot product per
|
||||
// part, and every trace of the carrier's phase lives in the weights,
|
||||
// built once, never per panel.
|
||||
//
|
||||
// The basis moments come from a composite 32-point Gauss-Legendre rule
|
||||
// whose subinterval count follows α, so the auxiliary rule resolves
|
||||
// the carrier the amplitude is multiplied by; the automatic panel cap
|
||||
// keeps that cost at one subinterval and the rule at the rounding
|
||||
// floor.
|
||||
func filonWeights(nodes []float64, alpha float64) (wCos, wSin []float64, err error) {
|
||||
m := len(nodes)
|
||||
// Barycentric weights of the interpolation nodes.
|
||||
bw := make([]float64, m)
|
||||
for i := range m {
|
||||
p := 1.0
|
||||
for j := range m {
|
||||
if j != i {
|
||||
p *= nodes[i] - nodes[j]
|
||||
}
|
||||
}
|
||||
if p == 0 {
|
||||
return nil, nil, base.Errf("IntegrateFilon: repeated interpolation nodes")
|
||||
}
|
||||
bw[i] = 1 / p
|
||||
}
|
||||
// The auxiliary rule: 32-point Gauss-Legendre over enough equal
|
||||
// subintervals of [-1, 1] that each carries at most 16
|
||||
// half-wavelengths of e^{iαy}.
|
||||
subs := 1
|
||||
if a := math.Abs(alpha); a > 16 {
|
||||
subs = int(math.Ceil(a / 16))
|
||||
}
|
||||
auxNodes, auxWeights, err := GaussLegendreNodes(32)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
wCos = make([]float64, m)
|
||||
wSin = make([]float64, m)
|
||||
span := 2.0 / float64(subs)
|
||||
for s := range subs {
|
||||
lo := -1 + float64(s)*span
|
||||
for t := range auxNodes {
|
||||
// The aux nodes live on [-1, 1]; map them into the
|
||||
// subinterval [lo, lo+span] with the half-span the affine
|
||||
// change of variables carries.
|
||||
y := lo + span*0.5*(auxNodes[t]+1)
|
||||
// Barycentric evaluation of every basis polynomial at y,
|
||||
// with the exact hit a node coincidence asks for.
|
||||
den := 0.0
|
||||
exact := -1
|
||||
for i := range m {
|
||||
d := y - nodes[i]
|
||||
if d == 0 {
|
||||
exact = i
|
||||
break
|
||||
}
|
||||
den += bw[i] / d
|
||||
}
|
||||
cy, sy := math.Cos(alpha*y), math.Sin(alpha*y)
|
||||
w := span * 0.5 * auxWeights[t]
|
||||
for i := range m {
|
||||
var li float64
|
||||
if exact >= 0 {
|
||||
if i == exact {
|
||||
li = 1
|
||||
}
|
||||
} else {
|
||||
li = bw[i] / (y - nodes[i]) / den
|
||||
}
|
||||
wCos[i] += w * li * cy
|
||||
wSin[i] += w * li * sy
|
||||
}
|
||||
}
|
||||
}
|
||||
return wCos, wSin, nil
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/big"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// IntegrateFilon against an external exact reference: the antiderivative
|
||||
//
|
||||
// ∫ p(x)·e^{ikx} dx = e^{ikx}·Σ_{j≥0} (−1)^j p^{(j)}(x)/(ik)^{j+1},
|
||||
//
|
||||
// summed in math/big at a working size far past the float64 grid, with
|
||||
// π from Machin's formula and the endpoint phases reduced mod 2π before
|
||||
// the Taylor run. The reference holds for every frequency tried here,
|
||||
// so a phase defect of the scheme itself shows against it.
|
||||
|
||||
const filonRefPrec = 512
|
||||
|
||||
func fb(x float64) *big.Float {
|
||||
return new(big.Float).SetPrec(filonRefPrec).SetFloat64(x)
|
||||
}
|
||||
|
||||
func fbInt(n int64) *big.Float {
|
||||
return new(big.Float).SetPrec(filonRefPrec).SetInt64(n)
|
||||
}
|
||||
|
||||
func fbPi() *big.Float {
|
||||
// π = 16·atan(1/5) − 4·atan(1/239).
|
||||
atan := func(t *big.Float) *big.Float {
|
||||
power := new(big.Float).SetPrec(filonRefPrec).Set(t)
|
||||
sum := fb(0)
|
||||
for k := int64(1); ; k += 2 {
|
||||
term := new(big.Float).SetPrec(filonRefPrec).Quo(power, fbInt(k))
|
||||
if (k/2)%2 == 1 {
|
||||
term.Neg(term)
|
||||
}
|
||||
sum.Add(sum, term)
|
||||
power.Mul(power, t)
|
||||
power.Mul(power, t)
|
||||
if term.MantExp(nil) < -int(filonRefPrec)-10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return sum
|
||||
}
|
||||
// 1/5 must reach atan as the exact quotient: the float64 literal
|
||||
// 0.2 carries a 1e-17 argument error that Machin's formula
|
||||
// amplifies sixteenfold into π itself.
|
||||
fifth := new(big.Float).SetPrec(filonRefPrec).Quo(fb(1), fbInt(5))
|
||||
two39 := new(big.Float).SetPrec(filonRefPrec).Quo(fb(1), fbInt(239))
|
||||
sixteen := new(big.Float).SetPrec(filonRefPrec).Mul(fbInt(16), atan(fifth))
|
||||
four := new(big.Float).SetPrec(filonRefPrec).Mul(fbInt(4), atan(two39))
|
||||
return sixteen.Sub(sixteen, four)
|
||||
}
|
||||
|
||||
var (
|
||||
filonTwoPi = new(big.Float).SetPrec(filonRefPrec).Mul(fb(2), fbPi())
|
||||
filonPi = fbPi()
|
||||
)
|
||||
|
||||
// filonBigSinCos returns sin(x), cos(x) for the exact big.Float argument,
|
||||
// kept in extended precision: the endpoint products below multiply them
|
||||
// by antiderivative terms far larger than the integral itself, so a
|
||||
// float64 detour here would show up in the reference's own answer.
|
||||
func filonBigSinCos(x *big.Float) (s, c *big.Float) {
|
||||
n := new(big.Float).SetPrec(filonRefPrec).Quo(x, filonTwoPi)
|
||||
ni, _ := n.Int(nil)
|
||||
r := new(big.Float).SetPrec(filonRefPrec).Mul(new(big.Float).SetInt(ni), filonTwoPi)
|
||||
r.Sub(x, r)
|
||||
// The remainder sits within (−2π, 2π); one step puts it in (−π, π].
|
||||
halfPi := new(big.Float).SetPrec(filonRefPrec).Quo(filonPi, fb(2))
|
||||
if r.Cmp(halfPi) > 0 {
|
||||
r.Sub(r, filonTwoPi)
|
||||
} else if r.Cmp(new(big.Float).SetPrec(filonRefPrec).Neg(halfPi)) < 0 {
|
||||
r.Add(r, filonTwoPi)
|
||||
}
|
||||
// Taylor runs about the reduced argument; the zero remainder is the
|
||||
// exact answer both series converge to.
|
||||
if r.Sign() == 0 {
|
||||
return fb(0), fb(1)
|
||||
}
|
||||
r2 := new(big.Float).SetPrec(filonRefPrec).Mul(r, r)
|
||||
ts, tc := new(big.Float).SetPrec(filonRefPrec).Set(r), fb(1)
|
||||
sumS, sumC := new(big.Float).SetPrec(filonRefPrec).Set(r), fb(1)
|
||||
for j := int64(1); ; j++ {
|
||||
ts.Mul(ts, r2)
|
||||
ts.Quo(ts, fbInt((2*j)*(2*j+1)))
|
||||
ts.Neg(ts)
|
||||
sumS.Add(sumS, ts)
|
||||
tc.Mul(tc, r2)
|
||||
tc.Quo(tc, fbInt((2*j-1)*(2*j)))
|
||||
tc.Neg(tc)
|
||||
sumC.Add(sumC, tc)
|
||||
if ts.Sign() == 0 || ts.MantExp(nil) < -int(filonRefPrec)-10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return sumS, sumC
|
||||
}
|
||||
|
||||
// poly is a real polynomial, coefficients ascending.
|
||||
type poly []float64
|
||||
|
||||
func (p poly) evalBig(x *big.Float) *big.Float {
|
||||
acc := fb(0)
|
||||
for _, v := range slices.Backward(p) {
|
||||
acc.Mul(acc, x)
|
||||
acc.Add(acc, fb(v))
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
// formalDeriv differentiates the coefficient list.
|
||||
func (p poly) formalDeriv() poly {
|
||||
if len(p) <= 1 {
|
||||
return poly{0}
|
||||
}
|
||||
d := make(poly, len(p)-1)
|
||||
for i := 1; i < len(p); i++ {
|
||||
d[i-1] = float64(i) * p[i]
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// filonPolyRef evaluates ∫ₐ^b p(x)·cos(kx) dx and the sine part against
|
||||
// the antiderivative above, in extended precision.
|
||||
func filonPolyRef(p poly, a, b, k float64) (c, s float64) {
|
||||
endpoint := func(x float64) (re, im *big.Float) {
|
||||
// Q(x) = Σ (−1)^j p^{(j)}(x)/(ik)^{j+1}, split by the cycle of
|
||||
// i^{−(j+1)}: −i, −1, i, 1.
|
||||
qre, qim := fb(0), fb(0)
|
||||
sign := fb(1)
|
||||
kpow := fb(1) // k^(j+1), built by repeated multiplication
|
||||
px := fb(x)
|
||||
pp := p
|
||||
value := pp.evalBig(px)
|
||||
for j := range p {
|
||||
kpow.Mul(kpow, fb(k))
|
||||
scale := new(big.Float).SetPrec(filonRefPrec).Quo(sign, kpow)
|
||||
switch (j + 1) % 4 {
|
||||
case 1: // −i
|
||||
qim.Sub(qim, scale.Mul(scale, value))
|
||||
case 2: // −1
|
||||
qre.Sub(qre, scale.Mul(scale, value))
|
||||
case 3: // i
|
||||
qim.Add(qim, scale.Mul(scale, value))
|
||||
default: // 1
|
||||
qre.Add(qre, scale.Mul(scale, value))
|
||||
}
|
||||
sign.Neg(sign)
|
||||
// p^{(j+1)} for the next term.
|
||||
pp = pp.formalDeriv()
|
||||
value = pp.evalBig(px)
|
||||
}
|
||||
sb, cb := filonBigSinCos(fb(k * x))
|
||||
ere := new(big.Float).SetPrec(filonRefPrec).Mul(cb, qre)
|
||||
ere.Sub(ere, new(big.Float).SetPrec(filonRefPrec).Mul(sb, qim))
|
||||
eim := new(big.Float).SetPrec(filonRefPrec).Mul(sb, qre)
|
||||
eim.Add(eim, new(big.Float).SetPrec(filonRefPrec).Mul(cb, qim))
|
||||
return ere, eim
|
||||
}
|
||||
reB, imB := endpoint(b)
|
||||
reA, imA := endpoint(a)
|
||||
cv, _ := reB.Sub(reB, reA).Float64()
|
||||
sv, _ := imB.Sub(imB, imA).Float64()
|
||||
return cv, sv
|
||||
}
|
||||
|
||||
func filonRelErr(got, want float64) float64 {
|
||||
if want == 0 {
|
||||
return math.Abs(got)
|
||||
}
|
||||
return math.Abs(got-want) / math.Abs(want)
|
||||
}
|
||||
|
||||
// ampScale bounds |p| over [a, b] by the sum of the coefficients'
|
||||
// magnitudes lifted to the interval's ends, the scale the absolute
|
||||
// tolerance is measured against: at high frequency the integral itself
|
||||
// can cancel to nearly nothing and a relative metric would chase noise.
|
||||
func ampScale(p poly, a, b float64) float64 {
|
||||
mag := math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
|
||||
s := 0.0
|
||||
pow := 1.0
|
||||
for _, c := range p {
|
||||
s += math.Abs(c) * pow
|
||||
pow *= mag
|
||||
}
|
||||
return math.Abs(b-a) * s
|
||||
}
|
||||
|
||||
// TestIntegrateFilonPolynomialMoments holds the scheme against the exact
|
||||
// antiderivative across amplitudes of every degree the default node
|
||||
// count interpolates exactly, intervals with both orientations' worth of
|
||||
// geometry, and frequencies from the settled to the far oscillatory.
|
||||
func TestIntegrateFilonPolynomialMoments(t *testing.T) {
|
||||
amplitudes := map[string]poly{
|
||||
"1": {1},
|
||||
"2 − 3x + x²": {2, -3, 1},
|
||||
"1 + 0.5x³": {1, 0, 0, 0.5},
|
||||
"x − 2x⁴ + 4x⁷": {0, 1, 0, 0, -2, 0, 0, 4},
|
||||
// Degree 15, the exact interpolation degree of the default 16
|
||||
// nodes.
|
||||
"degree 15": {1, -1, 0.5, 0.25, -0.125, 0.0625, 0.5, -0.5, 0.25, -0.25, 0.125, -0.125, 0.0625, -0.0625, 0.5, -0.25},
|
||||
}
|
||||
worst := 0.0
|
||||
for _, k := range []float64{1, 10, 100, 1000, 10000} {
|
||||
for label, p := range amplitudes {
|
||||
for _, c := range [][2]float64{{0, 1}, {2, 7}, {-1, 3}} {
|
||||
a, b := c[0], c[1]
|
||||
gotC, gotS, err := IntegrateFilon(plainPoly(p), a, b, k, FilonOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s on [%g,%g] k=%g: %v", label, a, b, k, err)
|
||||
}
|
||||
wantC, wantS := filonPolyRef(p, a, b, k)
|
||||
scale := ampScale(p, a, b)
|
||||
dC := math.Abs(gotC-wantC) / scale
|
||||
dS := math.Abs(gotS-wantS) / scale
|
||||
worst = math.Max(worst, math.Max(dC, dS))
|
||||
if dC > 1e-13 {
|
||||
t.Fatalf("%s on [%g,%g] k=%g: cos part %.17g against exact %.17g (scaled %.3g)",
|
||||
label, a, b, k, gotC, wantC, dC)
|
||||
}
|
||||
if dS > 1e-13 {
|
||||
t.Fatalf("%s on [%g,%g] k=%g: sin part %.17g against exact %.17g (scaled %.3g)",
|
||||
label, a, b, k, gotS, wantS, dS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("worst scaled moment error across the sweep: %.3e", worst)
|
||||
}
|
||||
|
||||
// plainPoly wraps a polynomial as the amplitude IntegrateFilon samples.
|
||||
func plainPoly(p poly) func(float64) (float64, error) {
|
||||
return func(x float64) (float64, error) {
|
||||
acc := 0.0
|
||||
for _, v := range slices.Backward(p) {
|
||||
acc = acc*x + v
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateFilonLargeFrequency pins the phase at frequencies where a
|
||||
// float64 antiderivative would stop being a reference: the extended
|
||||
// precision one keeps counting. The bound is absolute against the
|
||||
// amplitude scale, because the integral itself shrinks like 1/k.
|
||||
func TestIntegrateFilonLargeFrequency(t *testing.T) {
|
||||
amplitudes := map[string]poly{
|
||||
"2 − 3x + x²": {2, -3, 1},
|
||||
"1 + 0.5x³": {1, 0, 0, 0.5},
|
||||
"x − 2x⁴ + 4x⁷": {0, 1, 0, 0, -2, 0, 0, 4},
|
||||
}
|
||||
worst := 0.0
|
||||
for _, k := range []float64{1e5, 1e6} {
|
||||
for label, p := range amplitudes {
|
||||
gotC, gotS, err := IntegrateFilon(plainPoly(p), 0, 1, k, FilonOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s at k=%g: %v", label, k, err)
|
||||
}
|
||||
wantC, wantS := filonPolyRef(p, 0, 1, k)
|
||||
scale := ampScale(p, 0, 1)
|
||||
dC, dS := math.Abs(gotC-wantC)/scale, math.Abs(gotS-wantS)/scale
|
||||
t.Logf("k=%g %s: cos %.3e, sin %.3e (scaled absolute)", k, label, dC, dS)
|
||||
worst = math.Max(worst, math.Max(dC, dS))
|
||||
}
|
||||
}
|
||||
if worst > 1e-11 {
|
||||
t.Fatalf("the worst large-frequency scaled error %.3e is past the phase budget", worst)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateFilonBeatsPlainQuad measures the scheme's reason to exist:
|
||||
// at equal evaluation budgets the plain adaptive rule must resolve the
|
||||
// carrier while Filon tracks the amplitude, and the gap has to be worth
|
||||
// the second entry point.
|
||||
func TestIntegrateFilonBeatsPlainQuad(t *testing.T) {
|
||||
p := poly{0, 1, 0, 0, -2, 0, 0, 4}
|
||||
for _, k := range []float64{1000, 10000} {
|
||||
wantC, _ := filonPolyRef(p, 0, 1, k)
|
||||
// Filon's budget: automatic panels times the default node count.
|
||||
panels := int(math.Ceil(k / (2 * 16)))
|
||||
budget := panels * 16
|
||||
// The plain rule pays 31 evaluations per subinterval (a 21-point
|
||||
// rule and a 10-point rule on every leaf).
|
||||
leaves := budget / 31
|
||||
counts := 0
|
||||
amp := func(x float64) (float64, error) {
|
||||
counts++
|
||||
v, err := plainPoly(p)(x)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v * math.Cos(k*x), nil
|
||||
}
|
||||
got, _, err := IntegrateFunction(amp, 0, 1, QuadratureOptions{MaxIntervals: leaves})
|
||||
quadErr := math.Inf(1)
|
||||
if err != nil {
|
||||
t.Logf("k=%g: the plain rule failed within %d leaves (%d evaluations): %v", k, leaves, counts, err)
|
||||
} else {
|
||||
quadErr = filonRelErr(got, wantC)
|
||||
t.Logf("k=%g: plain quad %.3e (%d evaluations), Filon on the same budget below", k, quadErr, counts)
|
||||
}
|
||||
fCounts := 0
|
||||
f := func(x float64) (float64, error) {
|
||||
fCounts++
|
||||
return plainPoly(p)(x)
|
||||
}
|
||||
gotC, _, err := IntegrateFilon(f, 0, 1, k, FilonOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fErr := filonRelErr(gotC, wantC)
|
||||
t.Logf("k=%g: Filon %.3e from %d evaluations", k, fErr, fCounts)
|
||||
if fCounts > budget {
|
||||
t.Fatalf("Filon spent %d evaluations past its own budget %d", fCounts, budget)
|
||||
}
|
||||
if fErr >= quadErr {
|
||||
t.Fatalf("k=%g: Filon's error %.3e fails to beat the plain rule's %.3e on the same budget", k, fErr, quadErr)
|
||||
}
|
||||
if fErr*100 > quadErr {
|
||||
t.Fatalf("k=%g: Filon's error %.3e is within two orders of the plain rule's %.3e", k, fErr, quadErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIntegrateFilonRejectsInfiniteBounds pins the refusal: an infinite
|
||||
// bound used to fall into an unrepresentable panel count (the conversion
|
||||
// of the ceiling of infinity) and come back as a quiet NaN pair with a
|
||||
// nil error, and with k = 0 it sampled the amplitude at infinity. The
|
||||
// error contract refuses NaN bounds and an infinite frequency; an
|
||||
// infinite bound is the same breach and answers the same way.
|
||||
func TestIntegrateFilonRejectsInfiniteBounds(t *testing.T) {
|
||||
f := func(x float64) (float64, error) { return math.Exp(-x), nil }
|
||||
cases := []struct {
|
||||
label string
|
||||
a, b, k float64
|
||||
}{
|
||||
{"upper tail", 0, math.Inf(1), 1},
|
||||
{"lower tail", math.Inf(-1), 0, 1},
|
||||
{"whole line", math.Inf(-1), math.Inf(1), 1},
|
||||
{"zero frequency upper tail", 0, math.Inf(1), 0},
|
||||
{"span product overflow", 0, 1e100, 1e300},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cos, sin, err := IntegrateFilon(f, c.a, c.b, c.k, FilonOptions{})
|
||||
if err == nil {
|
||||
t.Fatalf("%s: [%g, %g] at k=%g integrated to (%g, %g) with no error, want a refusal",
|
||||
c.label, c.a, c.b, c.k, cos, sin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateFilonRejectsUnrepresentablePanels pins the two refusals
|
||||
// the infinite-bound guard alone does not reach. A panel estimate that
|
||||
// stays finite but sits beyond the int range converted to garbage: a
|
||||
// wrapped negative count iterated zero times and answered a quiet zero,
|
||||
// and a saturated count would iterate forever. And two finite bounds
|
||||
// far enough apart that their span overflows leave an infinite panel
|
||||
// width, where a constant amplitude stays finite and the carrier phase
|
||||
// 0·Inf comes back as a quiet NaN pair. Both answer with the error
|
||||
// contract instead.
|
||||
func TestIntegrateFilonRejectsUnrepresentablePanels(t *testing.T) {
|
||||
constant := func(float64) (float64, error) { return 1, nil }
|
||||
cases := []struct {
|
||||
label string
|
||||
a, b, k float64
|
||||
opts FilonOptions
|
||||
f func(x float64) (float64, error)
|
||||
}{
|
||||
{"finite estimate beyond the int range", 0, 1e8, 1e300, FilonOptions{}, constant},
|
||||
{"finite estimate just above the int range", 0, 1e18, 6e2, FilonOptions{}, constant},
|
||||
{"overflowing span at zero frequency", -math.MaxFloat64, math.MaxFloat64, 0, FilonOptions{}, constant},
|
||||
{"overflowing span with forced panels", -math.MaxFloat64, math.MaxFloat64, 0, FilonOptions{Panels: 2}, constant},
|
||||
{"overflowing span with a frequency", -math.MaxFloat64, math.MaxFloat64, 1, FilonOptions{}, constant},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cos, sin, err := IntegrateFilon(c.f, c.a, c.b, c.k, c.opts)
|
||||
if err == nil {
|
||||
t.Fatalf("%s: [%g, %g] at k=%g integrated to (%g, %g) with no error, want a refusal",
|
||||
c.label, c.a, c.b, c.k, cos, sin)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
)
|
||||
|
||||
// intFilon closed forms: the antiderivatives the referents come from.
|
||||
// intFilon1 integrates 1·cos(kx) and 1·sin(kx); intFilonX integrates x
|
||||
// against the same kernels; intFilonExp integrates e^{ax} against
|
||||
// them. All are exact calculus, evaluated independently of the code
|
||||
// under test.
|
||||
func intFilon1(a, b, k float64) (c, s float64) {
|
||||
return (math.Sin(k*b) - math.Sin(k*a)) / k,
|
||||
(math.Cos(k*a) - math.Cos(k*b)) / k
|
||||
}
|
||||
|
||||
func intFilonX(a, b, k float64) (c, s float64) {
|
||||
cb, sb := math.Cos(k*b), math.Sin(k*b)
|
||||
ca, sa := math.Cos(k*a), math.Sin(k*a)
|
||||
c = (cb+k*b*sb)/k/k - (ca+k*a*sa)/k/k
|
||||
s = (sb-k*b*cb)/k/k - (sa-k*a*ca)/k/k
|
||||
return c, s
|
||||
}
|
||||
|
||||
func intFilonExp(a, b, amp, k float64) (c, s float64) {
|
||||
cb, sb := math.Cos(k*b), math.Sin(k*b)
|
||||
ca, sa := math.Cos(k*a), math.Sin(k*a)
|
||||
eb, ea := math.Exp(amp*b), math.Exp(amp*a)
|
||||
c = eb*(amp*cb+k*sb)/(amp*amp+k*k) - ea*(amp*ca+k*sa)/(amp*amp+k*k)
|
||||
s = eb*(amp*sb-k*cb)/(amp*amp+k*k) - ea*(amp*sa-k*ca)/(amp*amp+k*k)
|
||||
return c, s
|
||||
}
|
||||
|
||||
func wantFilon(t *testing.T, label string, gotC, gotS, wantC, wantS, tol float64) {
|
||||
t.Helper()
|
||||
if d := math.Abs(gotC - wantC); d > tol*math.Max(1, math.Abs(wantC)) {
|
||||
t.Fatalf("%s: cos part = %.17g, want %.17g (absolute %.3g)", label, gotC, wantC, d)
|
||||
}
|
||||
if d := math.Abs(gotS - wantS); d > tol*math.Max(1, math.Abs(wantS)) {
|
||||
t.Fatalf("%s: sin part = %.17g, want %.17g (absolute %.3g)", label, gotS, wantS, d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateFilonExactAmplitudes pins the exactness the method
|
||||
// promises: unit and linear amplitudes are polynomials below the
|
||||
// default degree, so every frequency from one to a thousand must land
|
||||
// on the closed form at the rounding floor, whatever the carrier does
|
||||
// between the samples.
|
||||
func TestIntegrateFilonExactAmplitudes(t *testing.T) {
|
||||
one := func(float64) (float64, error) { return 1, nil }
|
||||
identity := func(x float64) (float64, error) { return x, nil }
|
||||
for _, c := range []struct {
|
||||
label string
|
||||
a, b float64
|
||||
k float64
|
||||
f func(float64) (float64, error)
|
||||
ref func(a, b, k float64) (c, s float64)
|
||||
}{
|
||||
{"unit on [0, π]", 0, math.Pi, 1, one, intFilon1},
|
||||
{"unit on [2, 7]", 2, 7, 100, one, intFilon1},
|
||||
{"unit on [0, 1]", 0, 1, 1000, one, intFilon1},
|
||||
{"x on [0, π]", 0, math.Pi, 1, identity, intFilonX},
|
||||
{"x on [2, 7]", 2, 7, 500, identity, intFilonX},
|
||||
} {
|
||||
gotC, gotS, err := IntegrateFilon(c.f, c.a, c.b, c.k, FilonOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.label, err)
|
||||
}
|
||||
wantC, wantS := c.ref(c.a, c.b, c.k)
|
||||
wantFilon(t, c.label, gotC, gotS, wantC, wantS, 1e-12)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateFilonZeroFrequency pins the degeneration at k = 0: the
|
||||
// sine part is exactly zero and the cosine part is the plain integral
|
||||
// of the amplitude.
|
||||
func TestIntegrateFilonZeroFrequency(t *testing.T) {
|
||||
f := func(x float64) (float64, error) { return x * x, nil }
|
||||
gotC, gotS, err := IntegrateFilon(f, 0, 3, 0, FilonOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateFilon: %v", err)
|
||||
}
|
||||
if gotS != 0 {
|
||||
t.Fatalf("the sine part at k = 0 is %g, want 0", gotS)
|
||||
}
|
||||
if d := math.Abs(gotC - 9); d > 1e-12 {
|
||||
t.Fatalf("the cosine part at k = 0 is %.17g, want 9", gotC)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateFilonExponential holds a non-polynomial amplitude
|
||||
// against the exact antiderivative at a frequency whose carrier the
|
||||
// automatic panel count must respect: two hundred and fifty
|
||||
// oscillations over the interval, answered from a few thousand
|
||||
// amplitude samples.
|
||||
func TestIntegrateFilonExponential(t *testing.T) {
|
||||
const amp = 0.5
|
||||
f := func(x float64) (float64, error) { return math.Exp(amp * x), nil }
|
||||
gotC, gotS, err := IntegrateFilon(f, 2, 7, 500, FilonOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateFilon: %v", err)
|
||||
}
|
||||
wantC, wantS := intFilonExp(2, 7, amp, 500)
|
||||
wantFilon(t, "exp amplitude", gotC, gotS, wantC, wantS, 1e-11)
|
||||
}
|
||||
|
||||
// TestIntegrateFilonOrientation pins the reversed interval and the
|
||||
// empty one: reversing negates both parts and an empty interval
|
||||
// integrates to nothing.
|
||||
func TestIntegrateFilonOrientation(t *testing.T) {
|
||||
f := func(x float64) (float64, error) { return math.Exp(0.2 * x), nil }
|
||||
fc, fs, err := IntegrateFilon(f, 0, 3, 40, FilonOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc, rs, err := IntegrateFilon(f, 3, 0, 40, FilonOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc != -fc || rs != -fs {
|
||||
t.Fatalf("the reversed interval gave (%.17g, %.17g), want the negation of (%.17g, %.17g)", rc, rs, fc, fs)
|
||||
}
|
||||
if ec, es, err := IntegrateFilon(f, 2, 2, 40, FilonOptions{}); err != nil || ec != 0 || es != 0 {
|
||||
t.Fatalf("the empty interval gave (%g, %g, %v)", ec, es, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateFilonDeterministic redraws one integral and requires
|
||||
// the same bits, the contract every entry point here carries.
|
||||
func TestIntegrateFilonDeterministic(t *testing.T) {
|
||||
f := func(x float64) (float64, error) { return math.Exp(0.1 * x), nil }
|
||||
one := func() (float64, float64) {
|
||||
c, s, err := IntegrateFilon(f, 0, 5, 300, FilonOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c, s
|
||||
}
|
||||
c1, s1 := one()
|
||||
c2, s2 := one()
|
||||
if c1 != c2 || s1 != s2 {
|
||||
t.Fatalf("the same call moved: (%.17g, %.17g) against (%.17g, %.17g)", c1, s1, c2, s2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateFilonErrors pins the contract: NaN bounds or frequency,
|
||||
// a node count out of range, a forced panel count whose panels carry
|
||||
// more carrier than the weights can be built within, and a failing or
|
||||
// non-finite amplitude all surface as errors naming themselves.
|
||||
func TestIntegrateFilonErrors(t *testing.T) {
|
||||
f := func(x float64) (float64, error) { return 1, nil }
|
||||
if _, _, err := IntegrateFilon(f, math.NaN(), 1, 10, FilonOptions{}); err == nil {
|
||||
t.Fatal("a NaN bound: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateFilon(f, 0, 1, math.NaN(), FilonOptions{}); err == nil {
|
||||
t.Fatal("a NaN frequency: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateFilon(f, 0, 1, math.Inf(1), FilonOptions{}); err == nil {
|
||||
t.Fatal("an infinite frequency: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateFilon(f, 0, 1, 10, FilonOptions{Nodes: 1}); err == nil {
|
||||
t.Fatal("one node: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateFilon(f, 0, 1, 10, FilonOptions{Nodes: 33}); err == nil {
|
||||
t.Fatal("33 nodes: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateFilon(f, 0, 1, 1e6, FilonOptions{Panels: 2}); err == nil {
|
||||
t.Fatal("two panels under a carrier of 1e6: want an error")
|
||||
}
|
||||
boom := func(float64) (float64, error) { return 0, base.Errf("amplitude failed") }
|
||||
if _, _, err := IntegrateFilon(boom, 0, 1, 10, FilonOptions{}); err == nil {
|
||||
t.Fatal("a failing amplitude: want the error to propagate")
|
||||
}
|
||||
bad := func(x float64) (float64, error) {
|
||||
if x > 0.5 {
|
||||
return math.NaN(), nil
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
if _, _, err := IntegrateFilon(bad, 0, 1, 10, FilonOptions{}); err == nil {
|
||||
t.Fatal("a non-finite amplitude value: want an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// The ADI sweeps enforce the boundary constants on the working state's
|
||||
// ring rows so the stencils read neighbours unconditionally, and the
|
||||
// y half-step builds its right sides in column pairs sharing the star
|
||||
// loads. The published history must not see any of that machinery:
|
||||
// sample 0 is the initial state exactly, and every later sample matches
|
||||
// a serial per-line reference bit for bit, non-zero boundary constants
|
||||
// included (the previous pins all carried zero boundaries, so a swapped
|
||||
// constant passed silently).
|
||||
|
||||
// heat2DReference walks the documented alternating-direction scheme one
|
||||
// line at a time with fresh scratch: the same expressions in the same
|
||||
// order the kernel's lanes use, so equal reads give equal bits and the
|
||||
// comparison below is exact.
|
||||
func heat2DReference(u0 []float64, rows, cols int, kappa, dx, dy, tFinal, dt float64, samples int, bb, bt, bl, br float64) [][]float64 {
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
rx := kappa * h / (2 * dx * dx)
|
||||
ry := kappa * h / (2 * dy * dy)
|
||||
u := append([]float64(nil), u0...)
|
||||
history := [][]float64{append([]float64(nil), u...)}
|
||||
for c := range cols {
|
||||
u[c] = bb
|
||||
u[(rows-1)*cols+c] = bt
|
||||
}
|
||||
every := steps / (samples - 1)
|
||||
star := make([]float64, rows*cols)
|
||||
lowerX := make([]float64, cols-3)
|
||||
upperX := make([]float64, cols-3)
|
||||
diagX := make([]float64, cols-2)
|
||||
for i := range lowerX {
|
||||
lowerX[i] = -rx
|
||||
upperX[i] = -rx
|
||||
}
|
||||
for i := range diagX {
|
||||
diagX[i] = 1 + 2*rx
|
||||
}
|
||||
lowerY := make([]float64, rows-3)
|
||||
upperY := make([]float64, rows-3)
|
||||
diagY := make([]float64, rows-2)
|
||||
for i := range lowerY {
|
||||
lowerY[i] = -ry
|
||||
upperY[i] = -ry
|
||||
}
|
||||
for i := range diagY {
|
||||
diagY[i] = 1 + 2*ry
|
||||
}
|
||||
for s := 1; s <= steps; s++ {
|
||||
clear(star)
|
||||
for r := 1; r < rows-1; r++ {
|
||||
row := u[r*cols : (r+1)*cols]
|
||||
up := u[(r+1)*cols : (r+2)*cols]
|
||||
down := u[(r-1)*cols : r*cols]
|
||||
rhs := make([]float64, cols)
|
||||
for c := range cols {
|
||||
rhs[c] = row[c] + ry*(up[c]-2*row[c]+down[c])
|
||||
}
|
||||
rhs[1] += rx * bl
|
||||
rhs[cols-2] += rx * br
|
||||
dst := star[r*cols+1 : r*cols+cols-1]
|
||||
err := base.TriSolve(dst, make([]float64, cols-2), make([]float64, cols-2),
|
||||
lowerX, diagX, upperX, rhs[1:cols-1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
star[r*cols] = bl
|
||||
star[r*cols+cols-1] = br
|
||||
}
|
||||
for c := 1; c < cols-1; c++ {
|
||||
rhs := make([]float64, rows)
|
||||
for r := range rows {
|
||||
off := r * cols
|
||||
l := star[off+c-1]
|
||||
wm := star[off+c]
|
||||
e := star[off+c+1]
|
||||
rhs[r] = wm + rx*(e-2*wm+l)
|
||||
}
|
||||
rhs[1] += ry * bb
|
||||
rhs[rows-2] += ry * bt
|
||||
dst := make([]float64, rows-2)
|
||||
err := base.TriSolve(dst, make([]float64, rows-2), make([]float64, rows-2),
|
||||
lowerY, diagY, upperY, rhs[1:rows-1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
u[c] = bb
|
||||
u[(rows-1)*cols+c] = bt
|
||||
for r := 1; r < rows-1; r++ {
|
||||
u[r*cols+c] = dst[r-1]
|
||||
}
|
||||
}
|
||||
for r := range rows {
|
||||
u[r*cols] = bl
|
||||
u[r*cols+cols-1] = br
|
||||
}
|
||||
if s%every == 0 && len(history) < samples {
|
||||
history = append(history, append([]float64(nil), u...))
|
||||
}
|
||||
}
|
||||
history[samples-1] = append([]float64(nil), u...)
|
||||
return history
|
||||
}
|
||||
|
||||
func TestHeat2DSampleZeroIsInitialState(t *testing.T) {
|
||||
// The ring enforcement belongs to the working state: sample 0 is
|
||||
// the initial state exactly, boundary constants and corners
|
||||
// included.
|
||||
const rows, cols = 3, 4
|
||||
u0 := []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
|
||||
state, err := core.FromFloats(u0, rows, cols)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
hist, err := IntegrateHeat2D(state, 1, 1, 1, 0.1, 0.05, 2, 1, 2, 3, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat2D: %v", err)
|
||||
}
|
||||
got := hist.RawFloats()
|
||||
for i := range u0 {
|
||||
if got[i] != u0[i] {
|
||||
t.Fatalf("sample 0 element %d = %v, want the initial %v", i, got[i], u0[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeat2DSamplesMatchSerialReference(t *testing.T) {
|
||||
const rows, cols = 5, 7
|
||||
u0 := make([]float64, rows*cols)
|
||||
for i := range u0 {
|
||||
u0[i] = float64(i%13) - 6
|
||||
}
|
||||
state, err := core.FromFloats(u0, rows, cols)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
const (
|
||||
kappa, dx, dy = 0.7, 0.3, 0.25
|
||||
tFinal, dt = 0.08, 0.01
|
||||
samples = 4
|
||||
bb, bt, bl, br = 1.5, -2.25, 3.125, -4.5
|
||||
)
|
||||
hist, err := IntegrateHeat2D(state, kappa, dx, dy, tFinal, dt, samples, bb, bt, bl, br)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat2D: %v", err)
|
||||
}
|
||||
want := heat2DReference(u0, rows, cols, kappa, dx, dy, tFinal, dt, samples, bb, bt, bl, br)
|
||||
got := hist.RawFloats()
|
||||
for s := range samples {
|
||||
for i := range u0 {
|
||||
if got[s*rows*cols+i] != want[s][i] {
|
||||
t.Fatalf("sample %d element %d = %v, want %v", s, i, got[s*rows*cols+i], want[s][i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// mustFloats builds a float array, failing the test on a bad shape.
|
||||
// Without an explicit shape it defaults to a vector of len(vals).
|
||||
func mustFloats(t *testing.T, vals []float64, shape ...int) *core.Array {
|
||||
t.Helper()
|
||||
if len(shape) == 0 {
|
||||
shape = []int{len(vals)}
|
||||
}
|
||||
a, err := core.FromFloats(vals, shape...)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats(%v, %v): %v", vals, shape, err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// 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 (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// decay returns f for y' = −y, the reference every scheme must nail.
|
||||
func decay(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.MulF(y, -1), nil
|
||||
}
|
||||
|
||||
// TestIntegrateODEExponential checks the adaptive solver against the
|
||||
// analytic exponential decay, forward and backward in time.
|
||||
func TestIntegrateODEExponential(t *testing.T) {
|
||||
y0 := mustFloats(t, []float64{1})
|
||||
end, err := IntegrateODE(decay, 0, 1, y0, ODEOptions{RelTol: 1e-10, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODE: %v", err)
|
||||
}
|
||||
if math.Abs(end.FloatAt(0)-math.Exp(-1)) > 1e-9 {
|
||||
t.Fatalf("y(1) = %.14g, want %.14g", end.FloatAt(0), math.Exp(-1))
|
||||
}
|
||||
// Backward integration from t=1 to t=0 must return the start.
|
||||
start, err := IntegrateODE(decay, 1, 0, mustFloats(t, []float64{math.Exp(-1)}),
|
||||
ODEOptions{RelTol: 1e-10, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODE backward: %v", err)
|
||||
}
|
||||
if math.Abs(start.FloatAt(0)-1) > 1e-8 {
|
||||
t.Fatalf("backward y(0) = %.14g, want 1", start.FloatAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEOscillator checks a two-dimensional linear system
|
||||
// against the analytic phase rotation.
|
||||
func TestIntegrateODEOscillator(t *testing.T) {
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
y0 := mustFloats(t, []float64{1, 0})
|
||||
end, err := IntegrateODE(f, 0, math.Pi/2, y0, ODEOptions{RelTol: 1e-11, AbsTol: 1e-13})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODE: %v", err)
|
||||
}
|
||||
// y1 = cos t, y2 = −sin t, so a quarter period lands on (0, −1).
|
||||
if math.Abs(end.FloatAt(0)) > 1e-7 || math.Abs(end.FloatAt(1)+1) > 1e-7 {
|
||||
t.Fatalf("quarter period = (%.10g, %.10g), want (0, -1)",
|
||||
end.FloatAt(0), end.FloatAt(1))
|
||||
}
|
||||
// A full period returns to the start.
|
||||
full, err := IntegrateODE(f, 0, 2*math.Pi, y0, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODE full: %v", err)
|
||||
}
|
||||
for i := range 2 {
|
||||
if math.Abs(full.FloatAt(i)-y0.FloatAt(i)) > 1e-6 {
|
||||
t.Fatalf("full period[%d] = %v, want %v", i, full.FloatAt(i), y0.FloatAt(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateRk4 checks the fixed-step scheme's fourth-order
|
||||
// convergence: halving h must shrink the error by roughly sixteen.
|
||||
func TestIntegrateRk4(t *testing.T) {
|
||||
errAt := func(steps int) float64 {
|
||||
end, err := IntegrateRK4(decay, 0, 1, mustFloats(t, []float64{1}), steps)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateRK4(%d): %v", steps, err)
|
||||
}
|
||||
return math.Abs(end.FloatAt(0) - math.Exp(-1))
|
||||
}
|
||||
e10, e20 := errAt(10), errAt(20)
|
||||
if e10 < 1e-13 {
|
||||
t.Skipf("error already at round-off (%v)", e10)
|
||||
}
|
||||
ratio := e10 / e20
|
||||
if ratio < 12 || ratio > 20 {
|
||||
t.Fatalf("error ratio over a halved step = %.2g, want ≈ 16 for a fourth-order scheme", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBackwardEulerStiff demonstrates the reason an implicit
|
||||
// scheme exists: on y' = −1000y the implicit Euler stays bounded and
|
||||
// matches its closed-form damping at a step where explicit schemes
|
||||
// blow up.
|
||||
func TestIntegrateBackwardEulerStiff(t *testing.T) {
|
||||
const lambda = 1000.0
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.MulF(y, -lambda), nil
|
||||
}
|
||||
// Ten steps of h = 0.01: h·λ = 10, far outside RK4's stability
|
||||
// region but perfectly damped for the implicit scheme.
|
||||
y0 := mustFloats(t, []float64{1})
|
||||
end, err := IntegrateBackwardEuler(f, 0, 0.1, y0, 10, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBackwardEuler: %v", err)
|
||||
}
|
||||
// Closed form of one implicit Euler step: y_{n+1} = y_n/(1+hλ),
|
||||
// so y_10 = (1/11)^10.
|
||||
damped := math.Pow(1/(1+0.01*lambda), 10)
|
||||
if math.Abs(end.FloatAt(0)-damped) > 1e-9*math.Abs(damped) {
|
||||
t.Fatalf("stiff result = %.12g, want %.12g", end.FloatAt(0), damped)
|
||||
}
|
||||
if end.FloatAt(0) <= 0 {
|
||||
t.Fatalf("the implicit scheme must stay positive on decay, got %v", end.FloatAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBackwardEulerAccuracy checks that with a sane step the
|
||||
// implicit scheme tracks the analytic decay as well.
|
||||
func TestIntegrateBackwardEulerAccuracy(t *testing.T) {
|
||||
end, err := IntegrateBackwardEuler(decay, 0, 1, mustFloats(t, []float64{1}), 1000, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBackwardEuler: %v", err)
|
||||
}
|
||||
// First order: the global error is O(h), about h/2 for decay.
|
||||
if math.Abs(end.FloatAt(0)-math.Exp(-1)) > 1e-3 {
|
||||
t.Fatalf("y(1) = %.14g, want %.14g ± 1e-3", end.FloatAt(0), math.Exp(-1))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBackwardEulerVectorState pins the Newton solve on a
|
||||
// two-dimensional stiff system: both modes decay at their own rate,
|
||||
// and constant steps give each component the closed form
|
||||
// y(t1) = y0·(1+h·λ)^{−steps} an implicit Euler step has on y' = −λy.
|
||||
func TestIntegrateBackwardEulerVectorState(t *testing.T) {
|
||||
const slow = 1.0
|
||||
const fast = 1000.0
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-fast * y.FloatAt(0), -slow * y.FloatAt(1)}, 2)
|
||||
}
|
||||
y0 := mustFloats(t, []float64{1, 1})
|
||||
end, err := IntegrateBackwardEuler(f, 0, 1, y0, 10, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBackwardEuler: %v", err)
|
||||
}
|
||||
wantFast := math.Pow(1/(1+0.1*fast), 10)
|
||||
if math.Abs(end.FloatAt(0)-wantFast) > 1e-9*math.Abs(wantFast) {
|
||||
t.Fatalf("fast mode = %.14g, want %.14g", end.FloatAt(0), wantFast)
|
||||
}
|
||||
wantSlow := math.Pow(1/(1+0.1*slow), 10)
|
||||
if math.Abs(end.FloatAt(1)-wantSlow) > 1e-9*math.Abs(wantSlow) {
|
||||
t.Fatalf("slow mode = %.14g, want %.14g", end.FloatAt(1), wantSlow)
|
||||
}
|
||||
}
|
||||
|
||||
// TestODEErrors pins the error contracts shared by the solvers.
|
||||
func TestODEErrors(t *testing.T) {
|
||||
wrongShape := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{1, 1}, 2)
|
||||
}
|
||||
y0 := mustFloats(t, []float64{1})
|
||||
if _, err := IntegrateODE(wrongShape, 0, 1, y0, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error when f returns the wrong shape")
|
||||
}
|
||||
if _, err := IntegrateODE(wrongShape, 0, 1, y0, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error when f returns the wrong shape in RK4 as well")
|
||||
}
|
||||
if _, err := IntegrateRK4(decay, 0, 1, y0, 0); err == nil {
|
||||
t.Fatal("expected an error for zero steps")
|
||||
}
|
||||
matrixState := mustFloats(t, []float64{1, 1}, 1, 2)
|
||||
if _, err := IntegrateODE(decay, 0, 1, matrixState, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a rank-2 state")
|
||||
}
|
||||
empty := mustFloats(t, nil)
|
||||
if _, err := IntegrateODE(decay, 0, 1, empty, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an empty state")
|
||||
}
|
||||
// A tight budget on a slow decay must report the budget, not lie.
|
||||
if _, err := IntegrateODE(decay, 0, 1, y0, ODEOptions{MaxSteps: 3}); err == nil {
|
||||
t.Fatal("expected an error for an exhausted step budget")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEPathExponential checks the sampled trajectory of
|
||||
// y' = −y against the analytic decay at every sample point.
|
||||
func TestIntegrateODEPathExponential(t *testing.T) {
|
||||
const n = 5
|
||||
times, states, err := IntegrateODEPath(decay, 0, 2, mustFloats(t, []float64{1}),
|
||||
n, ODEOptions{RelTol: 1e-10, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODEPath: %v", err)
|
||||
}
|
||||
if len(times) != n || len(states) != n {
|
||||
t.Fatalf("lengths (%d, %d), want (%d, %d)", len(times), len(states), n, n)
|
||||
}
|
||||
for i := range n {
|
||||
want := 0.5 * float64(i)
|
||||
if math.Abs(times[i]-want) > 1e-12 {
|
||||
t.Fatalf("times[%d] = %.14g, want %.14g", i, times[i], want)
|
||||
}
|
||||
got := states[i].FloatAt(0)
|
||||
if math.Abs(got-math.Exp(-want)) > 1e-9 {
|
||||
t.Fatalf("y(%.1f) = %.14g, want %.14g", want, got, math.Exp(-want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEPathBackward samples a backwards integration: the
|
||||
// times descend and the analytic law still holds per sample.
|
||||
func TestIntegrateODEPathBackward(t *testing.T) {
|
||||
times, states, err := IntegrateODEPath(decay, 2, 0, mustFloats(t, []float64{math.Exp(-2)}),
|
||||
3, ODEOptions{RelTol: 1e-10, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODEPath backward: %v", err)
|
||||
}
|
||||
for i, want := range []float64{2, 1, 0} {
|
||||
if math.Abs(times[i]-want) > 1e-12 {
|
||||
t.Fatalf("times[%d] = %.14g, want %g", i, times[i], want)
|
||||
}
|
||||
if math.Abs(states[i].FloatAt(0)-math.Exp(-want)) > 1e-9 {
|
||||
t.Fatalf("y(%g) = %.14g, want %.14g", want, states[i].FloatAt(0), math.Exp(-want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEPathStatesIndependent checks the returned states do
|
||||
// not alias one another or the initial vector: a later integration
|
||||
// must never rewrite an earlier sample.
|
||||
func TestIntegrateODEPathStatesIndependent(t *testing.T) {
|
||||
y0 := mustFloats(t, []float64{1, 0})
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
_, states, err := IntegrateODEPath(f, 0, 1, y0, 4, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODEPath: %v", err)
|
||||
}
|
||||
states[3].RawFloats()[0] = 99 // must not leak into y0 or the other samples
|
||||
if y0.FloatAt(0) != 1 {
|
||||
t.Fatal("mutating a sample changed the caller's initial state")
|
||||
}
|
||||
if states[2].FloatAt(0) == 99 {
|
||||
t.Fatal("samples alias one another")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEPathErrors pins the path-specific error contract.
|
||||
func TestIntegrateODEPathErrors(t *testing.T) {
|
||||
y0 := mustFloats(t, []float64{1})
|
||||
if _, _, err := IntegrateODEPath(decay, 0, 1, y0, 1, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a single sample")
|
||||
}
|
||||
// An f that fails past the midpoint must fail the whole path.
|
||||
boom := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
if t > 0.6 {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
return core.MulF(y, -1), nil
|
||||
}
|
||||
if _, _, err := IntegrateODEPath(boom, 0, 1, y0, 5, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected the operator error to propagate")
|
||||
}
|
||||
if _, _, err := IntegrateODEPath(decay, 0, 1, mustFloats(t, nil), 3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an empty state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEEventsProjectile drops a projectile and watches its
|
||||
// height: the crossing of zero is the flight time 2v₀/g, analytically
|
||||
// known, with the state's velocity the exact mirror of the launch.
|
||||
func TestIntegrateODEEventsProjectile(t *testing.T) {
|
||||
const g = 9.81
|
||||
const v0 = 10
|
||||
f := func(now float64, y *core.Array) (*core.Array, error) {
|
||||
return mustFloats(t, []float64{y.FloatAt(1), -g}), nil
|
||||
}
|
||||
height := func(now float64, y *core.Array) (float64, error) {
|
||||
return y.FloatAt(0), nil
|
||||
}
|
||||
hits, final, err := IntegrateODEEvents(f, 0, 5, mustFloats(t, []float64{0, v0}),
|
||||
[]ODEWatch{{Function: height, Direction: -1}}, ODEOptions{RelTol: 1e-12, AbsTol: 1e-14})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODEEvents: %v", err)
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("hits = %d, want 1", len(hits))
|
||||
}
|
||||
wantT := 2 * v0 / g
|
||||
if math.Abs(hits[0].Time-wantT) > 1e-9 {
|
||||
t.Fatalf("impact at %.14g, want %.14g", hits[0].Time, wantT)
|
||||
}
|
||||
if math.Abs(hits[0].State.FloatAt(1)+v0) > 1e-7 {
|
||||
t.Fatalf("impact speed %.10g, want %.10g", hits[0].State.FloatAt(1), float64(-v0))
|
||||
}
|
||||
if hits[0].Rising {
|
||||
t.Fatal("height crossing on the way down must not be rising")
|
||||
}
|
||||
if final.Len() != 2 {
|
||||
t.Fatalf("final state shape %v", final.Shape())
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEEventsOscillator watches the oscillator's position
|
||||
// over five periods: cos crosses zero once per half period, and the
|
||||
// falling-only filter keeps every second one, at π/2 + 2πk.
|
||||
func TestIntegrateODEEventsOscillator(t *testing.T) {
|
||||
f := func(now float64, y *core.Array) (*core.Array, error) {
|
||||
return mustFloats(t, []float64{y.FloatAt(1), -y.FloatAt(0)}), nil
|
||||
}
|
||||
position := func(now float64, y *core.Array) (float64, error) {
|
||||
return y.FloatAt(0), nil
|
||||
}
|
||||
hits, _, err := IntegrateODEEvents(f, 0, 5*2*math.Pi, mustFloats(t, []float64{1, 0}),
|
||||
[]ODEWatch{{Function: position, Direction: -1}}, ODEOptions{RelTol: 1e-12, AbsTol: 1e-14})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODEEvents: %v", err)
|
||||
}
|
||||
if len(hits) != 5 {
|
||||
t.Fatalf("hits = %d, want 5 falling crossings", len(hits))
|
||||
}
|
||||
for k, hit := range hits {
|
||||
want := math.Pi/2 + float64(2*k)*math.Pi
|
||||
if math.Abs(hit.Time-want) > 1e-8 {
|
||||
t.Fatalf("hit %d at %.12g, want %.12g", k, hit.Time, want)
|
||||
}
|
||||
}
|
||||
// Without the filter every half period fires: ten crossings.
|
||||
hits, _, err = IntegrateODEEvents(f, 0, 5*2*math.Pi, mustFloats(t, []float64{1, 0}),
|
||||
[]ODEWatch{{Function: position}}, ODEOptions{RelTol: 1e-12, AbsTol: 1e-14})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODEEvents unfiltered: %v", err)
|
||||
}
|
||||
if len(hits) != 10 {
|
||||
t.Fatalf("unfiltered hits = %d, want 10", len(hits))
|
||||
}
|
||||
for i := 1; i < len(hits); i++ {
|
||||
if hits[i].Time <= hits[i-1].Time {
|
||||
t.Fatal("hits are not sorted by time")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEStepsErrors pins the error contract of the step
|
||||
// recorder.
|
||||
func TestIntegrateODEStepsErrors(t *testing.T) {
|
||||
y0 := mustFloats(t, []float64{1})
|
||||
if _, _, err := IntegrateODESteps(decay, 0, 1, y0, ODEOptions{MaxSteps: 2}); err == nil {
|
||||
t.Fatal("expected an error for an exhausted step budget")
|
||||
}
|
||||
if _, _, err := IntegrateODESteps(decay, 0, 1, mustFloats(t, nil), ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an empty state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEStepsForward records the accepted steps of a decay:
|
||||
// the trace starts at the initial state, ends exactly at t1, and every
|
||||
// node sits on the analytic curve at solver tolerance.
|
||||
func TestIntegrateODEStepsForward(t *testing.T) {
|
||||
times, states, err := IntegrateODESteps(decay, 0, 1, mustFloats(t, []float64{1}),
|
||||
ODEOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODESteps: %v", err)
|
||||
}
|
||||
if len(times) != len(states) || len(times) < 3 {
|
||||
t.Fatalf("trace has %d times and %d states, want matching lengths of at least 3",
|
||||
len(times), len(states))
|
||||
}
|
||||
if times[0] != 0 || times[len(times)-1] != 1 {
|
||||
t.Fatalf("trace spans [%g, %g], want [0, 1]", times[0], times[len(times)-1])
|
||||
}
|
||||
for i := 1; i < len(times); i++ {
|
||||
if times[i] <= times[i-1] {
|
||||
t.Fatalf("times not strictly increasing at %d: %g after %g", i, times[i], times[i-1])
|
||||
}
|
||||
}
|
||||
if states[0].FloatAt(0) != 1 {
|
||||
t.Fatalf("first state = %v, want the initial state 1", states[0].FloatAt(0))
|
||||
}
|
||||
for i := range times {
|
||||
if math.Abs(states[i].FloatAt(0)-math.Exp(-times[i])) > 1e-6 {
|
||||
t.Fatalf("y(%g) = %.14g, want %.14g", times[i], states[i].FloatAt(0), math.Exp(-times[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEStepsDegenerate covers the zero-span trace and a
|
||||
// backward pass with descending times.
|
||||
func TestIntegrateODEStepsDegenerate(t *testing.T) {
|
||||
times, states, err := IntegrateODESteps(decay, 1, 1, mustFloats(t, []float64{1}), ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODESteps zero span: %v", err)
|
||||
}
|
||||
if len(times) != 1 || times[0] != 1 || states[0].FloatAt(0) != 1 {
|
||||
t.Fatalf("zero-span trace = %v, want the single initial node", times)
|
||||
}
|
||||
times, states, err = IntegrateODESteps(decay, 1, 0, mustFloats(t, []float64{math.Exp(-1)}),
|
||||
ODEOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODESteps backward: %v", err)
|
||||
}
|
||||
if times[0] != 1 || times[len(times)-1] != 0 {
|
||||
t.Fatalf("backward trace spans [%g, %g], want [1, 0]", times[0], times[len(times)-1])
|
||||
}
|
||||
for i := 1; i < len(times); i++ {
|
||||
if times[i] >= times[i-1] {
|
||||
t.Fatalf("backward times not strictly decreasing at %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateODEEventsErrors pins the validation and error paths.
|
||||
func TestIntegrateODEEventsErrors(t *testing.T) {
|
||||
f := decay
|
||||
if _, _, err := IntegrateODEEvents(f, 0, 1, mustFloats(t, []float64{1}),
|
||||
nil, ODEOptions{}); err == nil {
|
||||
t.Fatal("no watches: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateODEEvents(f, 0, 1, mustFloats(t, []float64{1}),
|
||||
[]ODEWatch{{}}, ODEOptions{}); err == nil {
|
||||
t.Fatal("empty watch: want an error")
|
||||
}
|
||||
boom := func(t float64, y *core.Array) (float64, error) {
|
||||
return 0, base.Errf("watch failed")
|
||||
}
|
||||
if _, _, err := IntegrateODEEvents(f, 0, 1, mustFloats(t, []float64{1}),
|
||||
[]ODEWatch{{Function: boom}}, ODEOptions{}); err == nil {
|
||||
t.Fatal("watch error: want an error")
|
||||
}
|
||||
// An f that fails on the refinement path must surface.
|
||||
broken := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
if t > 0.5 {
|
||||
return nil, base.Errf("integrand failed")
|
||||
}
|
||||
return core.MulF(y, -1), nil
|
||||
}
|
||||
cross := func(t float64, y *core.Array) (float64, error) {
|
||||
return 1 - t, nil
|
||||
}
|
||||
if _, _, err := IntegrateODEEvents(broken, 0, 2, mustFloats(t, []float64{1}),
|
||||
[]ODEWatch{{Function: cross}}, ODEOptions{}); err == nil {
|
||||
t.Fatal("refinement error: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestODEArrivalGuard pins the ulp-level arrival rule: an endpoint
|
||||
// missed by rounding terminates the loop, a genuine remaining distance
|
||||
// does not, and a plain integration across magnitudes still lands.
|
||||
func TestODEArrivalGuard(t *testing.T) {
|
||||
if !odeArrived(1, 1) {
|
||||
t.Fatal("equal times must count as arrived")
|
||||
}
|
||||
if !odeArrived(1, 1+4*2.220446049250313e-16) {
|
||||
t.Fatal("a four-ulp miss must count as arrived")
|
||||
}
|
||||
if odeArrived(1, 1.1) {
|
||||
t.Fatal("a genuine remaining distance must not count as arrived")
|
||||
}
|
||||
if odeArrived(0, -1e-20) {
|
||||
t.Fatal("a tiny but representable distance near zero must not count as arrived")
|
||||
}
|
||||
// An integration whose endpoints differ well beyond Sterbenz still
|
||||
// terminates and reports the endpoint state.
|
||||
f := func(tt float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(0) * 0}, 1)
|
||||
}
|
||||
y0, _ := core.FromFloats([]float64{3}, 1)
|
||||
got, err := IntegrateODE(f, 1e9, 1e9+0.5, y0, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateODE across magnitudes: %v", err)
|
||||
}
|
||||
if got.FloatAt(0) != 3 {
|
||||
t.Fatalf("constant state changed: %g", got.FloatAt(0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// 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 (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stiffCosine returns f for y' = −k(y − cos t), the canonical stiff
|
||||
// problem: a slow forcing with a transient decaying at rate k.
|
||||
func stiffCosine(k float64) func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-k * (y.FloatAt(0) - math.Cos(t))}, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBDF2Stiff is the demonstration the stiff solver exists
|
||||
// for: y' = −10^5(y − cos t) carries a transient of width 10^−5 under
|
||||
// a slow forcing, and BDF2 crosses it and follows the forcing to t=1
|
||||
// inside a 2000-step budget, landing on the exact solution
|
||||
// y(1) = (k²·cos 1 + k·sin 1)/(k² + 1).
|
||||
func TestIntegrateBDF2Stiff(t *testing.T) {
|
||||
const k = 1e5
|
||||
end, err := IntegrateBDF2(stiffCosine(k), 0, 1, mustFloats(t, []float64{0}),
|
||||
ODEOptions{MaxSteps: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBDF2: %v", err)
|
||||
}
|
||||
want := (k*k*math.Cos(1) + k*math.Sin(1)) / (k*k + 1)
|
||||
if math.Abs(end.FloatAt(0)-want) > 1e-6 {
|
||||
t.Fatalf("y(1) = %.14g, want %.14g", end.FloatAt(0), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBDF2StiffBeatsExplicit shows the same problem is out
|
||||
// of reach for the explicit pair: stability pins DOPRI to steps of
|
||||
// order 1/k, so a 5000-step budget dies a fifth of the way in.
|
||||
func TestIntegrateBDF2StiffBeatsExplicit(t *testing.T) {
|
||||
_, err := IntegrateODE(stiffCosine(1e5), 0, 1, mustFloats(t, []float64{0}),
|
||||
ODEOptions{MaxSteps: 5000})
|
||||
if err == nil {
|
||||
t.Fatal("explicit DOPRI was expected to exhaust its step budget on the stiff problem")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MaxSteps=5000") {
|
||||
t.Fatalf("want a step-budget error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBDF2FixedStepOrder verifies the second order of the underlying
|
||||
// formula directly: with exact history on y' = −y and uniform steps,
|
||||
// halving h must quarter the global error. The Milne constant for
|
||||
// equal steps is pinned to 2/11 along the way.
|
||||
func TestBDF2FixedStepOrder(t *testing.T) {
|
||||
if got := bdf2Milne(1, 2, 0, -1); math.Abs(got-2.0/11) > 1e-12 {
|
||||
t.Fatalf("bdf2Milne for equal steps = %.14g, want 2/11", got)
|
||||
}
|
||||
errAt := func(steps int) float64 {
|
||||
h := 1.0 / float64(steps)
|
||||
alpha := 1.5 / h
|
||||
now := 0.0
|
||||
yn := []float64{1}
|
||||
yNm1 := []float64{math.Exp(h)} // exact history at t−h
|
||||
w := &odeWork{}
|
||||
for range steps {
|
||||
tNext := now + h
|
||||
beta := []float64{2*yn[0]/h - yNm1[0]/(2*h)}
|
||||
z := make([]float64, 1)
|
||||
err := odeNewton("TestBDF2FixedStepOrder", decay, w, tNext, alpha, 1,
|
||||
beta, []float64{math.Exp(-tNext)}, z, 1e-13, 1e-13)
|
||||
if err != nil {
|
||||
t.Fatalf("odeNewton: %v", err)
|
||||
}
|
||||
yNm1 = yn
|
||||
yn = z
|
||||
now = tNext
|
||||
}
|
||||
return math.Abs(yn[0] - math.Exp(-1))
|
||||
}
|
||||
e20, e40 := errAt(20), errAt(40)
|
||||
if e20 < 1e-12 {
|
||||
t.Skipf("error already at round-off (%v)", e20)
|
||||
}
|
||||
ratio := e20 / e40
|
||||
if ratio < 3 || ratio > 5.2 {
|
||||
t.Fatalf("error ratio over a halved step = %.2g, want ≈ 4 for a second-order scheme", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBDF2Accuracy checks the adaptive driver on a smooth
|
||||
// problem against the analytic decay at a tolerance far below the
|
||||
// default, and over a full oscillator period with a two-dimensional
|
||||
// state, exercising the vector Newton path.
|
||||
func TestIntegrateBDF2Accuracy(t *testing.T) {
|
||||
end, err := IntegrateBDF2(decay, 0, 1, mustFloats(t, []float64{1}),
|
||||
ODEOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBDF2: %v", err)
|
||||
}
|
||||
if math.Abs(end.FloatAt(0)-math.Exp(-1)) > 1e-5 {
|
||||
t.Fatalf("y(1) = %.14g, want %.14g ± 1e-5", end.FloatAt(0), math.Exp(-1))
|
||||
}
|
||||
oscillator := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
full, err := IntegrateBDF2(oscillator, 0, 2*math.Pi, mustFloats(t, []float64{1, 0}),
|
||||
ODEOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBDF2 oscillator: %v", err)
|
||||
}
|
||||
if math.Abs(full.FloatAt(0)-1) > 1e-4 || math.Abs(full.FloatAt(1)) > 1e-4 {
|
||||
t.Fatalf("full period = (%.10g, %.10g), want (1, 0)",
|
||||
full.FloatAt(0), full.FloatAt(1))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBDF2Backward integrates the decay backwards from t=1
|
||||
// to t=0; the signed-step formulation must return the start value.
|
||||
func TestIntegrateBDF2Backward(t *testing.T) {
|
||||
end, err := IntegrateBDF2(decay, 1, 0, mustFloats(t, []float64{math.Exp(-1)}),
|
||||
ODEOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBDF2 backward: %v", err)
|
||||
}
|
||||
if math.Abs(end.FloatAt(0)-1) > 1e-5 {
|
||||
t.Fatalf("backward y(0) = %.14g, want 1 ± 1e-5", end.FloatAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBDF2Errors pins the error contract: a degenerate span
|
||||
// returns the initial state unchanged, a wrong-shaped f, a rank-2
|
||||
// state, an empty state and an exhausted step budget are errors.
|
||||
func TestIntegrateBDF2Errors(t *testing.T) {
|
||||
y0 := mustFloats(t, []float64{1})
|
||||
same, err := IntegrateBDF2(decay, 1, 1, y0, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("zero span: %v", err)
|
||||
}
|
||||
if math.Abs(same.FloatAt(0)-1) > 0 {
|
||||
t.Fatalf("zero span moved the state to %v", same.FloatAt(0))
|
||||
}
|
||||
wrongShape := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{1, 1}, 2)
|
||||
}
|
||||
if _, err := IntegrateBDF2(wrongShape, 0, 1, y0, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error when f returns the wrong shape")
|
||||
}
|
||||
matrixState := mustFloats(t, []float64{1, 1}, 1, 2)
|
||||
if _, err := IntegrateBDF2(decay, 0, 1, matrixState, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a rank-2 state")
|
||||
}
|
||||
if _, err := IntegrateBDF2(decay, 0, 1, mustFloats(t, nil), ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an empty state")
|
||||
}
|
||||
if _, err := IntegrateBDF2(decay, 0, 1, y0, ODEOptions{MaxSteps: 2}); err == nil {
|
||||
t.Fatal("expected an error for an exhausted step budget")
|
||||
}
|
||||
boom := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
if t > 0.5 {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
return core.MulF(y, -1), nil
|
||||
}
|
||||
if _, err := IntegrateBDF2(boom, 0, 1, y0, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected the operator error to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBDF2InitialStepBackwardProbe pins the probe direction: on a
|
||||
// backward span the initial-step probe must sample the dynamics at
|
||||
// t0 − h0, not extrapolate forward, so every evaluation time either
|
||||
// sits before t0 or the probe is wrong.
|
||||
func TestBDF2InitialStepBackwardProbe(t *testing.T) {
|
||||
var calls []float64
|
||||
f := func(tt float64, y *core.Array) (*core.Array, error) {
|
||||
calls = append(calls, tt)
|
||||
return mustFloats(t, []float64{0}), nil
|
||||
}
|
||||
h, err := bdf2InitialStep("TestBDF2InitialStep", f, 5, 0, []float64{1}, &ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("bdf2InitialStep: %v", err)
|
||||
}
|
||||
if h >= 0 {
|
||||
t.Fatalf("backward span must yield a negative first step, got %g", h)
|
||||
}
|
||||
backward := false
|
||||
for _, c := range calls {
|
||||
if c < 5 {
|
||||
backward = true
|
||||
}
|
||||
}
|
||||
if !backward {
|
||||
t.Fatalf("the probe never stepped backward from t0 = 5, evaluated at %v", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// 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 (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIntegrateBDFVarStiff is the demonstration pin: on y' =
|
||||
// −10^5(y − cos t) the variable-order driver lands on the exact y(1) =
|
||||
// (k²·cos 1 + k·sin 1)/(k² + 1) inside half the step budget BDF2
|
||||
// needed, having raised to order five on the smooth tail.
|
||||
func TestIntegrateBDFVarStiff(t *testing.T) {
|
||||
const k = 1e5
|
||||
var stats BDFVarStats
|
||||
end, err := IntegrateBDFVar(stiffCosine(k), 0, 1, mustFloats(t, []float64{0}),
|
||||
BDFVarOptions{MaxSteps: 2000, Stats: &stats})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBDFVar: %v", err)
|
||||
}
|
||||
want := (k*k*math.Cos(1) + k*math.Sin(1)) / (k*k + 1)
|
||||
if math.Abs(end.FloatAt(0)-want) > 1e-6 {
|
||||
t.Fatalf("y(1) = %.14g, want %.14g", end.FloatAt(0), want)
|
||||
}
|
||||
t.Logf("stiff run: %d steps, %d rejected, max order %d", stats.Steps, stats.Rejected, stats.MaxOrder)
|
||||
if stats.MaxOrder != 5 {
|
||||
t.Fatalf("max order reached = %d, want 5 on the smooth tail", stats.MaxOrder)
|
||||
}
|
||||
if stats.Steps > 1000 {
|
||||
t.Fatalf("the run took %d steps, want well inside the 2000-step budget BDF2 needed", stats.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBDFVarOrderAdapts instruments the order counter: the
|
||||
// first accepted steps run at order one (nothing else has history), so
|
||||
// a run that ends with order five must have climbed the ladder, and on
|
||||
// the same stiff problem it must spend far fewer steps than an
|
||||
// order-one-locked run, which is what step and order adaptation buy.
|
||||
func TestIntegrateBDFVarOrderAdapts(t *testing.T) {
|
||||
const k = 1e5
|
||||
var adaptive, locked BDFVarStats
|
||||
if _, err := IntegrateBDFVar(stiffCosine(k), 0, 1, mustFloats(t, []float64{0}),
|
||||
BDFVarOptions{MaxSteps: 50000, Stats: &adaptive}); err != nil {
|
||||
t.Fatalf("IntegrateBDFVar adaptive: %v", err)
|
||||
}
|
||||
if _, err := integrateBDFVar("TestIntegrateBDFVarOrderAdapts", stiffCosine(k), 0, 1,
|
||||
mustFloats(t, []float64{0}), BDFVarOptions{MaxSteps: 50000, Stats: &locked}, 1, true); err != nil {
|
||||
t.Fatalf("IntegrateBDFVar order-one locked: %v", err)
|
||||
}
|
||||
t.Logf("adaptive run: %d steps, locked run: %d steps", adaptive.Steps, locked.Steps)
|
||||
if adaptive.Steps < 8 || locked.Steps < 8 {
|
||||
t.Fatalf("implausible step counts: adaptive %d, locked %d", adaptive.Steps, locked.Steps)
|
||||
}
|
||||
if adaptive.MaxOrder != 5 {
|
||||
t.Fatalf("adaptive run reached order %d, want 5", adaptive.MaxOrder)
|
||||
}
|
||||
if locked.MaxOrder != 1 {
|
||||
t.Fatalf("locked run reached order %d, want 1 throughout", locked.MaxOrder)
|
||||
}
|
||||
if adaptive.Steps*3 > locked.Steps {
|
||||
t.Fatalf("the adaptive run took %d steps against the locked run's %d: order adaptation did not engage",
|
||||
adaptive.Steps, locked.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBDFVarAccuracy checks the adaptive driver on a smooth
|
||||
// problem against the analytic decay, over a full oscillator period
|
||||
// with a two-dimensional state, and backwards in time.
|
||||
func TestIntegrateBDFVarAccuracy(t *testing.T) {
|
||||
end, err := IntegrateBDFVar(decay, 0, 1, mustFloats(t, []float64{1}),
|
||||
BDFVarOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBDFVar: %v", err)
|
||||
}
|
||||
if math.Abs(end.FloatAt(0)-math.Exp(-1)) > 1e-5 {
|
||||
t.Fatalf("y(1) = %.14g, want %.14g ± 1e-5", end.FloatAt(0), math.Exp(-1))
|
||||
}
|
||||
oscillator := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
full, err := IntegrateBDFVar(oscillator, 0, 2*math.Pi, mustFloats(t, []float64{1, 0}),
|
||||
BDFVarOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBDFVar oscillator: %v", err)
|
||||
}
|
||||
if math.Abs(full.FloatAt(0)-1) > 1e-4 || math.Abs(full.FloatAt(1)) > 1e-4 {
|
||||
t.Fatalf("full period = (%.10g, %.10g), want (1, 0)",
|
||||
full.FloatAt(0), full.FloatAt(1))
|
||||
}
|
||||
back, err := IntegrateBDFVar(decay, 1, 0, mustFloats(t, []float64{math.Exp(-1)}),
|
||||
BDFVarOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBDFVar backward: %v", err)
|
||||
}
|
||||
if math.Abs(back.FloatAt(0)-1) > 1e-5 {
|
||||
t.Fatalf("backward y(0) = %.14g, want 1 ± 1e-5", back.FloatAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBDFVarCoefficientsMatchBDF2 pins the coefficient recurrence: at
|
||||
// order two the divided-difference form must reproduce the shipped
|
||||
// bdf2Coefficients, on equal steps and on skewed ones, in α, β and the
|
||||
// predictor seed alike.
|
||||
func TestBDFVarCoefficientsMatchBDF2(t *testing.T) {
|
||||
patterns := []struct{ tNext, t, tNm1, tNm2 float64 }{
|
||||
{3, 2, 1, 0},
|
||||
{1.3, 0.75, 0.4, -0.1},
|
||||
{5, 1, 0.5, -2},
|
||||
}
|
||||
vals := []float64{2.5, -3, 7} // y at tNm2, tNm1, t
|
||||
for _, p := range patterns {
|
||||
hist := &bdfVarHistory{}
|
||||
for i, tt := range []float64{p.tNm2, p.tNm1, p.t} {
|
||||
hist.push(tt, []float64{vals[i]})
|
||||
}
|
||||
beta := make([]float64, 1)
|
||||
seed := make([]float64, 1)
|
||||
alpha := bdfVarCoefficients(2, p.tNext, hist, beta, seed,
|
||||
make([]float64, bdfVarKeep), make([]float64, bdfVarKeep), make([]float64, bdfVarKeep))
|
||||
beta2 := make([]float64, 1)
|
||||
seed2 := make([]float64, 1)
|
||||
alpha2 := bdf2Coefficients(p.t, p.tNext, p.tNm1, p.tNm2, []float64{vals[2]},
|
||||
[]float64{vals[1]}, []float64{vals[0]}, beta2, seed2)
|
||||
tol := func(v float64) float64 { return 1e-12 * math.Max(1, math.Abs(v)) }
|
||||
if math.Abs(alpha-alpha2) > tol(alpha2) {
|
||||
t.Fatalf("pattern %v: alpha = %.16g, bdf2 gives %.16g", p, alpha, alpha2)
|
||||
}
|
||||
if math.Abs(beta[0]-beta2[0]) > tol(beta2[0]) {
|
||||
t.Fatalf("pattern %v: beta = %.16g, bdf2 gives %.16g", p, beta[0], beta2[0])
|
||||
}
|
||||
if math.Abs(seed[0]-seed2[0]) > tol(seed2[0]) {
|
||||
t.Fatalf("pattern %v: seed = %.16g, bdf2 gives %.16g", p, seed[0], seed2[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBDFVarMilneConstantMatchesBDF2 pins the variable-step Milne
|
||||
// constant against the shipped bdf2Milne at order two.
|
||||
func TestBDFVarMilneConstantMatchesBDF2(t *testing.T) {
|
||||
patterns := []struct{ tNext, t, tNm1, tNm2 float64 }{
|
||||
{3, 2, 1, 0},
|
||||
{1.3, 0.75, 0.4, -0.1},
|
||||
{5, 1, 0.5, -2},
|
||||
}
|
||||
for _, p := range patterns {
|
||||
hist := &bdfVarHistory{}
|
||||
for _, tt := range []float64{p.tNm2, p.tNm1, p.t} {
|
||||
hist.push(tt, []float64{0})
|
||||
}
|
||||
alpha := bdfVarCoefficients(2, p.tNext, hist, make([]float64, 1), make([]float64, 1),
|
||||
make([]float64, bdfVarKeep), make([]float64, bdfVarKeep), make([]float64, bdfVarKeep))
|
||||
_, tOldest := hist.back(2)
|
||||
got := 1 / (1 + alpha*(p.tNext-tOldest))
|
||||
want := bdf2Milne(p.t, p.tNext, p.tNm1, p.tNm2)
|
||||
if math.Abs(got-want) > 1e-14*math.Max(1, math.Abs(want)) {
|
||||
t.Fatalf("pattern %v: milne constant %.16g, bdf2Milne gives %.16g", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBDFVarFixedOrderLinear pins the exactness of the fixed
|
||||
// orders on y' = k·t^(k−1), whose solution y = t^k only order k
|
||||
// reproduces exactly: the k-step formula carries the k-th derivative
|
||||
// the problem is built from, and any lower order drops it, so each
|
||||
// locked run must land on 1 at the end AND report that it ran at the
|
||||
// locked order, which together rule out a hook that silently
|
||||
// integrates at order 1.
|
||||
func TestIntegrateBDFVarFixedOrderLinear(t *testing.T) {
|
||||
for order := 1; order <= 5; order++ {
|
||||
k := float64(order)
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
out, err := core.Zeros(core.Float, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.SetFloatAt(0, k*math.Pow(t, k-1))
|
||||
return out, nil
|
||||
}
|
||||
stats := &BDFVarStats{}
|
||||
end, err := integrateBDFVar("TestIntegrateBDFVarFixedOrderLinear", f, 0, 1,
|
||||
mustFloats(t, []float64{0}), BDFVarOptions{MaxSteps: 10000, Stats: stats}, order, true)
|
||||
if err != nil {
|
||||
t.Fatalf("locked order %d: %v", order, err)
|
||||
}
|
||||
if math.Abs(end[0]-1) > 5e-5 {
|
||||
t.Fatalf("locked order %d: y(1) = %.16g, want 1", order, end[0])
|
||||
}
|
||||
if stats.MaxOrder != order {
|
||||
t.Fatalf("locked order %d ran at max order %d", order, stats.MaxOrder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBDFVarExactPolynomialPerOrder drives the coefficient recurrence
|
||||
// directly: a single order-k step from exact history on the degree-k
|
||||
// polynomial p(t) = t^k must return p at the new time to rounding, on
|
||||
// skewed steps, because the variable-step formula is exact for degree
|
||||
// k when the past is exact.
|
||||
func TestBDFVarExactPolynomialPerOrder(t *testing.T) {
|
||||
const tNext = 1.3
|
||||
// Back-value times on skewed step gaps, newest first.
|
||||
patterns := [][6]float64{
|
||||
{1, 0.7, 0.35, 0.1, -0.2, -1},
|
||||
{1, 0.9, 0.75, 0.5, 0.2, -0.1},
|
||||
}
|
||||
for order := 1; order <= 5; order++ {
|
||||
for _, g := range patterns {
|
||||
times := g[:order+1]
|
||||
hist := &bdfVarHistory{}
|
||||
for _, tt := range times {
|
||||
hist.push(tt, []float64{math.Pow(tt, float64(order))})
|
||||
}
|
||||
beta := make([]float64, 1)
|
||||
seed := make([]float64, 1)
|
||||
w := &odeWork{}
|
||||
alpha := bdfVarCoefficients(order, tNext, hist, beta, seed,
|
||||
make([]float64, bdfVarKeep), make([]float64, bdfVarKeep), make([]float64, bdfVarKeep))
|
||||
z := make([]float64, 1)
|
||||
err := odeNewton("TestBDFVarExactPolynomialPerOrder",
|
||||
func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{float64(order) * math.Pow(t, float64(order-1))}, 1)
|
||||
}, w, tNext, alpha, 1, beta, seed, z, 1e-13, 1e-13)
|
||||
if err != nil {
|
||||
t.Fatalf("order %d gaps %v: odeNewton: %v", order, times, err)
|
||||
}
|
||||
want := math.Pow(tNext, float64(order))
|
||||
if math.Abs(z[0]-want) > 1e-11*math.Max(1, math.Abs(want)) {
|
||||
t.Fatalf("order %d gaps %v: z = %.16g, want %.16g to rounding", order, times, z[0], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBDFVarErrors pins the error contract: a degenerate span
|
||||
// returns the initial state unchanged, a wrong-shaped f, a rank-2
|
||||
// state, an empty state and an exhausted step budget are errors, and a
|
||||
// nonsensical order cap is refused.
|
||||
func TestIntegrateBDFVarErrors(t *testing.T) {
|
||||
y0 := mustFloats(t, []float64{1})
|
||||
same, err := integrateBDFVar("TestIntegrateBDFVarErrors", decay, 1, 1, y0, BDFVarOptions{}, 5, false)
|
||||
if err != nil {
|
||||
t.Fatalf("zero span: %v", err)
|
||||
}
|
||||
if math.Abs(same[0]-1) > 0 {
|
||||
t.Fatalf("zero span moved the state to %v", same[0])
|
||||
}
|
||||
wrongShape := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{1, 1}, 2)
|
||||
}
|
||||
if _, err := IntegrateBDFVar(wrongShape, 0, 1, y0, BDFVarOptions{}); err == nil {
|
||||
t.Fatal("expected an error when f returns the wrong shape")
|
||||
}
|
||||
matrixState := mustFloats(t, []float64{1, 1}, 1, 2)
|
||||
if _, err := IntegrateBDFVar(decay, 0, 1, matrixState, BDFVarOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a rank-2 state")
|
||||
}
|
||||
if _, err := IntegrateBDFVar(decay, 0, 1, mustFloats(t, nil), BDFVarOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an empty state")
|
||||
}
|
||||
if _, err := IntegrateBDFVar(decay, 0, 1, y0, BDFVarOptions{MaxSteps: 2}); err == nil {
|
||||
t.Fatal("expected an error for an exhausted step budget")
|
||||
}
|
||||
if _, err := integrateBDFVar("TestIntegrateBDFVarErrors", decay, 0, 1, y0, BDFVarOptions{}, 6, false); err == nil {
|
||||
t.Fatal("expected an error for an order cap above five")
|
||||
}
|
||||
if _, err := integrateBDFVar("TestIntegrateBDFVarErrors", decay, 0, 1, y0, BDFVarOptions{}, 0, false); err == nil {
|
||||
t.Fatal("expected an error for an order cap below one")
|
||||
}
|
||||
boom := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
if t > 0.5 {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
return core.MulF(y, -1), nil
|
||||
}
|
||||
if _, err := IntegrateBDFVar(boom, 0, 1, y0, BDFVarOptions{}); err == nil {
|
||||
t.Fatal("expected the operator error to propagate")
|
||||
}
|
||||
// An f that survives the two probe evaluations and fails on the
|
||||
// starter's own evaluation is refused at once.
|
||||
calls := 0
|
||||
counted := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
calls++
|
||||
if calls > 2 {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
return core.FromFloats([]float64{0}, 1)
|
||||
}
|
||||
if _, err := IntegrateBDFVar(counted, 0, 1, mustFloats(t, []float64{1}), BDFVarOptions{}); err == nil {
|
||||
t.Fatal("expected the starter's f failure to surface")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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"
|
||||
"sourcedock.dev/petrbalvin/tensor/optim"
|
||||
)
|
||||
|
||||
import "math"
|
||||
|
||||
// Two-point boundary value problems by shooting. The differential
|
||||
// equation is integrated as an initial value problem whose free
|
||||
// starting components are chosen so the trajectory lands on the
|
||||
// prescribed end values: the mismatch at t1 is a function of the free
|
||||
// starting components alone, and FindRootSystem drives that mismatch
|
||||
// to zero. Linear problems give a mismatch linear in the unknowns,
|
||||
// which the damped Newton settles in one step; nonlinear ones cost a
|
||||
// handful of trajectory integrations more.
|
||||
|
||||
// BoundaryConditions fixes the state of a boundary value problem at
|
||||
// the two ends of the interval. Start lists the components prescribed
|
||||
// at t0, whose values are read from the initial state; End lists the
|
||||
// components prescribed at t1 with their values in EndValues, parallel
|
||||
// to End. A component may carry a condition at both ends, as a
|
||||
// second-order equation written as a first-order system does for its
|
||||
// position; the remaining components are the shooting unknowns, seeded
|
||||
// from the initial state.
|
||||
type BoundaryConditions struct {
|
||||
Start []int
|
||||
End []int
|
||||
EndValues []float64
|
||||
}
|
||||
|
||||
// IntegrateBoundary solves the two-point boundary value problem y' =
|
||||
// f(t, y) on [t0, t1] under the given conditions by shooting, and
|
||||
// returns the trajectory sampled at nSamples evenly spaced times, the
|
||||
// same contract as IntegrateODEPath. states[0] carries the initial
|
||||
// state with the shooting unknowns replaced by the values that satisfy
|
||||
// the end conditions. Backward integration (t1 < t0) works.
|
||||
//
|
||||
// The count of conditions decides solvability: len(Start) + len(End)
|
||||
// must equal the state length, with the components in Start the ones
|
||||
// whose initial values are known. An index out of range, a repeated
|
||||
// index within Start or within End, an EndValues of the wrong length,
|
||||
// a truncated sample count or an exhausted step budget in any trial
|
||||
// trajectory is an error, never a silent answer. The shooting root
|
||||
// find is local: a start whose basin holds no matching trajectory, or
|
||||
// a trial that blows up on the way to t1, reports the failure.
|
||||
func IntegrateBoundary(f func(t float64, y *core.Array) (*core.Array, error),
|
||||
t0, t1 float64, y0 *core.Array, bc BoundaryConditions, nSamples int,
|
||||
opts ODEOptions) ([]float64, []*core.Array, error) {
|
||||
const name = "IntegrateBoundary"
|
||||
y, err := odeCheck("IntegrateBoundary", y0, &opts)
|
||||
if err != nil {
|
||||
return nil, nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
n := len(y)
|
||||
if nSamples < 2 {
|
||||
return nil, nil, base.Errf("%s: nSamples must be ≥ 2, got %d", name, nSamples)
|
||||
}
|
||||
if len(bc.End) == 0 {
|
||||
return nil, nil, base.Errf("%s: End must prescribe at least one component at t1", name)
|
||||
}
|
||||
if len(bc.Start)+len(bc.End) != n {
|
||||
return nil, nil, base.Errf("%s: %d conditions at t0 and %d at t1 for a state of length %d, want %d in total",
|
||||
name, len(bc.Start), len(bc.End), n, n)
|
||||
}
|
||||
if len(bc.EndValues) != len(bc.End) {
|
||||
return nil, nil, base.Errf("%s: EndValues has length %d, want %d to match End",
|
||||
name, len(bc.EndValues), len(bc.End))
|
||||
}
|
||||
inStart := make(map[int]bool, len(bc.Start))
|
||||
for _, j := range bc.Start {
|
||||
if j < 0 || j >= n {
|
||||
return nil, nil, base.Errf("%s: Start index %d out of range for a state of length %d", name, j, n)
|
||||
}
|
||||
if inStart[j] {
|
||||
return nil, nil, base.Errf("%s: Start prescribes component %d twice", name, j)
|
||||
}
|
||||
inStart[j] = true
|
||||
}
|
||||
inEnd := make(map[int]bool, len(bc.End))
|
||||
for _, j := range bc.End {
|
||||
if j < 0 || j >= n {
|
||||
return nil, nil, base.Errf("%s: End index %d out of range for a state of length %d", name, j, n)
|
||||
}
|
||||
if inEnd[j] {
|
||||
return nil, nil, base.Errf("%s: End prescribes component %d twice", name, j)
|
||||
}
|
||||
inEnd[j] = true
|
||||
}
|
||||
free := make([]int, 0, n-len(bc.Start))
|
||||
for j := range n {
|
||||
if !inStart[j] {
|
||||
free = append(free, j)
|
||||
}
|
||||
}
|
||||
// The end-point mismatch as a function of the free starting
|
||||
// components. Each evaluation is one full trajectory, so the
|
||||
// integration tolerance bounds the noise the root find must see
|
||||
// and its target sits just above that noise.
|
||||
residual := func(x *core.Array) (*core.Array, error) {
|
||||
u0 := cloneDenseSlice(y)
|
||||
for k, j := range free {
|
||||
u0[j] = x.FloatAt(k)
|
||||
}
|
||||
u1, err := IntegrateODE(f, t0, t1, wrapVector(u0), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := make([]float64, len(bc.End))
|
||||
for k, j := range bc.End {
|
||||
r[k] = u1.FloatAt(j) - bc.EndValues[k]
|
||||
}
|
||||
return wrapVector(r), nil
|
||||
}
|
||||
guess := make([]float64, len(free))
|
||||
for k, j := range free {
|
||||
guess[k] = y[j]
|
||||
}
|
||||
tol := math.Max(10*opts.RelTol, 1e-12)
|
||||
solution, _, err := optim.FindRootSystem(residual, wrapVector(guess),
|
||||
optim.RootSystemOptions{Tolerance: tol})
|
||||
if err != nil {
|
||||
return nil, nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
u0 := cloneDenseSlice(y)
|
||||
for k, j := range free {
|
||||
u0[j] = solution.FloatAt(k)
|
||||
}
|
||||
times, states, err := IntegrateODEPath(f, t0, t1, wrapVector(u0), nSamples, opts)
|
||||
if err != nil {
|
||||
return nil, nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
return times, states, nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import "sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// harmonicSystem returns f for y” = −y written as the first-order
|
||||
// system u' = (v, −u).
|
||||
func harmonicSystem(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
|
||||
// TestIntegrateBoundaryLinear shoots the classic y” = −y with
|
||||
// y(0) = 0 and y(π/2) = 1: the free initial slope must come out as 1
|
||||
// and the sampled trajectory must trace y = sin t.
|
||||
func TestIntegrateBoundaryLinear(t *testing.T) {
|
||||
bc := BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{1}}
|
||||
times, states, err := IntegrateBoundary(harmonicSystem, 0, math.Pi/2,
|
||||
mustFloats(t, []float64{0, 0.5}), bc, 5, ODEOptions{RelTol: 1e-9, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBoundary: %v", err)
|
||||
}
|
||||
for i := range times {
|
||||
want := math.Sin(times[i])
|
||||
if math.Abs(states[i].FloatAt(0)-want) > 1e-6 {
|
||||
t.Fatalf("y(%.6g) = %.12g, want %.12g", times[i], states[i].FloatAt(0), want)
|
||||
}
|
||||
}
|
||||
if math.Abs(states[0].FloatAt(1)-1) > 1e-5 {
|
||||
t.Fatalf("shooting slope = %.12g, want 1", states[0].FloatAt(1))
|
||||
}
|
||||
if math.Abs(times[4]-math.Pi/2) > 1e-12 {
|
||||
t.Fatalf("last sample time %.14g, want π/2", times[4])
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBoundaryNonlinear shoots y” = 2y³ with y(0) = 1 and
|
||||
// y(1) = 1/2, whose exact solution is y = 1/(t+1) with the initial
|
||||
// slope −1, and checks the sampled path against it.
|
||||
func TestIntegrateBoundaryNonlinear(t *testing.T) {
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
u := y.FloatAt(0)
|
||||
return core.FromFloats([]float64{y.FloatAt(1), 2 * u * u * u}, 2)
|
||||
}
|
||||
bc := BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{0.5}}
|
||||
times, states, err := IntegrateBoundary(f, 0, 1,
|
||||
mustFloats(t, []float64{1, -0.5}), bc, 5, ODEOptions{RelTol: 1e-10, AbsTol: 1e-13})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBoundary: %v", err)
|
||||
}
|
||||
for i := range times {
|
||||
want := 1 / (times[i] + 1)
|
||||
if math.Abs(states[i].FloatAt(0)-want) > 1e-6 {
|
||||
t.Fatalf("y(%.6g) = %.12g, want %.12g", times[i], states[i].FloatAt(0), want)
|
||||
}
|
||||
}
|
||||
if math.Abs(states[0].FloatAt(1)+1) > 1e-5 {
|
||||
t.Fatalf("shooting slope = %.12g, want −1", states[0].FloatAt(1))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBoundaryVelocityEnd prescribes the velocity at t1
|
||||
// instead of the position: y” = −y with y(0) = 1 and y'(1) = 0 has
|
||||
// y = cos t + tan(1)·sin t, and the shooting unknown is the initial
|
||||
// value of the very component the end condition watches.
|
||||
func TestIntegrateBoundaryVelocityEnd(t *testing.T) {
|
||||
bc := BoundaryConditions{Start: []int{0}, End: []int{1}, EndValues: []float64{0}}
|
||||
times, states, err := IntegrateBoundary(harmonicSystem, 0, 1,
|
||||
mustFloats(t, []float64{1, 1}), bc, 4, ODEOptions{RelTol: 1e-9, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBoundary: %v", err)
|
||||
}
|
||||
slope := math.Tan(1)
|
||||
for i := range times {
|
||||
want := math.Cos(times[i]) + slope*math.Sin(times[i])
|
||||
if math.Abs(states[i].FloatAt(0)-want) > 1e-6 {
|
||||
t.Fatalf("y(%.6g) = %.12g, want %.12g", times[i], states[i].FloatAt(0), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBoundaryBlowUp pins the honest failure: y” = y³ with
|
||||
// y(0) = 1 and y(1) = 100 demands a trajectory that reaches 100 only
|
||||
// by skirting its own finite-time blow-up, so some trial integration
|
||||
// fails and the shooting reports the error instead of an answer.
|
||||
func TestIntegrateBoundaryBlowUp(t *testing.T) {
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
u := y.FloatAt(0)
|
||||
return core.FromFloats([]float64{y.FloatAt(1), u * u * u}, 2)
|
||||
}
|
||||
bc := BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{100}}
|
||||
if _, _, err := IntegrateBoundary(f, 0, 1, mustFloats(t, []float64{1, 0}), bc, 3,
|
||||
ODEOptions{MaxSteps: 2000}); err == nil {
|
||||
t.Fatal("expected the shooting to fail on the blow-up problem")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateBoundaryErrors pins the validation contract.
|
||||
func TestIntegrateBoundaryErrors(t *testing.T) {
|
||||
y0 := mustFloats(t, []float64{1, 0})
|
||||
bc := BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{1}}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, y0, bc, 1, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a single sample")
|
||||
}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, y0,
|
||||
BoundaryConditions{Start: []int{0}}, 3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error when nothing is prescribed at t1")
|
||||
}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, y0,
|
||||
BoundaryConditions{Start: []int{0, 1}, End: []int{0}, EndValues: []float64{1}},
|
||||
3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for too many conditions")
|
||||
}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, y0,
|
||||
BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{}},
|
||||
3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error when EndValues does not match End")
|
||||
}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, y0,
|
||||
BoundaryConditions{Start: []int{5}, End: []int{1}, EndValues: []float64{0}},
|
||||
3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a Start index out of range")
|
||||
}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, y0,
|
||||
BoundaryConditions{Start: []int{0}, End: []int{2}, EndValues: []float64{1}},
|
||||
3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an End index out of range")
|
||||
}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, mustFloats(t, []float64{1, 0, 2}),
|
||||
BoundaryConditions{Start: []int{0, 0}, End: []int{2}, EndValues: []float64{1}},
|
||||
3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a repeated Start index")
|
||||
}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, y0,
|
||||
BoundaryConditions{Start: []int{0}, End: []int{1, 1}, EndValues: []float64{0, 1}},
|
||||
3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a repeated End index")
|
||||
}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, mustFloats(t, []float64{1}, 1),
|
||||
bc, 3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a rank-2 state")
|
||||
}
|
||||
if _, _, err := IntegrateBoundary(harmonicSystem, 0, 1, mustFloats(t, nil), bc, 3, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an empty state")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Boundary value problems by collocation, the mesh-based sibling of
|
||||
// the shooting method in IntegrateBoundary. Instead of marching a
|
||||
// single trajectory and tuning its free start, the solver lays a mesh
|
||||
// over [t0, t1], represents the solution by a cubic on every mesh
|
||||
// interval, drives the whole discrete system to zero by a damped
|
||||
// Newton iteration over a numerically assembled Jacobian, and then
|
||||
// halves the intervals whose residual estimate is past tolerance and
|
||||
// solves again, until every interval sits inside the tolerance or the
|
||||
// node budget runs out.
|
||||
//
|
||||
// The scheme is the classic three-point Lobatto IIIA collocation, not
|
||||
// the Kierzenka-Shampine variant: the collocation polynomial on each
|
||||
// interval satisfies the ODE at both endpoints and the midpoint,
|
||||
// which makes the nodal values fourth-order accurate in the interval
|
||||
// width. The unknowns follow the shape scipy's solve_bvp solves for:
|
||||
// the state y at every mesh node and the slope s = f(t, y) at every
|
||||
// mesh node. Between the nodes the solution is the piecewise cubic
|
||||
// Hermite through (t, y, s), which is exactly the collocation
|
||||
// polynomial, so that triple is the solver's S-slope representation
|
||||
// of the continuous answer.
|
||||
|
||||
// CollocationOptions tunes SolveBoundaryCollocation. RelTol ≤ 0 means
|
||||
// 1e-6, AbsTol ≤ 0 means 1e-9, InitialNodes ≤ 0 means 10, MaxNodes ≤ 0
|
||||
// means 256 and MaxIterations ≤ 0 means 40.
|
||||
type CollocationOptions struct {
|
||||
// RelTol and AbsTol scale the mesh-refinement estimate: an
|
||||
// interval whose root-mean-square of residual over
|
||||
// AbsTol + RelTol·|slope| stays above 1 is halved. The same pair
|
||||
// floors the Newton convergence, an order of magnitude below it.
|
||||
RelTol float64
|
||||
AbsTol float64
|
||||
// InitialNodes is the interval count of the uniform starting
|
||||
// mesh.
|
||||
InitialNodes int
|
||||
// MaxNodes bounds the refined mesh. The Newton matrix is factored
|
||||
// by the library's dense LU, so the cap also bounds the per-round
|
||||
// cost; a two-component system lives comfortably at 256, well
|
||||
// inside memory.
|
||||
MaxNodes int
|
||||
// MaxIterations bounds the damped Newton rounds on each mesh.
|
||||
MaxIterations int
|
||||
}
|
||||
|
||||
// CollocationSolution carries the solved problem: Mesh holds the node
|
||||
// times, Values[k] the state at Mesh[k] and Slopes[k] the derivative
|
||||
// y' = f(t, y) there. The piecewise cubic Hermite through
|
||||
// (Mesh, Values, Slopes) is the collocation solution itself, so
|
||||
// interpolating from that data between the nodes is exact to the
|
||||
// solver's tolerance.
|
||||
type CollocationSolution struct {
|
||||
Mesh []float64
|
||||
Values []*core.Array
|
||||
Slopes []*core.Array
|
||||
}
|
||||
|
||||
// SolveBoundaryCollocation solves the two-point boundary value
|
||||
// problem y' = f(t, y) on [t0, t1] by three-point Lobatto IIIA
|
||||
// collocation on an adaptively refined mesh, returning the mesh, the
|
||||
// nodal states and the nodal slopes. The boundary conditions follow
|
||||
// the BoundaryConditions contract of IntegrateBoundary: Start lists
|
||||
// the components prescribed at t0 with values read from y0, End the
|
||||
// components prescribed at t1 with EndValues, and exactly n
|
||||
// conditions must be given in total, because the collocation system
|
||||
// is square. The initial guess interpolates linearly between the
|
||||
// prescribed endpoint states and reads its slopes from f.
|
||||
//
|
||||
// Refusal is part of the contract: inconsistent boundary conditions
|
||||
// (fewer or more than n conditions, out-of-range or repeated
|
||||
// indices, EndValues of the wrong length), a non-positive interval,
|
||||
// a starting mesh past MaxNodes, refinement that would grow past
|
||||
// MaxNodes, a singular Newton matrix or an iteration that cannot
|
||||
// converge are errors, never silent answers.
|
||||
func SolveBoundaryCollocation(f func(t float64, y *core.Array) (*core.Array, error),
|
||||
t0, t1 float64, y0 *core.Array, bc BoundaryConditions,
|
||||
opts CollocationOptions) (*CollocationSolution, error) {
|
||||
const name = "SolveBoundaryCollocation"
|
||||
if opts.RelTol <= 0 {
|
||||
opts.RelTol = 1e-6
|
||||
}
|
||||
if opts.AbsTol <= 0 {
|
||||
opts.AbsTol = 1e-9
|
||||
}
|
||||
if opts.InitialNodes <= 0 {
|
||||
opts.InitialNodes = 10
|
||||
}
|
||||
if opts.MaxNodes <= 0 {
|
||||
opts.MaxNodes = 256
|
||||
}
|
||||
if opts.MaxIterations <= 0 {
|
||||
opts.MaxIterations = 40
|
||||
}
|
||||
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 !(t1 > t0) {
|
||||
return nil, base.Errf("%s: the interval must have positive length, got [%g, %g]", name, t0, t1)
|
||||
}
|
||||
n := y0.Len()
|
||||
seed := make([]float64, n)
|
||||
for i := range n {
|
||||
seed[i] = y0.FloatAt(i)
|
||||
if math.IsNaN(seed[i]) || math.IsInf(seed[i], 0) {
|
||||
return nil, base.Errf("%s: the state holds the non-finite value %g at %d", name, seed[i], i)
|
||||
}
|
||||
}
|
||||
if len(bc.End) == 0 {
|
||||
return nil, base.Errf("%s: End must prescribe at least one component at t1", name)
|
||||
}
|
||||
if len(bc.Start)+len(bc.End) != n {
|
||||
return nil, base.Errf("%s: %d conditions at t0 and %d at t1 for a state of length %d, want %d in total",
|
||||
name, len(bc.Start), len(bc.End), n, n)
|
||||
}
|
||||
if len(bc.EndValues) != len(bc.End) {
|
||||
return nil, base.Errf("%s: EndValues has length %d, want %d to match End",
|
||||
name, len(bc.EndValues), len(bc.End))
|
||||
}
|
||||
inStart := make(map[int]bool, len(bc.Start))
|
||||
for _, j := range bc.Start {
|
||||
if j < 0 || j >= n {
|
||||
return nil, base.Errf("%s: Start index %d out of range for a state of length %d", name, j, n)
|
||||
}
|
||||
if inStart[j] {
|
||||
return nil, base.Errf("%s: Start prescribes component %d twice", name, j)
|
||||
}
|
||||
inStart[j] = true
|
||||
}
|
||||
inEnd := make(map[int]bool, len(bc.End))
|
||||
for _, j := range bc.End {
|
||||
if j < 0 || j >= n {
|
||||
return nil, base.Errf("%s: End index %d out of range for a state of length %d", name, j, n)
|
||||
}
|
||||
if inEnd[j] {
|
||||
return nil, base.Errf("%s: End prescribes component %d twice", name, j)
|
||||
}
|
||||
inEnd[j] = true
|
||||
}
|
||||
endState := cloneDenseSlice(seed)
|
||||
for q, j := range bc.End {
|
||||
endState[j] = bc.EndValues[q]
|
||||
}
|
||||
if opts.InitialNodes < 1 {
|
||||
return nil, base.Errf("%s: InitialNodes must be ≥ 1, got %d", name, opts.InitialNodes)
|
||||
}
|
||||
if opts.InitialNodes+1 > opts.MaxNodes {
|
||||
return nil, base.Errf("%s: the starting mesh of %d intervals already exceeds MaxNodes=%d",
|
||||
name, opts.InitialNodes, opts.MaxNodes)
|
||||
}
|
||||
|
||||
// The starting mesh and guess: uniform in t, linear between the
|
||||
// prescribed endpoint states, slopes read from f.
|
||||
mesh := make([]float64, opts.InitialNodes+1)
|
||||
for k := range mesh {
|
||||
mesh[k] = t0 + (t1-t0)*float64(k)/float64(opts.InitialNodes)
|
||||
}
|
||||
stride := 2 * n
|
||||
z := make([]float64, stride*(opts.InitialNodes+1))
|
||||
for k := range opts.InitialNodes + 1 {
|
||||
theta := (mesh[k] - t0) / (t1 - t0)
|
||||
for i := range n {
|
||||
z[k*stride+i] = seed[i] + theta*(endState[i]-seed[i])
|
||||
}
|
||||
}
|
||||
for k := range opts.InitialNodes + 1 {
|
||||
sv, err := odeEval(name, f, mesh[k], z[k*stride:k*stride+n], n, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy(z[k*stride+n:(k+1)*stride], sv)
|
||||
}
|
||||
|
||||
// collocResidual writes the discrete system for the unknown vector
|
||||
// zz into dst: one slope-definition block per node, one
|
||||
// collocation block per interval (the midpoint value eliminated
|
||||
// through the cubic Hermite it belongs to), then the boundary
|
||||
// rows. The row count equals the unknown count exactly.
|
||||
collocResidual := func(dst, zz, msh []float64) error {
|
||||
nodes := len(msh)
|
||||
for k := range nodes {
|
||||
fv, err := odeEval(name, f, msh[k], zz[k*stride:k*stride+n], n, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range n {
|
||||
dst[k*n+i] = zz[k*stride+n+i] - fv[i]
|
||||
}
|
||||
}
|
||||
baseRow := nodes * n
|
||||
ym := make([]float64, n)
|
||||
for i := range nodes - 1 {
|
||||
h := msh[i+1] - msh[i]
|
||||
for j := range n {
|
||||
ym[j] = (zz[i*stride+j]+zz[(i+1)*stride+j])/2 + h*(zz[i*stride+n+j]-zz[(i+1)*stride+n+j])/8
|
||||
}
|
||||
fm, err := odeEval(name, f, msh[i]+h/2, ym, n, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for j := range n {
|
||||
dst[baseRow+i*n+j] = zz[(i+1)*stride+j] - zz[i*stride+j] -
|
||||
h*(zz[i*stride+n+j]+4*fm[j]+zz[(i+1)*stride+n+j])/6
|
||||
}
|
||||
}
|
||||
last := len(dst) - n
|
||||
for p, j := range bc.Start {
|
||||
dst[last+p] = zz[j] - seed[j]
|
||||
}
|
||||
for q, j := range bc.End {
|
||||
dst[last+len(bc.Start)+q] = zz[(nodes-1)*stride+j] - bc.EndValues[q]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collocJac assembles the Newton matrix by central differences:
|
||||
// the slope rows differentiate y − f(t, y) over their node's y
|
||||
// block (their slope columns are the exact −I), the collocation
|
||||
// rows differentiate the interval map over the four blocks
|
||||
// (yᵢ, sᵢ, yᵢ₊₁, sᵢ₊₁), and the boundary rows enter exactly.
|
||||
collocJac := func(zz, msh []float64) ([][]float64, error) {
|
||||
nodes := len(msh)
|
||||
size := stride * nodes
|
||||
jac := make([][]float64, size)
|
||||
for i := range jac {
|
||||
jac[i] = make([]float64, size)
|
||||
}
|
||||
for k := range nodes {
|
||||
// The slope rows are s − f(y): as a function of the node's
|
||||
// y block the residual is −f(y), and the slope columns
|
||||
// carry the +I.
|
||||
block := func(y []float64) ([]float64, error) {
|
||||
fv, err := odeEval(name, f, msh[k], y, n, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]float64, n)
|
||||
for i := range n {
|
||||
out[i] = -fv[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if err := collocNumJac(name, block, zz[k*stride:k*stride+n], k*n, k*stride, n, n, jac); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range n {
|
||||
// The slope rows are s − f(y), so the slope columns
|
||||
// carry +I.
|
||||
jac[k*n+i][k*stride+n+i] = 1
|
||||
}
|
||||
}
|
||||
baseRow := nodes * n
|
||||
w := make([]float64, 4*n)
|
||||
ym := make([]float64, n)
|
||||
for i := range nodes - 1 {
|
||||
h := msh[i+1] - msh[i]
|
||||
copy(w[0:n], zz[i*stride:i*stride+n])
|
||||
copy(w[n:2*n], zz[i*stride+n:(i+1)*stride])
|
||||
copy(w[2*n:3*n], zz[(i+1)*stride:(i+1)*stride+n])
|
||||
copy(w[3*n:4*n], zz[(i+1)*stride+n:(i+2)*stride])
|
||||
interval := func(x []float64) ([]float64, error) {
|
||||
for j := range n {
|
||||
ym[j] = (x[j]+x[2*n+j])/2 + h*(x[n+j]-x[3*n+j])/8
|
||||
}
|
||||
fm, err := odeEval(name, f, msh[i]+h/2, ym, n, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]float64, n)
|
||||
for j := range n {
|
||||
out[j] = x[2*n+j] - x[j] - h*(x[n+j]+4*fm[j]+x[3*n+j])/6
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if err := collocNumJac(name, interval, w, baseRow+i*n, i*stride, n, 4*n, jac); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
last := size - n
|
||||
for p, j := range bc.Start {
|
||||
jac[last+p][j] = 1
|
||||
}
|
||||
for q, j := range bc.End {
|
||||
jac[last+len(bc.Start)+q][(nodes-1)*stride+j] = 1
|
||||
}
|
||||
return jac, nil
|
||||
}
|
||||
|
||||
// newtonSolve drives the damped Newton on the fixed mesh: the
|
||||
// Jacobian is frozen from the seed and rebuilt twice when
|
||||
// convergence drags, as odeNewton does, and each round backtracks
|
||||
// along the step until the residual infinity norm actually falls.
|
||||
newtonSolve := func(zz, msh []float64) error {
|
||||
size := stride * len(msh)
|
||||
r := make([]float64, size)
|
||||
trialZ := make([]float64, size)
|
||||
trialR := make([]float64, size)
|
||||
col := make([]float64, size)
|
||||
if err := collocResidual(r, zz, msh); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range r {
|
||||
if math.IsNaN(r[i]) || math.IsInf(r[i], 0) {
|
||||
return base.Errf("%s: the residual returned the non-finite value %g at row %d", name, r[i], i)
|
||||
}
|
||||
}
|
||||
scale := normInfOfStep(zz)
|
||||
// The algebraic floor sits three orders below the mesh
|
||||
// tolerance: the refinement estimator reads the true ODE
|
||||
// residual of the interpolant, and that reading must not be
|
||||
// dominated by the residual the Newton iteration left.
|
||||
limit := math.Max(0.001*(opts.AbsTol+opts.RelTol*scale), 8*base.EpsF*(scale+1))
|
||||
worst := normInfOfStep(r)
|
||||
var work [][]float64
|
||||
var perm []int
|
||||
for iteration := 0; iteration < opts.MaxIterations; iteration++ {
|
||||
if worst <= limit {
|
||||
return nil
|
||||
}
|
||||
if iteration == 0 || iteration == 4 || iteration == 10 {
|
||||
jac, err := collocJac(zz, msh)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Factor a working copy: base.Factor consumes its
|
||||
// argument in place, and the pristine matrix is not
|
||||
// needed again before the next rebuild.
|
||||
work = make([][]float64, size)
|
||||
for i := range jac {
|
||||
work[i] = cloneDenseSlice(jac[i])
|
||||
}
|
||||
perm, _ = base.Factor(work)
|
||||
if err := base.CheckSingular(name, work); err != nil {
|
||||
return base.Errf("%s: %w, singular collocation Newton matrix", name, errNewtonStalled)
|
||||
}
|
||||
}
|
||||
for i := range col {
|
||||
col[i] = -r[i]
|
||||
}
|
||||
base.PermuteColumn(col, perm)
|
||||
base.SolveColumn(work, col)
|
||||
accepted := false
|
||||
factor := 1.0
|
||||
for range 40 {
|
||||
for i := range zz {
|
||||
trialZ[i] = zz[i] + factor*col[i]
|
||||
}
|
||||
if err := collocResidual(trialR, trialZ, msh); err == nil {
|
||||
finite := true
|
||||
cand := 0.0
|
||||
for i := range trialR {
|
||||
v := trialR[i]
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
finite = false
|
||||
break
|
||||
}
|
||||
cand = math.Max(cand, math.Abs(v))
|
||||
}
|
||||
if finite && cand <= (1-1e-4*factor)*worst {
|
||||
copy(zz, trialZ)
|
||||
// The residual travels with the accepted point,
|
||||
// so the next round solves against the state
|
||||
// this round left behind.
|
||||
copy(r, trialR)
|
||||
worst = cand
|
||||
accepted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
factor /= 2
|
||||
}
|
||||
if !accepted {
|
||||
return base.Errf("%s: %w: the residual cannot be reduced below %g by damping",
|
||||
name, errNewtonStalled, worst)
|
||||
}
|
||||
}
|
||||
return base.Errf("%s: %w after %d rounds, residual %g", name, errNewtonStalled, opts.MaxIterations, worst)
|
||||
}
|
||||
|
||||
// estimateRefinement returns the intervals whose root-mean-square
|
||||
// scaled residual is past 1, measured from the collocation
|
||||
// solution's own cubic Hermite: the ODE is evaluated at three
|
||||
// interior quadrature points of every interval (the nodes one half
|
||||
// plus or minus half the square root of three sevenths and the
|
||||
// midpoint, weights 49/180, 16/45, 49/180) and the mismatch with
|
||||
// the Hermite derivative is quadrature-weighted (the endpoints
|
||||
// contribute exactly nothing, the Hermite slope is the ODE slope
|
||||
// there by construction).
|
||||
estimateRefinement := func(zz, msh []float64) ([]int, error) {
|
||||
nodes := len(msh)
|
||||
theta := [3]float64{0.5 * (1 - math.Sqrt(3.0/7)), 0.5, 0.5 * (1 + math.Sqrt(3.0/7))}
|
||||
weight := [3]float64{49.0 / 180, 16.0 / 45, 49.0 / 180}
|
||||
var bad []int
|
||||
val := make([]float64, n)
|
||||
for i := range nodes - 1 {
|
||||
h := msh[i+1] - msh[i]
|
||||
sum := 0.0
|
||||
for pt := range 3 {
|
||||
th := theta[pt]
|
||||
th2 := th * th
|
||||
th3 := th2 * th
|
||||
h00 := 2*th3 - 3*th2 + 1
|
||||
h10 := th3 - 2*th2 + th
|
||||
h01 := -2*th3 + 3*th2
|
||||
h11 := th3 - th2
|
||||
hd00 := 6*th2 - 6*th
|
||||
hd10 := 3*th2 - 4*th + 1
|
||||
hd01 := -6*th2 + 6*th
|
||||
hd11 := 3*th2 - 2*th
|
||||
for j := range n {
|
||||
val[j] = h00*zz[i*stride+j] + h*h10*zz[i*stride+n+j] +
|
||||
h01*zz[(i+1)*stride+j] + h*h11*zz[(i+1)*stride+n+j]
|
||||
}
|
||||
fv, err := odeEval(name, f, msh[i]+th*h, val, n, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for j := range n {
|
||||
der := hd00*zz[i*stride+j]/h + hd10*zz[i*stride+n+j] +
|
||||
hd01*zz[(i+1)*stride+j]/h + hd11*zz[(i+1)*stride+n+j]
|
||||
slope := math.Max(math.Abs(zz[i*stride+n+j]), math.Abs(zz[(i+1)*stride+n+j]))
|
||||
sc := opts.AbsTol + opts.RelTol*math.Max(slope, math.Abs(fv[j]))
|
||||
ratio := (der - fv[j]) / sc
|
||||
sum += weight[pt] * ratio * ratio
|
||||
}
|
||||
}
|
||||
if math.Sqrt(sum) > 1 {
|
||||
bad = append(bad, i)
|
||||
}
|
||||
}
|
||||
return bad, nil
|
||||
}
|
||||
|
||||
// refineMesh inserts the midpoint of every interval in bad, with
|
||||
// the new node's state and slope taken from the collocation
|
||||
// solution's own cubic Hermite at the midpoint.
|
||||
refineMesh := func(zz, msh []float64, bad []int) ([]float64, []float64, error) {
|
||||
nodes := len(msh)
|
||||
if nodes+len(bad) > opts.MaxNodes {
|
||||
return nil, nil, base.Errf("%s: refining %d intervals would grow the mesh to %d nodes past MaxNodes=%d",
|
||||
name, len(bad), nodes+len(bad), opts.MaxNodes)
|
||||
}
|
||||
badSet := make(map[int]bool, len(bad))
|
||||
for _, i := range bad {
|
||||
badSet[i] = true
|
||||
}
|
||||
newMesh := make([]float64, 0, nodes+len(bad))
|
||||
newZ := make([]float64, 0, len(zz)+2*n*len(bad))
|
||||
push := func(t float64, y, s []float64) {
|
||||
newMesh = append(newMesh, t)
|
||||
newZ = append(newZ, y...)
|
||||
newZ = append(newZ, s...)
|
||||
}
|
||||
for i := range nodes - 1 {
|
||||
push(msh[i], zz[i*stride:i*stride+n], zz[i*stride+n:(i+1)*stride])
|
||||
if !badSet[i] {
|
||||
continue
|
||||
}
|
||||
h := msh[i+1] - msh[i]
|
||||
mid := (msh[i] + msh[i+1]) / 2
|
||||
ym := make([]float64, n)
|
||||
sm := make([]float64, n)
|
||||
for j := range n {
|
||||
ym[j] = (zz[i*stride+j]+zz[(i+1)*stride+j])/2 + h*(zz[i*stride+n+j]-zz[(i+1)*stride+n+j])/8
|
||||
sm[j] = 1.5*(zz[(i+1)*stride+j]-zz[i*stride+j])/h - (zz[i*stride+n+j]+zz[(i+1)*stride+n+j])/4
|
||||
}
|
||||
push(mid, ym, sm)
|
||||
}
|
||||
push(msh[nodes-1], zz[(nodes-1)*stride:(nodes-1)*stride+n], zz[(nodes-1)*stride+n:nodes*stride])
|
||||
return newMesh, newZ, nil
|
||||
}
|
||||
|
||||
for pass := 0; ; pass++ {
|
||||
if pass >= 100 {
|
||||
return nil, base.Errf("%s: refinement did not settle within 100 passes", name)
|
||||
}
|
||||
if err := newtonSolve(z, mesh); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bad, err := estimateRefinement(z, mesh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bad) == 0 {
|
||||
break
|
||||
}
|
||||
mesh, z, err = refineMesh(z, mesh, bad)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
solution := &CollocationSolution{
|
||||
Mesh: slices.Clone(mesh),
|
||||
Values: make([]*core.Array, len(mesh)),
|
||||
Slopes: make([]*core.Array, len(mesh)),
|
||||
}
|
||||
for k := range mesh {
|
||||
solution.Values[k] = arrayFromVector(z[k*stride : k*stride+n])
|
||||
solution.Slopes[k] = arrayFromVector(z[k*stride+n : (k+1)*stride])
|
||||
}
|
||||
return solution, nil
|
||||
}
|
||||
|
||||
// collocNumJac fills jac[row0+i][col0+c] with the central-difference
|
||||
// derivative of g's i-th output against x's c-th entry, one column
|
||||
// per entry of x. g receives its own perturbed copy of x and returns
|
||||
// a fresh output slice, so nothing aliases.
|
||||
func collocNumJac(name string, g func(x []float64) ([]float64, error), x []float64,
|
||||
row0, col0, rows, cols int, jac [][]float64) error {
|
||||
xp := make([]float64, cols)
|
||||
xm := make([]float64, cols)
|
||||
for c := range cols {
|
||||
eps := math.Sqrt(base.EpsF) * math.Max(1, math.Abs(x[c]))
|
||||
copy(xp, x)
|
||||
copy(xm, x)
|
||||
xp[c] += eps
|
||||
xm[c] -= eps
|
||||
rp, e1 := g(xp)
|
||||
if e1 != nil {
|
||||
return base.Errf("%s: %w", name, e1)
|
||||
}
|
||||
rm, e2 := g(xm)
|
||||
if e2 != nil {
|
||||
return base.Errf("%s: %w", name, e2)
|
||||
}
|
||||
for i := range rows {
|
||||
jac[row0+i][col0+c] = (rp[i] - rm[i]) / (2 * eps)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// collocCubicSystem is y” = 6t written as u' = v, v' = 6t, whose
|
||||
// exact solution with u(0) = 0, u(1) = 1 is u = t³, v = 3t². The
|
||||
// three-point Lobatto IIIA collocation reproduces a cubic exactly, so
|
||||
// the discrete solve is the exact answer, not an approximation.
|
||||
func collocCubicSystem(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), 6 * t}, 2)
|
||||
}
|
||||
|
||||
// collocHermite evaluates the solution's piecewise cubic Hermite
|
||||
// through (mesh, values, slopes) at time tau, the documented
|
||||
// continuous representation of the collocation answer.
|
||||
func collocHermite(t *testing.T, sol *CollocationSolution, tau float64, component int) float64 {
|
||||
t.Helper()
|
||||
if tau < sol.Mesh[0] || tau > sol.Mesh[len(sol.Mesh)-1] {
|
||||
t.Fatalf("time %g outside the mesh", tau)
|
||||
}
|
||||
lo, hi := 0, len(sol.Mesh)-1
|
||||
for hi-lo > 1 {
|
||||
mid := (lo + hi) / 2
|
||||
if sol.Mesh[mid] <= tau {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
h := sol.Mesh[lo+1] - sol.Mesh[lo]
|
||||
th := (tau - sol.Mesh[lo]) / h
|
||||
th2 := th * th
|
||||
th3 := th2 * th
|
||||
y0 := sol.Values[lo].FloatAt(component)
|
||||
y1 := sol.Values[lo+1].FloatAt(component)
|
||||
s0 := sol.Slopes[lo].FloatAt(component)
|
||||
s1 := sol.Slopes[lo+1].FloatAt(component)
|
||||
return (2*th3-3*th2+1)*y0 + h*(th3-2*th2+th)*s0 +
|
||||
(-2*th3+3*th2)*y1 + h*(th3-th2)*s1
|
||||
}
|
||||
|
||||
// TestSolveBoundaryCollocationCubicExact solves the linear problem
|
||||
// u” = 6t with u(0) = 0, u(1) = 1 on a uniform mesh: the cubic
|
||||
// collocation reproduces t³ exactly, the Newton residual drops to
|
||||
// machine precision in one step, and no refinement is needed, so the
|
||||
// mesh keeps its initial size.
|
||||
func TestSolveBoundaryCollocationCubicExact(t *testing.T) {
|
||||
sol, err := SolveBoundaryCollocation(collocCubicSystem, 0, 1,
|
||||
mustFloats(t, []float64{0, 0}),
|
||||
BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{1}},
|
||||
CollocationOptions{RelTol: 1e-7, InitialNodes: 8, MaxNodes: 64})
|
||||
if err != nil {
|
||||
t.Fatalf("SolveBoundaryCollocation: %v", err)
|
||||
}
|
||||
if len(sol.Mesh) != 9 {
|
||||
t.Fatalf("mesh grew to %d nodes on a cubic-exact problem, want the initial 9", len(sol.Mesh))
|
||||
}
|
||||
for k, tk := range sol.Mesh {
|
||||
if math.Abs(sol.Values[k].FloatAt(0)-tk*tk*tk) > 1e-12 {
|
||||
t.Fatalf("u(%.6g) = %.14g, want %.14g", tk, sol.Values[k].FloatAt(0), tk*tk*tk)
|
||||
}
|
||||
if math.Abs(sol.Slopes[k].FloatAt(0)-3*tk*tk) > 1e-12 {
|
||||
t.Fatalf("u'(%.6g) = %.14g, want %.14g", tk, sol.Slopes[k].FloatAt(0), 3*tk*tk)
|
||||
}
|
||||
if math.Abs(sol.Values[k].FloatAt(1)-3*tk*tk) > 1e-12 {
|
||||
t.Fatalf("v(%.6g) = %.14g, want %.14g", tk, sol.Values[k].FloatAt(1), 3*tk*tk)
|
||||
}
|
||||
}
|
||||
// A one-component state carries exactly one condition: an
|
||||
// endpoint-only prescription solves y' = y backward from t1.
|
||||
sol1, err := SolveBoundaryCollocation(
|
||||
func(t float64, y *core.Array) (*core.Array, error) { return core.MulF(y, 1), nil },
|
||||
0, 1, mustFloats(t, []float64{1}),
|
||||
BoundaryConditions{End: []int{0}, EndValues: []float64{math.E}},
|
||||
CollocationOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("one-component solve: %v", err)
|
||||
}
|
||||
for k, tk := range sol1.Mesh {
|
||||
if math.Abs(sol1.Values[k].FloatAt(0)-math.Exp(tk)) > 1e-6 {
|
||||
t.Fatalf("y(%.6g) = %.14g, want %.14g", tk, sol1.Values[k].FloatAt(0), math.Exp(tk))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolveBoundaryCollocationBratuMatchesShooting solves Bratu's
|
||||
// equation u” + e^u = 0 with u(0) = u(1) = 0, λ = 1, and requires
|
||||
// the collocation answer to agree with the shooting method's answer
|
||||
// through the existing IntegrateBoundary.
|
||||
func TestSolveBoundaryCollocationBratuMatchesShooting(t *testing.T) {
|
||||
bratu := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -math.Exp(y.FloatAt(0))}, 2)
|
||||
}
|
||||
sol, err := SolveBoundaryCollocation(bratu, 0, 1,
|
||||
mustFloats(t, []float64{0, 0.4}),
|
||||
BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{0}},
|
||||
CollocationOptions{RelTol: 1e-7, AbsTol: 1e-10, InitialNodes: 10, MaxNodes: 600, MaxIterations: 60})
|
||||
if err != nil {
|
||||
t.Fatalf("collocation: %v", err)
|
||||
}
|
||||
if len(sol.Mesh) <= 10 {
|
||||
t.Fatalf("the mesh never grew past the initial 10 intervals (refinement instrument): %d nodes", len(sol.Mesh))
|
||||
}
|
||||
times, states, err := IntegrateBoundary(bratu, 0, 1,
|
||||
mustFloats(t, []float64{0, 0.4}),
|
||||
BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{0}},
|
||||
9, ODEOptions{RelTol: 1e-10, AbsTol: 1e-13})
|
||||
if err != nil {
|
||||
t.Fatalf("shooting: %v", err)
|
||||
}
|
||||
worstU, worstS := 0.0, 0.0
|
||||
for i := 1; i < len(times)-1; i++ {
|
||||
if d := math.Abs(collocHermite(t, sol, times[i], 0) - states[i].FloatAt(0)); d > worstU {
|
||||
worstU = d
|
||||
}
|
||||
if d := math.Abs(collocHermite(t, sol, times[i], 1) - states[i].FloatAt(1)); d > worstS {
|
||||
worstS = d
|
||||
}
|
||||
}
|
||||
t.Logf("Bratu λ=1: worst state difference %.3g, worst slope difference %.3g", worstU, worstS)
|
||||
if worstU > 1e-5 {
|
||||
t.Fatalf("collocation and shooting disagree on u by %.3g", worstU)
|
||||
}
|
||||
if worstS > 1e-4 {
|
||||
t.Fatalf("collocation and shooting disagree on u' by %.3g", worstS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolveBoundaryCollocationRefinementLayer pins the refinement
|
||||
// loop with a linear boundary-layer problem u” = −100·u' scaled as
|
||||
// u' = v, v' = −100v, whose solution 1 − e^(−100t) needs intervals
|
||||
// clustered near t = 0. A loose tolerance must leave the initial
|
||||
// mesh alone; a tight one must refine it and land on the solution.
|
||||
func TestSolveBoundaryCollocationRefinementLayer(t *testing.T) {
|
||||
layer := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -100 * y.FloatAt(1)}, 2)
|
||||
}
|
||||
exact := func(t float64) float64 { return 1 - math.Exp(-100*t) }
|
||||
bc := BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{1}}
|
||||
y0 := mustFloats(t, []float64{0, 0})
|
||||
loose, err := SolveBoundaryCollocation(layer, 0, 1, y0, bc,
|
||||
CollocationOptions{AbsTol: 1e6, RelTol: 1, InitialNodes: 10, MaxNodes: 4000})
|
||||
if err != nil {
|
||||
t.Fatalf("loose solve: %v", err)
|
||||
}
|
||||
if len(loose.Mesh) != 11 {
|
||||
t.Fatalf("a loose tolerance still refined to %d nodes", len(loose.Mesh))
|
||||
}
|
||||
tight, err := SolveBoundaryCollocation(layer, 0, 1, y0, bc,
|
||||
CollocationOptions{RelTol: 1e-6, AbsTol: 1e-9, InitialNodes: 10, MaxNodes: 4000, MaxIterations: 60})
|
||||
if err != nil {
|
||||
t.Fatalf("tight solve: %v", err)
|
||||
}
|
||||
t.Logf("layer problem: mesh refined from 11 to %d nodes", len(tight.Mesh))
|
||||
if len(tight.Mesh) <= 11 {
|
||||
t.Fatal("the tight solve never refined the mesh (refinement instrument)")
|
||||
}
|
||||
worst := 0.0
|
||||
for k, tk := range tight.Mesh {
|
||||
if d := math.Abs(tight.Values[k].FloatAt(0) - exact(tk)); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("layer problem: worst nodal error %.3g", worst)
|
||||
if worst > 1e-3 {
|
||||
t.Fatalf("refined layer error %.3g too large", worst)
|
||||
}
|
||||
for k := range tight.Mesh {
|
||||
if k > 0 && tight.Mesh[k] <= tight.Mesh[k-1] {
|
||||
t.Fatalf("the mesh is not increasing at %d", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSolveBoundaryCollocationErrors pins the refusal contract.
|
||||
func TestSolveBoundaryCollocationErrors(t *testing.T) {
|
||||
const name = "SolveBoundaryCollocation"
|
||||
good := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
y0 := mustFloats(t, []float64{0, 0.5})
|
||||
bc := BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{1}}
|
||||
// Inconsistent boundary conditions: wrong counts, repeated and
|
||||
// out-of-range indices, wrong EndValues length.
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, y0,
|
||||
BoundaryConditions{End: []int{0}, EndValues: []float64{1}}, CollocationOptions{}); err == nil || !stringsContains(err, "in total") {
|
||||
t.Fatalf("%s: one condition for a two-component state: %v", name, err)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, y0,
|
||||
BoundaryConditions{Start: []int{0, 1}, End: []int{0, 1}, EndValues: []float64{1, 2}},
|
||||
CollocationOptions{}); err == nil || !stringsContains(err, "in total") {
|
||||
t.Fatalf("%s: four conditions for a two-component state: %v", name, err)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, y0,
|
||||
BoundaryConditions{Start: []int{0}, End: []int{0}}, CollocationOptions{}); err == nil || !stringsContains(err, "EndValues") {
|
||||
t.Fatalf("%s: EndValues mismatch: %v", name, err)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, y0,
|
||||
BoundaryConditions{Start: []int{0}, End: []int{2}, EndValues: []float64{1}},
|
||||
CollocationOptions{}); err == nil || !stringsContains(err, "out of range") {
|
||||
t.Fatalf("%s: End index out of range: %v", name, err)
|
||||
}
|
||||
// The duplicate-index cases need the total count to be right
|
||||
// first, so they run on a three-component state.
|
||||
good3 := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0), y.FloatAt(2)}, 3)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good3, 0, 1, mustFloats(t, []float64{0, 0.5, 1}),
|
||||
BoundaryConditions{Start: []int{0, 0}, End: []int{2}, EndValues: []float64{1}},
|
||||
CollocationOptions{}); err == nil || !stringsContains(err, "twice") {
|
||||
t.Fatalf("%s: repeated Start index: %v", name, err)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good3, 0, 1, mustFloats(t, []float64{0, 0.5, 1}),
|
||||
BoundaryConditions{Start: []int{0}, End: []int{2, 2}, EndValues: []float64{1, 2}},
|
||||
CollocationOptions{}); err == nil || !stringsContains(err, "twice") {
|
||||
t.Fatalf("%s: repeated End index: %v", name, err)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, y0,
|
||||
BoundaryConditions{}, CollocationOptions{}); err == nil || !stringsContains(err, "End must prescribe") {
|
||||
t.Fatalf("%s: nothing prescribed at t1: %v", name, err)
|
||||
}
|
||||
// State and interval gates.
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, mustFloats(t, []float64{1, 0, 2}),
|
||||
bc, CollocationOptions{}); err == nil || !stringsContains(err, "in total") {
|
||||
t.Fatalf("%s: three components with two conditions: %v", name, err)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, mustFloats(t, nil), bc, CollocationOptions{}); err == nil {
|
||||
t.Fatalf("%s: empty state accepted", name)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, mustFloats(t, []float64{1, 0, 2}, 1, 3), bc, CollocationOptions{}); err == nil || !stringsContains(err, "vector") {
|
||||
t.Fatalf("%s: a rank-2 state accepted", name)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 0, y0, bc, CollocationOptions{}); err == nil || !stringsContains(err, "positive length") {
|
||||
t.Fatalf("%s: an empty interval accepted", name)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good, 1, 0, y0, bc, CollocationOptions{}); err == nil || !stringsContains(err, "positive length") {
|
||||
t.Fatalf("%s: a backward interval accepted", name)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, mustFloats(t, []float64{math.NaN(), 0}),
|
||||
bc, CollocationOptions{}); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatalf("%s: a NaN state accepted", name)
|
||||
}
|
||||
// Mesh gates.
|
||||
if _, err := SolveBoundaryCollocation(good, 0, 1, y0, bc,
|
||||
CollocationOptions{InitialNodes: 300, MaxNodes: 200}); err == nil || !stringsContains(err, "MaxNodes") {
|
||||
t.Fatalf("%s: a starting mesh past MaxNodes accepted", name)
|
||||
}
|
||||
// A right-hand side of the wrong shape surfaces with its name.
|
||||
bad := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{1, 2, 3}, 3)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(bad, 0, 1, y0, bc, CollocationOptions{}); err == nil || !stringsContains(err, "want a vector") {
|
||||
t.Fatalf("%s: a wrong-shaped f accepted", name)
|
||||
}
|
||||
// Refinement past MaxNodes is refused with its name: the layer
|
||||
// problem demands far more than 24 intervals at this tolerance.
|
||||
layer := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -100 * y.FloatAt(1)}, 2)
|
||||
}
|
||||
if _, err := SolveBoundaryCollocation(layer, 0, 1, mustFloats(t, []float64{0, 0}),
|
||||
BoundaryConditions{Start: []int{0}, End: []int{0}, EndValues: []float64{1}},
|
||||
CollocationOptions{RelTol: 1e-4, AbsTol: 1e-6, InitialNodes: 8, MaxNodes: 24}); err == nil || !stringsContains(err, "MaxNodes") {
|
||||
t.Fatalf("%s: refinement past MaxNodes accepted", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// 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 "math"
|
||||
|
||||
// Differential-algebraic equations in the semi-implicit mass-matrix
|
||||
// form M·y' = f(t, y) with a singular M whose rank deficiency sits in
|
||||
// whole zero rows (and, by the same counts, whole zero columns). The
|
||||
// differential rows carry the implicit Euler scheme, the algebraic
|
||||
// rows are constraints the implicit relation enforces at every step,
|
||||
// and the coupled nonlinear system of each step is solved by Newton
|
||||
// over the numerical Jacobian and the library's dense LU, the
|
||||
// machinery the ODE stiff solvers already carry.
|
||||
//
|
||||
// The honest contract. The solver is first order and takes equal
|
||||
// steps, the DAE twin of IntegrateBackwardEuler. Consistent initial
|
||||
// values are the caller's contract: the solver verifies the initial
|
||||
// residual on the algebraic rows and refuses when it sits beyond the
|
||||
// tolerance, naming the row, but it does not project a general start
|
||||
// onto the constraint manifold (a consistent initialiser is a
|
||||
// documented follow-up). A second-order formula is not offered yet
|
||||
// either: BDF2 keeps index 1 only when the algebraic start satisfies
|
||||
// the constraint to second order, which again needs the missing
|
||||
// projection step. And the index is certified at t0: the solver factors
|
||||
// the Jacobian of the algebraic rows against the algebraic variables
|
||||
// and refuses when it is singular, which is the textbook refutation of
|
||||
// the index-1 contract and is exactly what catches the Cartesian
|
||||
// pendulum with multipliers, an index-3 system whose per-step solves
|
||||
// would otherwise converge while the trajectory drifted.
|
||||
|
||||
// DAEOptions tunes IntegrateDAE. RelTol ≤ 0 means 1e-6, AbsTol ≤ 0
|
||||
// means 1e-9, the ODEOptions defaults; they size the Newton tolerance
|
||||
// of the per-step solves.
|
||||
type DAEOptions struct {
|
||||
RelTol float64
|
||||
AbsTol float64
|
||||
}
|
||||
|
||||
// IntegrateDAE integrates the mass-matrix differential-algebraic
|
||||
// system M·y' = f(t, y) from t0 to t1 over the given number of equal
|
||||
// implicit Euler steps and returns y(t1). The mass matrix must be
|
||||
// square and singular, with its rank deficiency carried by whole zero
|
||||
// rows matched by whole zero columns; the zero rows are the algebraic
|
||||
// constraints and the zero columns the algebraic variables. Backward
|
||||
// integration works: a t1 < t0 simply integrates in the negative
|
||||
// direction. A wrong-shaped state or matrix, a nonsingular matrix, a
|
||||
// rank deficiency outside whole zero rows, an inconsistent initial
|
||||
// residual on an algebraic row, a singular algebraic block at t0 (the
|
||||
// index-1 refutation), or a Newton iteration that cannot converge is
|
||||
// an error, never a silently truncated trajectory.
|
||||
func IntegrateDAE(f func(t float64, y *core.Array) (*core.Array, error),
|
||||
m *core.Array, t0, t1 float64, y0 *core.Array, steps int, opts DAEOptions) (*core.Array, error) {
|
||||
const name = "IntegrateDAE"
|
||||
if steps <= 0 {
|
||||
return nil, base.Errf("%s: steps must be ≥ 1, got %d", name, steps)
|
||||
}
|
||||
y, err := odeCheck(name, y0, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := len(y)
|
||||
mat, err := daeMassMatrix(name, m, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relTol, absTol := opts.RelTol, opts.AbsTol
|
||||
if relTol <= 0 {
|
||||
relTol = 1e-6
|
||||
}
|
||||
if absTol <= 0 {
|
||||
absTol = 1e-9
|
||||
}
|
||||
rows, cols, err := daeAlgebraicSets(name, mat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The initial residual on the algebraic rows is the constraint the
|
||||
// steps will enforce; a start that violates it has no trajectory.
|
||||
fy0, err := odeEval(name, f, t0, y, n, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
residual := fy0[row]
|
||||
limit := 100 * (absTol + relTol*math.Abs(residual))
|
||||
if math.Abs(residual) > limit {
|
||||
return nil, base.Errf("%s: the initial residual on algebraic row %d is %g, beyond the consistency tolerance %g: consistent initial values are the caller's contract",
|
||||
name, row, residual, limit)
|
||||
}
|
||||
}
|
||||
// The index-1 certificate: the algebraic rows must determine the
|
||||
// algebraic variables, which is the nonsingularity of their
|
||||
// Jacobian block. A singular block means the mass-matrix rank
|
||||
// alone admitted index 1 but the system behaves above it.
|
||||
w := &odeWork{}
|
||||
jac, err := odeJacobian(name, f, t0, y, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block := make([][]float64, len(rows))
|
||||
for i, r := range rows {
|
||||
block[i] = make([]float64, len(cols))
|
||||
for j, c := range cols {
|
||||
block[i][j] = jac[r*n+c]
|
||||
}
|
||||
}
|
||||
base.Factor(block)
|
||||
if err := base.CheckSingular(name, block); err != nil {
|
||||
return nil, base.Errf("%s: %w: the Jacobian of the algebraic rows against the algebraic variables is singular at t=%g, the system behaves above index 1",
|
||||
name, err, t0)
|
||||
}
|
||||
h := (t1 - t0) / float64(steps)
|
||||
myn := make([]float64, n)
|
||||
// One Newton result buffer serves every step: daeNewton overwrites
|
||||
// it fully before the step copies it into the state, so no step
|
||||
// allocates its own.
|
||||
zbuf := make([]float64, n)
|
||||
t := t0
|
||||
if odeArrived(t, t1) {
|
||||
return arrayFromVector(y), nil
|
||||
}
|
||||
// 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
|
||||
daeMatVec(myn, mat, y)
|
||||
if nerr := daeNewton(name, f, w, mat, tNext, h, myn, y, zbuf, absTol, relTol); nerr != nil {
|
||||
return nil, nerr
|
||||
}
|
||||
copy(y, zbuf)
|
||||
}
|
||||
return arrayFromVector(y), nil
|
||||
}
|
||||
|
||||
// daeMassMatrix reads the mass matrix into a dense float64 row-major
|
||||
// form and validates its shape against the state length.
|
||||
func daeMassMatrix(name string, m *core.Array, n int) ([][]float64, error) {
|
||||
if m.Dtype() == core.Complex {
|
||||
return nil, base.Errf("%s: complex mass matrices are not supported", name)
|
||||
}
|
||||
shape := m.Shape()
|
||||
if m.NDim() != 2 || shape[0] != n || shape[1] != n {
|
||||
return nil, base.Errf("%s: the mass matrix must be square of the state's length %d, got shape %s",
|
||||
name, n, base.ShapeText(shape))
|
||||
}
|
||||
mat := make([][]float64, n)
|
||||
for i := range n {
|
||||
mat[i] = make([]float64, n)
|
||||
for j := range n {
|
||||
v := m.FloatAt(i*n + j)
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil, base.Errf("%s: the mass matrix carries the non-finite entry %g at row %d, column %d", name, v, i, j)
|
||||
}
|
||||
mat[i][j] = v
|
||||
}
|
||||
}
|
||||
return mat, nil
|
||||
}
|
||||
|
||||
// daeAlgebraicSets returns the algebraic row and column indices: the
|
||||
// zero rows and the zero columns of the mass matrix, verified to be
|
||||
// equally numerous and to carry the whole rank deficiency.
|
||||
func daeAlgebraicSets(name string, mat [][]float64) ([]int, []int, error) {
|
||||
n := len(mat)
|
||||
worst := 0.0
|
||||
for i := range n {
|
||||
for j := range n {
|
||||
worst = math.Max(worst, math.Abs(mat[i][j]))
|
||||
}
|
||||
}
|
||||
tiny := worst * 1e-14
|
||||
var rows, cols []int
|
||||
for i := range n {
|
||||
zero := true
|
||||
for j := range n {
|
||||
if math.Abs(mat[i][j]) > tiny {
|
||||
zero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if zero {
|
||||
rows = append(rows, i)
|
||||
}
|
||||
}
|
||||
for j := range n {
|
||||
zero := true
|
||||
for i := range n {
|
||||
if math.Abs(mat[i][j]) > tiny {
|
||||
zero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if zero {
|
||||
cols = append(cols, j)
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil, base.Errf("%s: the mass matrix has no zero rows, want a singular mass matrix", name)
|
||||
}
|
||||
if len(rows) != len(cols) {
|
||||
return nil, nil, base.Errf("%s: the mass matrix carries %d zero rows and %d zero columns, want equal counts for the semi-explicit index-1 form",
|
||||
name, len(rows), len(cols))
|
||||
}
|
||||
if rank := daeRank(mat, worst*1e-12); rank != n-len(rows) {
|
||||
return nil, nil, base.Errf("%s: the mass matrix carries rank deficiency outside whole zero rows (rank %d with %d zero rows), which the index-1 contract refutes",
|
||||
name, rank, len(rows))
|
||||
}
|
||||
return rows, cols, nil
|
||||
}
|
||||
|
||||
// daeRank counts the pivots Gaussian elimination with partial pivoting
|
||||
// leaves above the tolerance.
|
||||
func daeRank(mat [][]float64, tol float64) int {
|
||||
n := len(mat)
|
||||
a := make([][]float64, n)
|
||||
for i := range n {
|
||||
a[i] = cloneDenseSlice(mat[i])
|
||||
}
|
||||
rank, row := 0, 0
|
||||
for col := 0; col < n && row < n; col++ {
|
||||
piv := row
|
||||
for i := row + 1; i < n; i++ {
|
||||
if math.Abs(a[i][col]) > math.Abs(a[piv][col]) {
|
||||
piv = i
|
||||
}
|
||||
}
|
||||
if math.Abs(a[piv][col]) <= tol {
|
||||
continue
|
||||
}
|
||||
a[piv], a[row] = a[row], a[piv]
|
||||
for i := row + 1; i < n; i++ {
|
||||
f := a[i][col] / a[row][col]
|
||||
for j := col; j < n; j++ {
|
||||
a[i][j] -= f * a[row][j]
|
||||
}
|
||||
}
|
||||
rank++
|
||||
row++
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
// daeMatVec writes the product m·x into dst.
|
||||
func daeMatVec(dst []float64, m [][]float64, x []float64) {
|
||||
for i := range dst {
|
||||
s := 0.0
|
||||
for j, v := range m[i] {
|
||||
s += v * x[j]
|
||||
}
|
||||
dst[i] = s
|
||||
}
|
||||
}
|
||||
|
||||
// daeNewton solves the implicit Euler step equation M·z − M·y_n =
|
||||
// h·f(tNext, z) for z by Newton over a numerical Jacobian and the
|
||||
// library's LU solver, the mass-matrix twin of odeNewton. The
|
||||
// converged state lands in dst, which must not alias seed: a driver
|
||||
// that hands one buffer down every step allocates it once per run.
|
||||
// The Jacobian is frozen from the seed and rebuilt twice when
|
||||
// convergence drags; 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. An iteration that outlives twenty rounds, or
|
||||
// a singular Newton matrix, surfaces as errNewtonStalled; an f that
|
||||
// fails is the fatal error it is.
|
||||
func daeNewton(name string, f func(t float64, y *core.Array) (*core.Array, error),
|
||||
w *odeWork, m [][]float64, tNext, h float64, myn, seed, dst []float64,
|
||||
absTol, relTol float64) error {
|
||||
n := len(seed)
|
||||
w.use(n)
|
||||
z := dst
|
||||
copy(z, seed)
|
||||
mz := w.mz
|
||||
for iteration := range 20 {
|
||||
daeMatVec(mz, m, z)
|
||||
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] = mz[i] - h*w.fzs[i] - myn[i]
|
||||
worst = math.Max(worst, math.Abs(w.g[i]))
|
||||
terms = math.Max(terms, math.Abs(mz[i])+math.Abs(h*w.fzs[i])+math.Abs(myn[i]))
|
||||
}
|
||||
limit := math.Max(0.1*(absTol+relTol*normInfOfStep(z)), 8*base.EpsF*terms)
|
||||
if worst <= limit {
|
||||
return nil
|
||||
}
|
||||
if iteration == 0 || iteration == 4 || iteration == 10 {
|
||||
jac, jerr := odeJacobian(name, f, tNext, z, w)
|
||||
if jerr != nil {
|
||||
return jerr
|
||||
}
|
||||
// Newton matrix M − 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] = m[i][j] - h*jac[i*n+j]
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// 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 (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// daeCircuit returns the source, mass matrix and state of a linear
|
||||
// index-1 circuit: a one-volt source feeds a unit resistor into node
|
||||
// v1 (unit capacitor to ground), an inductor of one henry carries i
|
||||
// on to node v2, and node v2 dumps through a unit resistor with no
|
||||
// capacitor, so its KCL row 0 = i − v2 is the algebraic constraint
|
||||
// and i the algebraic variable. With C = L = R = 1 the differential
|
||||
// pair is x' = Ax + (1, 0) with A = [[−1, −1], [1, −1]], whose
|
||||
// solution is elementary.
|
||||
func daeCircuit(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{
|
||||
1 - y.FloatAt(0) - y.FloatAt(2),
|
||||
y.FloatAt(2) - y.FloatAt(1),
|
||||
y.FloatAt(0) - y.FloatAt(1),
|
||||
}, 3)
|
||||
}
|
||||
|
||||
func daeCircuitEnd(t *testing.T, steps int, y0 []float64, t0, t1 float64) []float64 {
|
||||
t.Helper()
|
||||
m := mustFloats(t, []float64{1, 0, 0, 0, 0, 0, 0, 0, 1}, 3, 3)
|
||||
end, err := IntegrateDAE(daeCircuit, m, t0, t1, mustFloats(t, y0), steps, DAEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateDAE: %v", err)
|
||||
}
|
||||
return []float64{end.FloatAt(0), end.FloatAt(1), end.FloatAt(2)}
|
||||
}
|
||||
|
||||
// daeCircuitExact evaluates the exact v1, v2, i at time t: the
|
||||
// equilibrium (0.5, 0.5) plus the elementary homogeneous part.
|
||||
func daeCircuitExact(t float64) []float64 {
|
||||
c := math.Exp(-t) / 2
|
||||
return []float64{
|
||||
0.5 + c*(math.Cos(t)+math.Sin(t)),
|
||||
0.5 + c*(math.Sin(t)-math.Cos(t)),
|
||||
0.5 + c*(math.Sin(t)-math.Cos(t)),
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateDAECircuit is the linear index-1 pin: the differential
|
||||
// nodes track the elementary solution, and the algebraic variable i
|
||||
// satisfies the KCL constraint i = v2 to rounding at the end state,
|
||||
// because every step enforces the constraint row exactly.
|
||||
func TestIntegrateDAECircuit(t *testing.T) {
|
||||
end := daeCircuitEnd(t, 200, []float64{1, 0, 0}, 0, 1)
|
||||
want := daeCircuitExact(1)
|
||||
for k, band := range []float64{0.01, 0.01, 0.01} {
|
||||
if math.Abs(end[k]-want[k]) > band {
|
||||
t.Fatalf("circuit[%d] = %.14g, want %.14g ± %g", k, end[k], want[k], band)
|
||||
}
|
||||
}
|
||||
if math.Abs(end[1]-end[2]) > 1e-10 {
|
||||
t.Fatalf("the constraint i = v2 drifted to %g at the end state", end[1]-end[2])
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateDAEScalarConstraint pins the algebraic variable on the
|
||||
// exact constraint to rounding: with w' unconstrained by M's zero row
|
||||
// and 0 = w − cos t, the solved w must equal cos at every step, so
|
||||
// certainly at the end.
|
||||
func TestIntegrateDAEScalarConstraint(t *testing.T) {
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1) - math.Cos(t)}, 2)
|
||||
}
|
||||
m := mustFloats(t, []float64{1, 0, 0, 0}, 2, 2)
|
||||
end, err := IntegrateDAE(f, m, 0, 1, mustFloats(t, []float64{1, 1}), 25, DAEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateDAE: %v", err)
|
||||
}
|
||||
if math.Abs(end.FloatAt(1)-math.Cos(1)) > 1e-12 {
|
||||
t.Fatalf("algebraic w(1) = %.16g, want cos(1) = %.16g to rounding",
|
||||
end.FloatAt(1), math.Cos(1))
|
||||
}
|
||||
if math.Abs(end.FloatAt(0)-math.Exp(-1)) > 0.05 {
|
||||
t.Fatalf("differential u(1) = %.14g, want %.14g ± 0.05", end.FloatAt(0), math.Exp(-1))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateDAEBackward integrates the circuit backwards from the
|
||||
// exact end state; the signed-step formulation must return the start.
|
||||
func TestIntegrateDAEBackward(t *testing.T) {
|
||||
want := daeCircuitExact(1)
|
||||
end := daeCircuitEnd(t, 200, want, 1, 0)
|
||||
start := daeCircuitExact(0)
|
||||
for k := range 3 {
|
||||
if math.Abs(end[k]-start[k]) > 0.01 {
|
||||
t.Fatalf("backward circuit[%d] = %.14g, want %.14g ± 0.01", k, end[k], start[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateDAEConsistencyRefused pins the initial-residual check:
|
||||
// a start violating the KCL row by one full unit is refused, with the
|
||||
// row named.
|
||||
func TestIntegrateDAEConsistencyRefused(t *testing.T) {
|
||||
m := mustFloats(t, []float64{1, 0, 0, 0, 0, 0, 0, 0, 1}, 3, 3)
|
||||
_, err := IntegrateDAE(daeCircuit, m, 0, 1, mustFloats(t, []float64{1, 1, 0}), 200, DAEOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an inconsistent start")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "row 1") {
|
||||
t.Fatalf("want the algebraic row named, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateDAEPendulumRefused pins the honest index-3 refusal: the
|
||||
// Cartesian pendulum with multipliers has a mass matrix that admits
|
||||
// index 1 by rank alone, but its algebraic rows (the constraints) do
|
||||
// not depend on the algebraic variables (the multipliers) at all, so
|
||||
// the certified block is singular and the solver refuses, naming the
|
||||
// detection.
|
||||
func TestIntegrateDAEPendulumRefused(t *testing.T) {
|
||||
// y = (x, ypos, u, v, lambda, mu): position, velocity, multipliers.
|
||||
// m = 1, g = 0, length 1: the unit circle, started at (1, 0) with
|
||||
// unit tangential speed and the multiplier that holds it there.
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
x, ypos, u, v, lambda := y.FloatAt(0), y.FloatAt(1), y.FloatAt(2), y.FloatAt(3), y.FloatAt(4)
|
||||
return core.FromFloats([]float64{
|
||||
u, v,
|
||||
-2 * x * lambda,
|
||||
-2 * ypos * lambda,
|
||||
x*x + ypos*ypos - 1,
|
||||
x*u + ypos*v,
|
||||
}, 6)
|
||||
}
|
||||
m := mustFloats(t, []float64{
|
||||
1, 0, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0, 0,
|
||||
0, 0, 1, 0, 0, 0,
|
||||
0, 0, 0, 1, 0, 0,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
}, 6, 6)
|
||||
y0 := mustFloats(t, []float64{1, 0, 0, 1, 0.5, 0})
|
||||
_, err := IntegrateDAE(f, m, 0, 0.1, y0, 10, DAEOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected the index-3 pendulum to be refused")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "index 1") || !strings.Contains(err.Error(), "singular") {
|
||||
t.Fatalf("want the index detection stated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateDAEIndexTwoStall pins the other honest refusal: an
|
||||
// ordinary stiff ODE whose per-step Newton matrix is exactly singular
|
||||
// at the chosen step (y0' = 100·y0 with h·100 = 1) fails loudly
|
||||
// through the Newton solve, not through silent drift; the index-1
|
||||
// certificate itself passes because the algebraic row y1 − y0 does
|
||||
// depend on the algebraic variable, so the refusal here comes from
|
||||
// the differential row's pathology and must be named as such.
|
||||
func TestIntegrateDAEIndexTwoStall(t *testing.T) {
|
||||
// y0' = 100·y0 with h·100 = 1 makes the Newton matrix singular.
|
||||
f := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{100 * y.FloatAt(0), y.FloatAt(1) - y.FloatAt(0)}, 2)
|
||||
}
|
||||
m := mustFloats(t, []float64{1, 0, 0, 0}, 2, 2)
|
||||
_, err := IntegrateDAE(f, m, 0, 1, mustFloats(t, []float64{1, 1}), 100, DAEOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected the singular per-step solve to be refused")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Newton") {
|
||||
t.Fatalf("want a Newton failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateDAEErrors pins the structural error contract: a zero
|
||||
// step count, a rank-1 mass matrix of the wrong shape, a nonsingular
|
||||
// matrix, a rank deficiency without whole zero rows, mismatched zero
|
||||
// row and column counts, an empty state, a non-finite matrix entry and
|
||||
// a failing f are all errors; a degenerate span returns the start.
|
||||
func TestIntegrateDAEErrors(t *testing.T) {
|
||||
good := mustFloats(t, []float64{1, 0, 0, 0}, 2, 2)
|
||||
simple := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1)}, 2)
|
||||
}
|
||||
simple3 := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1), -y.FloatAt(2)}, 3)
|
||||
}
|
||||
if _, err := IntegrateDAE(simple, good, 0, 1, mustFloats(t, []float64{1, 1}), 0, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for zero steps")
|
||||
}
|
||||
badShape := mustFloats(t, []float64{1, 0}, 1, 2)
|
||||
if _, err := IntegrateDAE(simple, badShape, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a non-square mass matrix")
|
||||
}
|
||||
identity := mustFloats(t, []float64{1, 0, 0, 1}, 2, 2)
|
||||
if _, err := IntegrateDAE(simple, identity, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a nonsingular mass matrix")
|
||||
}
|
||||
noZeroRows := mustFloats(t, []float64{1, 1, 1, 1}, 2, 2)
|
||||
if _, err := IntegrateDAE(simple, noZeroRows, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for rank deficiency without zero rows")
|
||||
}
|
||||
rowColMismatch := mustFloats(t, []float64{1, 1, 0, 0}, 2, 2)
|
||||
if _, err := IntegrateDAE(simple, rowColMismatch, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for mismatched zero row and column counts")
|
||||
}
|
||||
if _, err := IntegrateDAE(simple, good, 0, 1, mustFloats(t, nil), 10, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an empty state")
|
||||
}
|
||||
nonFinite := mustFloats(t, []float64{1, 0, 0, math.Inf(1)}, 2, 2)
|
||||
if _, err := IntegrateDAE(simple, nonFinite, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a non-finite mass matrix entry")
|
||||
}
|
||||
complexM, _ := core.FromComplexes([]complex128{1, 0, 0, 1}, 2, 2)
|
||||
if _, err := IntegrateDAE(simple, complexM, 0, 1, mustFloats(t, []float64{1, 1}), 10, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a complex mass matrix")
|
||||
}
|
||||
// One zero row but a second dependent row: the rank deficiency
|
||||
// exceeds the zero rows and the contract is refused.
|
||||
hiddenDeficiency := mustFloats(t, []float64{1, 1, 0, 1, 1, 0, 0, 0, 0}, 3, 3)
|
||||
_, err := IntegrateDAE(simple3, hiddenDeficiency, 0, 1, mustFloats(t, []float64{1, 1, 1}), 10, DAEOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "rank deficiency") {
|
||||
t.Fatalf("expected the hidden rank deficiency to be refused, got %v", err)
|
||||
}
|
||||
// A degenerate span answers the validated start unchanged. The
|
||||
// start must satisfy the algebraic row of this system, y1 = 0.
|
||||
same, err := IntegrateDAE(simple, good, 1, 1, mustFloats(t, []float64{1, 0}), 10, DAEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("zero span: %v", err)
|
||||
}
|
||||
if same.FloatAt(0) != 1 || same.FloatAt(1) != 0 {
|
||||
t.Fatalf("zero span moved the state to (%v, %v)", same.FloatAt(0), same.FloatAt(1))
|
||||
}
|
||||
boom := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
if t > 0.5 {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
return core.FromFloats([]float64{-y.FloatAt(0), y.FloatAt(1)}, 2)
|
||||
}
|
||||
if _, err := IntegrateDAE(boom, good, 0, 1, mustFloats(t, []float64{1, 0}), 100, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected the operator error to propagate")
|
||||
}
|
||||
// An f failing on the initial evaluation, the initial Jacobian and
|
||||
// inside the first Newton iteration is refused at once.
|
||||
always := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
if _, err := IntegrateDAE(always, good, 0, 1, mustFloats(t, []float64{1, 0}), 10, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an f that always fails")
|
||||
}
|
||||
// An f that only tolerates the exact seed fails when the Newton
|
||||
// iteration perturbs the state for its numerical Jacobian.
|
||||
touchy := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
if y.FloatAt(0) != 1 {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
return core.FromFloats([]float64{1 - y.FloatAt(0), y.FloatAt(1) - y.FloatAt(0)}, 2)
|
||||
}
|
||||
if _, err := IntegrateDAE(touchy, good, 0, 1, mustFloats(t, []float64{1, 0}), 10, DAEOptions{}); err == nil {
|
||||
t.Fatal("expected the Jacobian perturbation to trip the f error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// 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 (
|
||||
"cmp"
|
||||
"math"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Event detection along an ODE trajectory. Impact times, resonance
|
||||
// crossings and threshold passages are all the same question: when
|
||||
// does a scalar watch function g(t, y) cross zero on the way to t1?
|
||||
// The integrator checks every accepted step for a sign change of each
|
||||
// watch and, when one appears, narrows the crossing by bisection that
|
||||
// re-integrates the step's interval, so the event time is as accurate
|
||||
// as the integrator itself and needs no dense-output machinery.
|
||||
//
|
||||
// Watches are only compared across the boundaries of accepted steps:
|
||||
// a watch that touches zero and returns to its sign inside one step
|
||||
// goes unnoticed, the same blind spot every step-based detector has.
|
||||
// Sign changes are searched strictly after t0, so a watch sitting on
|
||||
// zero at the initial state does not fire until it leaves and returns.
|
||||
|
||||
// ODEWatch reports a scalar quantity to watch along the trajectory.
|
||||
// Direction filters the crossings: +1 records only rising crossings
|
||||
// (watch going from negative to non-negative), −1 only falling ones,
|
||||
// 0 both.
|
||||
type ODEWatch struct {
|
||||
Function func(t float64, y *core.Array) (float64, error)
|
||||
Direction int
|
||||
}
|
||||
|
||||
// ODEEventHit records one zero crossing: the time it happened, the
|
||||
// state there, and which watch fired.
|
||||
type ODEEventHit struct {
|
||||
Time float64
|
||||
State *core.Array
|
||||
Watch int
|
||||
Rising bool
|
||||
}
|
||||
|
||||
// IntegrateODEEvents integrates y' = f(t, y) from t0 to t1 exactly
|
||||
// like IntegrateODE and, alongside the final state, returns every
|
||||
// zero crossing of the watches, sorted by time. The watch functions
|
||||
// must tolerate being called at any time inside [t0, t1]; they are
|
||||
// also called at the step boundaries the integrator accepts, and the
|
||||
// first accepted step is seeded with the watch value at its start
|
||||
// state, so a crossing inside it is detected like any other. A watch
|
||||
// error, a non-finite watch value included, aborts the integration.
|
||||
func IntegrateODEEvents(f func(t float64, y *core.Array) (*core.Array, error),
|
||||
t0, t1 float64, y0 *core.Array, watches []ODEWatch, opts ODEOptions) ([]ODEEventHit, *core.Array, error) {
|
||||
if len(watches) == 0 {
|
||||
return nil, nil, base.Errf("IntegrateODEEvents: at least one watch is needed")
|
||||
}
|
||||
for i := range watches {
|
||||
if watches[i].Function == nil {
|
||||
return nil, nil, base.Errf("IntegrateODEEvents: watch %d has no function", i)
|
||||
}
|
||||
}
|
||||
// gPrev[i] carries the watch value at the last accepted boundary;
|
||||
// havePrev becomes true after the first evaluation.
|
||||
gPrev := make([]float64, len(watches))
|
||||
havePrev := false
|
||||
var hits []ODEEventHit
|
||||
|
||||
watch := func(tPrev, tNow float64, yPrev, yNow []float64) (bool, error) {
|
||||
for i := range watches {
|
||||
if !havePrev {
|
||||
// First accepted step: the watch value at the step's
|
||||
// start state seeds the comparison, so a crossing
|
||||
// inside the very first step is seen like any other
|
||||
// one instead of hiding behind a missing gPrev.
|
||||
g0, err := watches[i].Function(tPrev, wrapVector(yPrev))
|
||||
if err != nil {
|
||||
return false, base.Errf("IntegrateODEEvents: watch %d: %w", i, err)
|
||||
}
|
||||
if math.IsNaN(g0) || math.IsInf(g0, 0) {
|
||||
return false, base.Errf("IntegrateODEEvents: watch %d returned the non-finite value %g at t=%g", i, g0, tPrev)
|
||||
}
|
||||
gPrev[i] = g0
|
||||
}
|
||||
g, err := watches[i].Function(tNow, wrapVector(yNow))
|
||||
if err != nil {
|
||||
return false, base.Errf("IntegrateODEEvents: watch %d: %w", i, err)
|
||||
}
|
||||
// A non-finite watch value compares false against every
|
||||
// sign test and would masquerade as a crossing (or swallow
|
||||
// one), so it is refused like quad's non-finite integrand.
|
||||
if math.IsNaN(g) || math.IsInf(g, 0) {
|
||||
return false, base.Errf("IntegrateODEEvents: watch %d returned the non-finite value %g at t=%g", i, g, tNow)
|
||||
}
|
||||
gp := gPrev[i]
|
||||
if gp == 0 {
|
||||
gPrev[i] = g
|
||||
continue
|
||||
}
|
||||
if g == 0 {
|
||||
// The watch landed exactly on zero at the accepted
|
||||
// boundary: the crossing is here, at (tNow, yNow), no
|
||||
// bisection needed. The leaving interval starts from
|
||||
// zero, so the next step stays silent, exactly the
|
||||
// "does not fire until it leaves and returns" the
|
||||
// documented zero-at-start rule spells out. The clamp
|
||||
// h = t1 − t makes the final boundary land here for
|
||||
// round numbers, so dropping it would lose events.
|
||||
rising := gp < 0
|
||||
if watches[i].Direction > 0 && !rising || watches[i].Direction < 0 && rising {
|
||||
gPrev[i] = g
|
||||
continue
|
||||
}
|
||||
state := make([]float64, len(yNow))
|
||||
copy(state, yNow)
|
||||
hits = append(hits, ODEEventHit{
|
||||
Time: tNow,
|
||||
State: arrayFromVector(state),
|
||||
Watch: i,
|
||||
Rising: rising,
|
||||
})
|
||||
gPrev[i] = g
|
||||
continue
|
||||
}
|
||||
if (gp < 0) == (g < 0) {
|
||||
gPrev[i] = g
|
||||
continue
|
||||
}
|
||||
// A crossing between tPrev and tNow: bisect on the
|
||||
// re-integrated watch from the step's start state.
|
||||
rising := gp < 0
|
||||
if watches[i].Direction != 0 {
|
||||
if watches[i].Direction > 0 && !rising {
|
||||
gPrev[i] = g
|
||||
continue
|
||||
}
|
||||
if watches[i].Direction < 0 && rising {
|
||||
gPrev[i] = g
|
||||
continue
|
||||
}
|
||||
}
|
||||
tHit, yHit, err := refineEvent(f, tPrev, tNow, yPrev, gPrev[i], watches[i].Function, opts)
|
||||
if err != nil {
|
||||
return false, base.Errf("IntegrateODEEvents: %w", err)
|
||||
}
|
||||
hits = append(hits, ODEEventHit{
|
||||
Time: tHit,
|
||||
State: yHit,
|
||||
Watch: i,
|
||||
Rising: rising,
|
||||
})
|
||||
gPrev[i] = g
|
||||
}
|
||||
havePrev = true
|
||||
return false, nil
|
||||
}
|
||||
final, err := odeRun(f, t0, t1, y0, opts, watch)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slices.SortFunc(hits, func(a, b ODEEventHit) int {
|
||||
return cmp.Compare(a.Time, b.Time)
|
||||
})
|
||||
return hits, final, nil
|
||||
}
|
||||
|
||||
// refineEvent narrows a zero crossing of g between tPrev and tNow by
|
||||
// bisection, evaluating g by re-integrating from the step's start
|
||||
// state. Both endpoint values are known to have opposite signs, which
|
||||
// bisection turns into the crossing at integrator accuracy.
|
||||
func refineEvent(f func(t float64, y *core.Array) (*core.Array, error),
|
||||
tPrev, tNow float64, yPrev []float64, gPrev float64,
|
||||
g func(t float64, y *core.Array) (float64, error), opts ODEOptions) (float64, *core.Array, error) {
|
||||
lo, hi := tPrev, tNow
|
||||
for range 100 {
|
||||
mid := (lo + hi) / 2
|
||||
if mid == lo || mid == hi {
|
||||
break
|
||||
}
|
||||
yMid, err := IntegrateODE(f, tPrev, mid, wrapVector(yPrev), opts)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
gm, err := g(mid, yMid)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if gm == 0 {
|
||||
return mid, yMid, nil
|
||||
}
|
||||
if (gPrev < 0) == (gm < 0) {
|
||||
lo = mid
|
||||
gPrev = gm
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
tHit := (lo + hi) / 2
|
||||
yHit, err := IntegrateODE(f, tPrev, tHit, wrapVector(yPrev), opts)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return tHit, yHit, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// TestFixedStepTimeGrid pins the exact time grid of the fixed-step
|
||||
// solvers: the stage times come from t0 + i·h, never from an
|
||||
// accumulated t += h, whose rounding walks over a long run. On
|
||||
// y' = cos t from t0 = 1e6 the walk used to hold the answer at an
|
||||
// error of 1.1e-8 that no step count refined away; the grid puts RK4
|
||||
// at its own truncation floor and lets the first-order schemes show
|
||||
// their h. The tolerances sit an order below the old walk, so the pin
|
||||
// fails on an accumulated-time build.
|
||||
func TestFixedStepTimeGrid(t *testing.T) {
|
||||
const t0, span, steps = 1e6, 1e-1, 1e5
|
||||
cos := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{math.Cos(t)}, 1)
|
||||
}
|
||||
exact := math.Sin(t0 + span)
|
||||
end, err := IntegrateRK4(cos, t0, t0+span, mustFloats(t, []float64{math.Sin(t0)}), steps)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateRK4: %v", err)
|
||||
}
|
||||
if got := math.Abs(end.FloatAt(0) - exact); got > 1e-11 {
|
||||
t.Fatalf("RK4 error %.3e exceeds 1e-11: the stage times have left the exact grid", got)
|
||||
}
|
||||
end, err = IntegrateBackwardEuler(cos, t0, t0+span, mustFloats(t, []float64{math.Sin(t0)}), 1e6, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateBackwardEuler: %v", err)
|
||||
}
|
||||
if got := math.Abs(end.FloatAt(0) - exact); got > 5e-9 {
|
||||
t.Fatalf("backward Euler error %.3e exceeds 5e-9: the step times have left the exact grid", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// 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 Rosenbrock-Wanner workhorse: the four-stage L-stable scheme ROS4
|
||||
// of Hairer and Wanner, fourth order accurate with an embedded
|
||||
// third-order solution driving the step control. Where BDF solves each
|
||||
// step by Newton, a W method puts the Jacobian into the formula
|
||||
// itself: every stage is one linear solve against the frozen matrix
|
||||
// (1/(γh))I − J, so a step costs one numerical Jacobian, one LU
|
||||
// factorisation and four back-substitutions, and no Newton iteration
|
||||
// ever stalls. The scheme is L-stable: its stability function vanishes
|
||||
// at infinity, so a step far beyond the transient's time constant
|
||||
// damps the stiff mode instead of amplifying it.
|
||||
//
|
||||
// The stage form is the divided one the standard implementations use.
|
||||
// With A = (1/(γh))I − J factored once per step, stage one solves
|
||||
// A·k₁ = f(t, y) and stage i solves A·kᵢ = f(t + cᵢh, yᵢ) +
|
||||
// (Σⱼ<ᵢ cᵢⱼkⱼ)/h with the stage value yᵢ = y + Σⱼ<ᵢ aᵢⱼkⱼ; the step
|
||||
// advances by y + Σ bᵢkᵢ and the embedded estimate is Σ êᵢkᵢ. Stage
|
||||
// four shares its node and its stage value with stage three, so its f
|
||||
// evaluation carries over and a step costs three f calls. The digits
|
||||
// are the published ones, cross-checked against the standard
|
||||
// implementations of the scheme.
|
||||
//
|
||||
// Two honest limits of the tableau. The Jacobian enters at (t, y) and
|
||||
// the tableau's time-derivative weights are left out, because the
|
||||
// package's f contract carries no partial derivative in t: the fourth
|
||||
// order therefore holds for autonomous systems (every problem in this
|
||||
// package's stiff tests is one), while a genuinely time-dependent f
|
||||
// loses the second-order local terms those weights carry and can
|
||||
// degrade to first order globally; measured on y' = −y + t with
|
||||
// uniform steps the global ratios come out near 2, not near 16. And
|
||||
// the scheme is L-stable but not stiffly accurate: the fourth stage's
|
||||
// weights differ from the solution weights, the stiff damping coming
|
||||
// from the stability limit rather than from stage-end agreement.
|
||||
|
||||
var (
|
||||
// rowGamma is the abscissa of the stage solves and the source of
|
||||
// the L-stability: the stability function's denominator is
|
||||
// (1 − γz)⁴ and its numerator vanishes at infinity.
|
||||
rowGamma = 0.57282
|
||||
|
||||
// rowNodes are the stage times as multiples of h. The second node
|
||||
// is the published tableau's c2 = 0.114564 (Hairer and Wanner's
|
||||
// ROS4); its doubled form 2·gamma appears in some reprints, and the
|
||||
// digit never enters the arithmetic of an autonomous problem, where
|
||||
// stage times cancel, so either form integrates identically here.
|
||||
rowNodes = [4]float64{0, 0.114564, 0.65521686381559, 0.65521686381559}
|
||||
|
||||
// rowA holds the stage-value weights a[i][j], the coefficient of
|
||||
// k_j inside stage i's value.
|
||||
rowA = [4][3]float64{
|
||||
{},
|
||||
{2},
|
||||
{1.867943637803922, 0.2344449711399156},
|
||||
{1.867943637803922, 0.2344449711399156, 0},
|
||||
}
|
||||
|
||||
// rowC holds the Jacobian-coupling weights c[i][j], the coefficient
|
||||
// of k_j inside stage i's right side, divided by h.
|
||||
rowC = [4][3]float64{
|
||||
{},
|
||||
{-7.13761503641231},
|
||||
{2.580708087951457, 0.6515950076447975},
|
||||
{-2.137148994382534, -0.3214669691237626, -0.6949742501781779},
|
||||
}
|
||||
|
||||
// rowB advances the solution; rowE carries the embedded third-order
|
||||
// estimate (the difference between the fourth-order and the
|
||||
// embedded third-order weights).
|
||||
rowB = [4]float64{2.255570073418735, 0.2870493262186792, 0.435317943184018, 1.093502252409163}
|
||||
rowE = [4]float64{-0.2815431932141155, -0.0727619912493892, -0.1082196201495311, -1.093502252409163}
|
||||
)
|
||||
|
||||
// IntegrateROS4 integrates y' = f(t, y) from t0 to t1 with the
|
||||
// four-stage L-stable Rosenbrock-Wanner scheme ROS4 and returns y(t1).
|
||||
// The fourth order holds for autonomous systems; a genuinely
|
||||
// time-dependent f loses the second-order local terms the tableau's
|
||||
// time-derivative weights would carry (the package's f contract has no
|
||||
// partial derivative in t), and can degrade to first order globally:
|
||||
// measured on y' = −y + t with uniform steps the ratios come out near
|
||||
// 2, not near 16. The adaptive controller still holds its tolerance
|
||||
// there, at the cost of more steps. The step size follows the embedded
|
||||
// error under the classic accept-or-shrink control, with the numerical
|
||||
// Jacobian taken once per step through the same central-difference
|
||||
// helper the Newton path uses; there is no user Jacobian parameter.
|
||||
// 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 stage that leaves the
|
||||
// finite range even as the step shrinks is an error, never a silently
|
||||
// truncated trajectory.
|
||||
func IntegrateROS4(f func(t float64, y *core.Array) (*core.Array, error),
|
||||
t0, t1 float64, y0 *core.Array, opts ODEOptions) (*core.Array, error) {
|
||||
const name = "IntegrateROS4"
|
||||
y, err := odeCheck(name, y0, &opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h, err := bdf2InitialStep(name, f, t0, t1, y, &opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
budget := odeBudget{max: opts.MaxSteps}
|
||||
w := &odeWork{}
|
||||
t := t0
|
||||
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)
|
||||
yEnd, errNorm, nerr := ros4Step(name, f, w, t, y, h, opts.AbsTol, opts.RelTol)
|
||||
if nerr != nil {
|
||||
if errors.Is(nerr, errNewtonStalled) {
|
||||
// The stage matrix closed onto singularity: halve the
|
||||
// step and retry the same interval, within the budget.
|
||||
h *= 0.5
|
||||
continue
|
||||
}
|
||||
return nil, nerr
|
||||
}
|
||||
if math.IsNaN(errNorm) || math.IsInf(errNorm, 0) {
|
||||
// A stage left the finite range: shrink hard and retry.
|
||||
h *= 0.25
|
||||
continue
|
||||
}
|
||||
factor := min(5, max(0.2, 0.9*math.Pow(1/errNorm, 1.0/4)))
|
||||
if errNorm <= 1 {
|
||||
copy(y, yEnd)
|
||||
prevT := t
|
||||
t += h
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
// Rejected: retry the same interval with the smaller step.
|
||||
h *= max(0.2, factor)
|
||||
}
|
||||
}
|
||||
return arrayFromVector(y), nil
|
||||
}
|
||||
|
||||
// ros4Step attempts one ROS4 step of the given size from (t, y) and
|
||||
// returns the candidate end state, the embedded error norm against the
|
||||
// mixed absolute and relative tolerance, and a nil error when the
|
||||
// stages all solved. Every buffer belongs to the workspace, so a step
|
||||
// allocates nothing of its own: the caller's y is left untouched, and
|
||||
// the returned slice stays valid until the next step.
|
||||
func ros4Step(name string, f func(t float64, y *core.Array) (*core.Array, error),
|
||||
w *odeWork, t float64, y []float64, h, absTol, relTol float64) ([]float64, float64, error) {
|
||||
n := len(y)
|
||||
w.use(n)
|
||||
// One scratch buffer per role: the stage value, the stage right
|
||||
// side, f's result and the candidate end state are rebuilt at the
|
||||
// top of their use and read only by it.
|
||||
ks, stage, rhs, fy, yEnd := w.ks, w.stage, w.rhs, w.fy, w.yEnd
|
||||
// One numerical Jacobian per step, the house central-difference
|
||||
// helper the Newton path shares, and one factorisation of I − hγJ,
|
||||
// which is (1/(γh))I − J scaled by γh: the scale folds into the
|
||||
// stage right sides instead of the matrix.
|
||||
jac, jerr := odeJacobian(name, f, t, y, w)
|
||||
if jerr != nil {
|
||||
return nil, 0, jerr
|
||||
}
|
||||
lu := w.mat
|
||||
// The step-scaled matrix weight and the stage weight are one
|
||||
// product each, the same (−h·γ) and (h·γ) groupings the element
|
||||
// loops evaluated.
|
||||
hgr := -h * rowGamma
|
||||
hg := h * rowGamma
|
||||
for i := range n {
|
||||
row := lu[i]
|
||||
for j := range n {
|
||||
row[j] = hgr * jac[i*n+j]
|
||||
}
|
||||
row[i]++
|
||||
}
|
||||
w.perm, _ = base.Factor(lu)
|
||||
if err := base.CheckSingular(name, lu); err != nil {
|
||||
return nil, 0, base.Errf("%s: %w, singular stage matrix at t=%g", name, errNewtonStalled, t)
|
||||
}
|
||||
// Stage one.
|
||||
out, ferr := odeCall(name, f, t, y, n, &w.views)
|
||||
if ferr != nil {
|
||||
return nil, 0, ferr
|
||||
}
|
||||
readVector(fy, out)
|
||||
for i := range n {
|
||||
rhs[i] = hg * fy[i]
|
||||
}
|
||||
odePermuteColumn(rhs, w.perm, w.visited)
|
||||
base.SolveColumn(lu, rhs)
|
||||
copy(ks[0], rhs)
|
||||
// Stages two through four. Stage four repeats stage three's node
|
||||
// and stage value, so its derivative carries over.
|
||||
for s := 1; s < 4; s++ {
|
||||
if s < 3 {
|
||||
copy(stage, y)
|
||||
for j := range s {
|
||||
aj := rowA[s][j]
|
||||
if aj == 0 {
|
||||
continue
|
||||
}
|
||||
kjs := ks[j]
|
||||
for i := range n {
|
||||
stage[i] += aj * kjs[i]
|
||||
}
|
||||
}
|
||||
out, ferr = odeCall(name, f, t+rowNodes[s]*h, stage, n, &w.views)
|
||||
if ferr != nil {
|
||||
return nil, 0, ferr
|
||||
}
|
||||
readVector(fy, out)
|
||||
}
|
||||
for i := range n {
|
||||
rhs[i] = hg * fy[i]
|
||||
}
|
||||
for j := range s {
|
||||
cj := rowC[s][j]
|
||||
if cj == 0 {
|
||||
continue
|
||||
}
|
||||
gcj := rowGamma * cj
|
||||
kjs := ks[j]
|
||||
for i := range n {
|
||||
rhs[i] += gcj * kjs[i]
|
||||
}
|
||||
}
|
||||
odePermuteColumn(rhs, w.perm, w.visited)
|
||||
base.SolveColumn(lu, rhs)
|
||||
copy(ks[s], rhs)
|
||||
}
|
||||
// The embedded pair: the b weights advance, the gap to the
|
||||
// embedded third-order solution estimates the local error.
|
||||
errNorm := 0.0
|
||||
for i := range n {
|
||||
e, advance := 0.0, 0.0
|
||||
for s := range 4 {
|
||||
e += rowE[s] * ks[s][i]
|
||||
advance += rowB[s] * ks[s][i]
|
||||
}
|
||||
yEnd[i] = y[i] + advance
|
||||
scale := absTol + relTol*math.Max(math.Abs(y[i]), math.Abs(yEnd[i]))
|
||||
ratio := e / scale
|
||||
errNorm += ratio * ratio
|
||||
}
|
||||
return yEnd, math.Sqrt(errNorm/float64(n)) + 1e-10, nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// 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 (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestROS4FixedStepOrder pins the fourth order of the scheme: on the
|
||||
// oscillator, whose exact rotation is known, uniform steps must shrink
|
||||
// the global error by roughly sixteen per halving. The convergence is
|
||||
// measured against the driven step, the way the order is defined.
|
||||
func TestROS4FixedStepOrder(t *testing.T) {
|
||||
oscillator := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
errAt := func(steps int) float64 {
|
||||
h := 1.0 / float64(steps)
|
||||
y := []float64{1, 0}
|
||||
now := 0.0
|
||||
w := &odeWork{}
|
||||
for range steps {
|
||||
yEnd, _, err := ros4Step("TestROS4FixedStepOrder", oscillator, w, now, y, h, 1e-300, 1e-300)
|
||||
if err != nil {
|
||||
t.Fatalf("ros4Step: %v", err)
|
||||
}
|
||||
copy(y, yEnd)
|
||||
now += h
|
||||
}
|
||||
return math.Max(math.Abs(y[0]-math.Cos(1)), math.Abs(y[1]+math.Sin(1)))
|
||||
}
|
||||
e8, e16, e32 := errAt(8), errAt(16), errAt(32)
|
||||
if e8 < 1e-13 {
|
||||
t.Skipf("error already at round-off (%v)", e8)
|
||||
}
|
||||
for _, r := range []float64{e8 / e16, e16 / e32} {
|
||||
if r < 12 || r > 20 {
|
||||
t.Fatalf("error ratio over a halved step = %.2g, want ≈ 16 for a fourth-order scheme", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestROS4NonAutonomousDegradation pins the documented limit the
|
||||
// autonomous fourth order carries with it: on y' = −y + t, whose exact
|
||||
// answer y = t − 1 + e^{−t} is known, the tableau's missing
|
||||
// time-derivative weights cost the second-order local terms and the
|
||||
// uniform-step ratios sit near 2, first order, not near 16. A change
|
||||
// that lifts this must move the doc comment with it.
|
||||
func TestROS4NonAutonomousDegradation(t *testing.T) {
|
||||
forced := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-y.FloatAt(0) + t}, 1)
|
||||
}
|
||||
errAt := func(steps int) float64 {
|
||||
h := 1.0 / float64(steps)
|
||||
y := []float64{0}
|
||||
now := 0.0
|
||||
w := &odeWork{}
|
||||
for range steps {
|
||||
yEnd, _, err := ros4Step("TestROS4NonAutonomousDegradation", forced, w, now, y, h, 1e-300, 1e-300)
|
||||
if err != nil {
|
||||
t.Fatalf("ros4Step: %v", err)
|
||||
}
|
||||
copy(y, yEnd)
|
||||
now += h
|
||||
}
|
||||
return math.Abs(y[0] - (1 - 1/math.E))
|
||||
}
|
||||
e8, e16, e32 := errAt(8), errAt(16), errAt(32)
|
||||
for _, r := range []float64{e8 / e16, e16 / e32} {
|
||||
if r >= 4 {
|
||||
t.Fatalf("error ratio over a halved step = %.2g, the forced system runs at first order (the doc comment names this limit)", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateROS4Quadrature pins exactness on y' = 1: the constant
|
||||
// right side is reproduced to rounding whatever the accepted steps do.
|
||||
func TestIntegrateROS4Quadrature(t *testing.T) {
|
||||
one := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{1}, 1)
|
||||
}
|
||||
end, err := IntegrateROS4(one, 0, 1, mustFloats(t, []float64{0}), ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateROS4: %v", err)
|
||||
}
|
||||
if math.Abs(end.FloatAt(0)-1) > 1e-12 {
|
||||
t.Fatalf("y(1) = %.16g, want 1 to rounding", end.FloatAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateROS4Accuracy checks the adaptive driver on the analytic
|
||||
// decay and over a full oscillator period with a two-dimensional state.
|
||||
func TestIntegrateROS4Accuracy(t *testing.T) {
|
||||
end, err := IntegrateROS4(decay, 0, 1, mustFloats(t, []float64{1}),
|
||||
ODEOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateROS4: %v", err)
|
||||
}
|
||||
if math.Abs(end.FloatAt(0)-math.Exp(-1)) > 1e-7 {
|
||||
t.Fatalf("y(1) = %.14g, want %.14g ± 1e-7", end.FloatAt(0), math.Exp(-1))
|
||||
}
|
||||
oscillator := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2)
|
||||
}
|
||||
full, err := IntegrateROS4(oscillator, 0, 2*math.Pi, mustFloats(t, []float64{1, 0}),
|
||||
ODEOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateROS4 oscillator: %v", err)
|
||||
}
|
||||
if math.Abs(full.FloatAt(0)-1) > 1e-4 || math.Abs(full.FloatAt(1)) > 1e-4 {
|
||||
t.Fatalf("full period = (%.10g, %.10g), want (1, 0)",
|
||||
full.FloatAt(0), full.FloatAt(1))
|
||||
}
|
||||
back, err := IntegrateROS4(decay, 1, 0, mustFloats(t, []float64{math.Exp(-1)}),
|
||||
ODEOptions{RelTol: 1e-8, AbsTol: 1e-12})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateROS4 backward: %v", err)
|
||||
}
|
||||
if math.Abs(back.FloatAt(0)-1) > 1e-7 {
|
||||
t.Fatalf("backward y(0) = %.14g, want 1 ± 1e-7", back.FloatAt(0))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateROS4LStability pins the L-stable damping: on y' =
|
||||
// −10^8(y − 1) and y' = −10^8 y the steps are far beyond the transient
|
||||
// and a non-L-stable scheme blows up, while the W scheme lands on the
|
||||
// forcing, respectively on zero, with a bounded step count.
|
||||
func TestIntegrateROS4LStability(t *testing.T) {
|
||||
rise := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-1e8 * (y.FloatAt(0) - 1)}, 1)
|
||||
}
|
||||
end, err := IntegrateROS4(rise, 0, 1, mustFloats(t, []float64{0}), ODEOptions{MaxSteps: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateROS4 stiff rise: %v", err)
|
||||
}
|
||||
if v := end.FloatAt(0); math.IsNaN(v) || math.Abs(v-1) > 1e-9 {
|
||||
t.Fatalf("stiff rise y(1) = %.14g, want 1", v)
|
||||
}
|
||||
decayStiff := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-1e8 * y.FloatAt(0)}, 1)
|
||||
}
|
||||
end, err = IntegrateROS4(decayStiff, 0, 1, mustFloats(t, []float64{1}), ODEOptions{MaxSteps: 1000})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateROS4 stiff decay: %v", err)
|
||||
}
|
||||
if v := end.FloatAt(0); math.IsNaN(v) || math.Abs(v) > 1e-9 {
|
||||
t.Fatalf("stiff decay y(1) = %.14g, want 0", v)
|
||||
}
|
||||
// The one-step amplification at a stiff eigenvalue must die out:
|
||||
// a giant step on the decay damps the state by orders of magnitude
|
||||
// instead of amplifying it.
|
||||
yEnd, _, err := ros4Step("TestIntegrateROS4LStability", decay, &odeWork{}, 0, []float64{1}, 1e6, 1e-3, 1e-3)
|
||||
if err != nil {
|
||||
t.Fatalf("ros4Step at z = 1e6: %v", err)
|
||||
}
|
||||
if math.Abs(yEnd[0]) > 1e-4 {
|
||||
t.Fatalf("one step at h·λ = 1e6 multiplied the state by %.3g, want heavy damping", yEnd[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateROS4VanDerPol integrates the Van der Pol oscillator in
|
||||
// the stiff relaxation regime: μ = 1000 carries a transient of width
|
||||
// 10^−3 under a slow motion, and the W scheme must cross it and follow
|
||||
// the slow branch inside the step budget.
|
||||
func TestIntegrateROS4VanDerPol(t *testing.T) {
|
||||
const mu = 1000.0
|
||||
vdp := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{
|
||||
y.FloatAt(1),
|
||||
mu*(1-y.FloatAt(0)*y.FloatAt(0))*y.FloatAt(1) - y.FloatAt(0),
|
||||
}, 2)
|
||||
}
|
||||
end, err := IntegrateROS4(vdp, 0, 2, mustFloats(t, []float64{2, 0}),
|
||||
ODEOptions{MaxSteps: 100000})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateROS4 Van der Pol: %v", err)
|
||||
}
|
||||
for i := range 2 {
|
||||
if math.IsNaN(end.FloatAt(i)) || math.IsInf(end.FloatAt(i), 0) {
|
||||
t.Fatalf("Van der Pol state[%d] = %g left the finite range", i, end.FloatAt(i))
|
||||
}
|
||||
}
|
||||
// The trajectory returns onto the slow branch near x = 2 with a
|
||||
// small velocity; anything else means the jump was not resolved.
|
||||
if math.Abs(end.FloatAt(0)-2) > 0.01 || math.Abs(end.FloatAt(1)) > 0.01 {
|
||||
t.Fatalf("Van der Pol end = (%.10g, %.10g), want the slow branch near (2, 0)",
|
||||
end.FloatAt(0), end.FloatAt(1))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateROS4Errors pins the error contract: a degenerate span
|
||||
// returns the initial state unchanged, a wrong-shaped f, a rank-2
|
||||
// state, an empty state, an exhausted step budget and an f that blows
|
||||
// up mid-span are errors.
|
||||
func TestIntegrateROS4Errors(t *testing.T) {
|
||||
y0 := mustFloats(t, []float64{1})
|
||||
same, err := IntegrateROS4(decay, 1, 1, y0, ODEOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("zero span: %v", err)
|
||||
}
|
||||
if math.Abs(same.FloatAt(0)-1) > 0 {
|
||||
t.Fatalf("zero span moved the state to %v", same.FloatAt(0))
|
||||
}
|
||||
wrongShape := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{1, 1}, 2)
|
||||
}
|
||||
if _, err := IntegrateROS4(wrongShape, 0, 1, y0, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error when f returns the wrong shape")
|
||||
}
|
||||
matrixState := mustFloats(t, []float64{1, 1}, 1, 2)
|
||||
if _, err := IntegrateROS4(decay, 0, 1, matrixState, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for a rank-2 state")
|
||||
}
|
||||
if _, err := IntegrateROS4(decay, 0, 1, mustFloats(t, nil), ODEOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an empty state")
|
||||
}
|
||||
if _, err := IntegrateROS4(decay, 0, 1, y0, ODEOptions{MaxSteps: 2}); err == nil {
|
||||
t.Fatal("expected an error for an exhausted step budget")
|
||||
} else if !strings.Contains(err.Error(), "MaxSteps=2") {
|
||||
t.Fatalf("want a step-budget error, got %v", err)
|
||||
}
|
||||
boom := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
if t > 0.5 {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
return core.MulF(y, -1), nil
|
||||
}
|
||||
if _, err := IntegrateROS4(boom, 0, 1, y0, ODEOptions{}); err == nil {
|
||||
t.Fatal("expected the operator error to propagate")
|
||||
}
|
||||
nan := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{math.NaN()}, 1)
|
||||
}
|
||||
if _, err := IntegrateROS4(nan, 0, 1, y0, ODEOptions{MaxSteps: 200}); err == nil {
|
||||
t.Fatal("expected an error when f returns NaN throughout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestROS4StepErrors pins the error paths of a single attempted step:
|
||||
// an f failing inside the numerical Jacobian and an f failing at the
|
||||
// later stage times are both fatal to the step.
|
||||
func TestROS4StepErrors(t *testing.T) {
|
||||
always := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
if _, _, err := ros4Step("TestROS4StepErrors", always, &odeWork{}, 0, []float64{1}, 0.1, 1e-6, 1e-9); err == nil {
|
||||
t.Fatal("expected the Jacobian's f error to propagate")
|
||||
}
|
||||
gated := func(t float64, y *core.Array) (*core.Array, error) {
|
||||
if t > 0 {
|
||||
return nil, base.Errf("detector tripped")
|
||||
}
|
||||
return core.FromFloats([]float64{0}, 1)
|
||||
}
|
||||
if _, _, err := ros4Step("TestROS4StepErrors", gated, &odeWork{}, 0, []float64{1}, 0.1, 1e-6, 1e-9); err == nil {
|
||||
t.Fatal("expected the stage f error to propagate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Turnkey PDE evolution in one space dimension: the two
|
||||
// equations half of physics reduces to, wrapped on machinery the
|
||||
// library already owns. The heat equation runs Crank-Nicolson (the
|
||||
// unconditionally stable trapezoidal rule) through the shared
|
||||
// tridiagonal solver; the wave equation runs velocity Verlet on the
|
||||
// second-order form, kick-drift-kick like the Hamiltonian integrator
|
||||
// it is. Both return the trajectory sampled on a time grid,
|
||||
// IntegrateODEPath-style.
|
||||
|
||||
// pdeValidate checks the shared input contract and returns the grid
|
||||
// size.
|
||||
func pdeValidate(name string, u0 *core.Array, dx, tFinal, dt float64, samples int) (int, error) {
|
||||
if u0.NDim() != 1 || u0.Len() == 0 {
|
||||
return 0, base.Errf("%s: the initial condition must be a non-empty rank-1 array, got shape %s",
|
||||
name, base.ShapeText(u0.Shape()))
|
||||
}
|
||||
if u0.Dtype() == core.Complex {
|
||||
return 0, base.Errf("%s: complex states are not supported", name)
|
||||
}
|
||||
if !(dx > 0) {
|
||||
return 0, base.Errf("%s: the grid spacing must be positive, got %g", name, dx)
|
||||
}
|
||||
if !(tFinal > 0) {
|
||||
return 0, base.Errf("%s: the integration time must be positive, got %g", name, tFinal)
|
||||
}
|
||||
if !(dt > 0) {
|
||||
return 0, base.Errf("%s: the time step must be positive, got %g", name, dt)
|
||||
}
|
||||
// A dt far below tFinal/1e12 cannot be honoured: the step count
|
||||
// would leave the int range on some platforms and wrap on others,
|
||||
// and the silently larger step would run past the wave equation's
|
||||
// CFL check, which runs on the requested dt.
|
||||
if tFinal/dt > 1e12 {
|
||||
return 0, base.Errf("%s: dt = %g asks for more than 1e12 steps over %g", name, dt, tFinal)
|
||||
}
|
||||
if samples < 2 {
|
||||
return 0, base.Errf("%s: at least two samples are needed, got %d", name, samples)
|
||||
}
|
||||
// A non-finite entry would flow through the stencil and the
|
||||
// tridiagonal solve's zero-pivot checks compare false against NaN,
|
||||
// publishing an all-NaN history with no error.
|
||||
for i := range u0.Len() {
|
||||
if v := u0.FloatAt(i); math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return 0, base.Errf("%s: the initial condition holds the non-finite value %g at %d", name, v, i)
|
||||
}
|
||||
}
|
||||
return u0.Len(), nil
|
||||
}
|
||||
|
||||
// pdeSchedule picks the step count and the actual step size for a
|
||||
// requested dt. The count is rounded up to a multiple of the sampling
|
||||
// interval, so every published time j·tFinal/(samples−1) is a step
|
||||
// boundary and the last step lands on tFinal exactly: the returned
|
||||
// samples are the evenly spaced interior states the documentation
|
||||
// promises, not the states at multiples of dt.
|
||||
func pdeSchedule(tFinal, dt float64, samples int) (steps int, h float64) {
|
||||
steps = max(int(math.Ceil(tFinal/dt)), samples-1)
|
||||
if rem := steps % (samples - 1); rem != 0 {
|
||||
steps += samples - 1 - rem
|
||||
}
|
||||
return steps, tFinal / float64(steps)
|
||||
}
|
||||
|
||||
// IntegrateHeat1D evolves u_t = κ·u_xx over [0, L] discretised by the
|
||||
// interior grid of u0 (n = u0.Len(), dx = L/(n+1)), from t = 0 to
|
||||
// tFinal in equal steps of at most dt, holding the boundary values
|
||||
// boundL and boundR (Dirichlet). It returns the (samples, n) array of
|
||||
// interior states evenly spaced in time, endpoints included. Crank-
|
||||
// Nicolson is stable for any dt; accuracy wants dt of a few dx²/κ.
|
||||
func IntegrateHeat1D(u0 *core.Array, kappa, dx, tFinal, dt float64, samples int, boundL, boundR float64) (*core.Array, error) {
|
||||
const name = "IntegrateHeat1D"
|
||||
n, err := pdeValidate(name, u0, dx, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !(kappa > 0) || math.IsInf(kappa, 0) {
|
||||
return nil, base.Errf("%s: the diffusivity must be positive, got %g", name, kappa)
|
||||
}
|
||||
// The boundary values enter the right side every step: a non-finite
|
||||
// one would flow through the stencil and the solve and publish an
|
||||
// all-NaN history with no error.
|
||||
if math.IsNaN(boundL) || math.IsInf(boundL, 0) || math.IsNaN(boundR) || math.IsInf(boundR, 0) {
|
||||
return nil, base.Errf("%s: the boundary values must be finite, got %g and %g", name, boundL, boundR)
|
||||
}
|
||||
u := make([]float64, n)
|
||||
copy(u, denseFloats(u0))
|
||||
// Crank-Nicolson: (I − r/2·A)uⁿ⁺¹ = (I + r/2·A)uⁿ with A the
|
||||
// second-difference stencil and r = κ·h/dx² for the step h the
|
||||
// schedule actually takes; the Dirichlet neighbours enter the right
|
||||
// side through the stencil ends.
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
r := kappa * h / (dx * dx)
|
||||
lower := make([]float64, n-1)
|
||||
diag := make([]float64, n)
|
||||
upper := make([]float64, n-1)
|
||||
// The left side carries I − r/2·A: the diagonal gains r (A's −2
|
||||
// times −r/2) and the off-diagonals stay −r/2. The system is
|
||||
// strictly diagonally dominant for every positive r, so the
|
||||
// elimination's pivots stay finite and non-zero.
|
||||
for i := range n {
|
||||
diag[i] = 1 + r
|
||||
if i < n-1 {
|
||||
lower[i] = -r / 2
|
||||
upper[i] = -r / 2
|
||||
}
|
||||
}
|
||||
|
||||
every := steps / (samples - 1)
|
||||
out := make([]float64, samples*n)
|
||||
copy(out, u)
|
||||
written := 1
|
||||
// The right side and the elimination scratch are constants of one
|
||||
// solve: every step refills the same buffers, the kernel reads its
|
||||
// inputs without touching them, and the solution is written
|
||||
// straight into the working state the samples copy from.
|
||||
var tri triScratch
|
||||
triSized(&tri, n, n)
|
||||
rhs := tri.rhs
|
||||
// The trapezoidal weight is a constant of the scheme: one division
|
||||
// by two, the same value the expression inside the loop carried.
|
||||
half := r / 2
|
||||
for s := 1; s <= steps; s++ {
|
||||
for i := range n {
|
||||
um, up := boundL, boundR
|
||||
if i > 0 {
|
||||
um = u[i-1]
|
||||
}
|
||||
if i < n-1 {
|
||||
up = u[i+1]
|
||||
}
|
||||
rhs[i] = u[i] + half*(um-2*u[i]+up)
|
||||
}
|
||||
// The implicit side's boundary neighbours move across as known
|
||||
// data: the first and last rows only, in that order.
|
||||
rhs[0] += half * boundL
|
||||
rhs[n-1] += half * boundR
|
||||
if err := base.TriSolve(u, tri.cp, tri.dp, lower, diag, upper, rhs); err != nil {
|
||||
return nil, base.Errf("%s: %w", name, err)
|
||||
}
|
||||
if s%every == 0 && written < samples {
|
||||
copy(out[written*n:(written+1)*n], u)
|
||||
written++
|
||||
}
|
||||
}
|
||||
// The final state is the last sample whatever the grid remainder.
|
||||
copy(out[(samples-1)*n:], u)
|
||||
return core.FromFloats(out, samples, n)
|
||||
}
|
||||
|
||||
// IntegrateWave1D evolves u_tt = c²·u_xx over [0, L] with the grid of
|
||||
// u0 (dx = L/(n+1), Dirichlet ends held at zero) and the initial
|
||||
// velocity v0, by velocity Verlet with fixed step dt. The CFL budget
|
||||
// |c·dt/dx| ≤ 1 is a genuine stability requirement and is enforced as
|
||||
// an error. The return contract mirrors IntegrateHeat1D.
|
||||
func IntegrateWave1D(u0, v0 *core.Array, c, dx, tFinal, dt float64, samples int) (*core.Array, error) {
|
||||
const name = "IntegrateWave1D"
|
||||
n, err := pdeValidate(name, u0, dx, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v0.NDim() != 1 || v0.Len() != n {
|
||||
return nil, base.Errf("%s: the initial velocity must match the state shape, got %s",
|
||||
name, base.ShapeText(v0.Shape()))
|
||||
}
|
||||
if v0.Dtype() == core.Complex {
|
||||
return nil, base.Errf("%s: complex velocities are not supported", name)
|
||||
}
|
||||
// As for u0: a non-finite velocity flows through the Verlet kick
|
||||
// and poisons the trajectory without an error.
|
||||
for i := range n {
|
||||
if v := v0.FloatAt(i); math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil, base.Errf("%s: the initial velocity holds the non-finite value %g at %d", name, v, i)
|
||||
}
|
||||
}
|
||||
// The CFL ratio compares false against 1 when it is NaN, so a
|
||||
// non-finite speed is refused before the budget test.
|
||||
if math.IsNaN(c) || math.IsInf(c, 0) {
|
||||
return nil, base.Errf("%s: the wave speed must be finite, got %g", name, c)
|
||||
}
|
||||
cfl := math.Abs(c * dt / dx)
|
||||
if cfl > 1 {
|
||||
return nil, base.Errf("%s: CFL violated, |c·dt/dx| = %.3g > 1", name, cfl)
|
||||
}
|
||||
u := make([]float64, n)
|
||||
v := make([]float64, n)
|
||||
copy(u, denseFloats(u0))
|
||||
copy(v, denseFloats(v0))
|
||||
// The stencil's constants: the products are the ones the element
|
||||
// loop evaluated, built once per run.
|
||||
cc := c * c
|
||||
dx2 := dx * dx
|
||||
accel := func(dst, us []float64) {
|
||||
// The two ends take the fixed zero neighbour the boundaries
|
||||
// impose; the interior runs the same stencil over the real
|
||||
// neighbours, so the two tests leave the element loop. Every
|
||||
// term keeps the order the uniform loop evaluated.
|
||||
end := func(i int) {
|
||||
um, up := 0.0, 0.0
|
||||
if i > 0 {
|
||||
um = us[i-1]
|
||||
}
|
||||
if i < n-1 {
|
||||
up = us[i+1]
|
||||
}
|
||||
dst[i] = cc * (um - 2*us[i] + up) / dx2
|
||||
}
|
||||
end(0)
|
||||
for i := 1; i < n-1; i++ {
|
||||
dst[i] = cc * (us[i-1] - 2*us[i] + us[i+1]) / dx2
|
||||
}
|
||||
if n > 1 {
|
||||
end(n - 1)
|
||||
}
|
||||
}
|
||||
// Kick-drift-kick: the modified energy stays within (c·dt/dx)²/8
|
||||
// of the true one, which is why the wave equation keeps its shape.
|
||||
// The schedule's step never exceeds dt, so the CFL check above
|
||||
// bounds this one too.
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
every := steps / (samples - 1)
|
||||
out := make([]float64, samples*n)
|
||||
copy(out, u)
|
||||
written := 1
|
||||
a := make([]float64, n)
|
||||
for s := 1; s <= steps; s++ {
|
||||
accel(a, u)
|
||||
for i := range n {
|
||||
v[i] += 0.5 * h * a[i]
|
||||
}
|
||||
for i := range n {
|
||||
u[i] += h * v[i]
|
||||
}
|
||||
accel(a, u)
|
||||
for i := range n {
|
||||
v[i] += 0.5 * h * a[i]
|
||||
}
|
||||
if s%every == 0 && written < samples {
|
||||
copy(out[written*n:(written+1)*n], u)
|
||||
written++
|
||||
}
|
||||
}
|
||||
copy(out[(samples-1)*n:], u)
|
||||
return core.FromFloats(out, samples, n)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/engine"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Two-dimensional evolution equations on the rectangle, the
|
||||
// higher-rank siblings of IntegrateHeat1D and IntegrateWave1D. The
|
||||
// grid is the rank-2 shape of the initial state: row r samples
|
||||
// y = r·dy, column c samples x = c·dx, the boundary ring is held
|
||||
// fixed, and the interior carries the dynamics.
|
||||
|
||||
// pde2dValidate checks the shared rectangle arguments and returns the
|
||||
// grid shape.
|
||||
func pde2dValidate(name string, u0 *core.Array, dx, dy, tFinal, dt float64, samples int) (rows, cols int, err error) {
|
||||
if u0.NDim() != 2 {
|
||||
return 0, 0, base.Errf("%s: the initial state must be rank 2, got shape %s", name, base.ShapeText(u0.Shape()))
|
||||
}
|
||||
if u0.Dtype() == core.Complex {
|
||||
return 0, 0, base.Errf("%s: complex initial states are not supported", name)
|
||||
}
|
||||
rows, cols = u0.Shape()[0], u0.Shape()[1]
|
||||
if rows < 3 || cols < 3 {
|
||||
return 0, 0, base.Errf("%s: the grid must be at least 3×3 to hold interior points, got %d×%d", name, rows, cols)
|
||||
}
|
||||
if !(dx > 0) || !(dy > 0) {
|
||||
return 0, 0, base.Errf("%s: the spacings must be positive, got %g and %g", name, dx, dy)
|
||||
}
|
||||
if !(tFinal > 0) || !(dt > 0) {
|
||||
return 0, 0, base.Errf("%s: tFinal and dt must be positive, got %g and %g", name, tFinal, dt)
|
||||
}
|
||||
// As in the 1-D validation: a dt this far below tFinal cannot be
|
||||
// honoured, and the wrapped step count would disable the bound
|
||||
// silently.
|
||||
if tFinal/dt > 1e12 {
|
||||
return 0, 0, base.Errf("%s: dt = %g asks for more than 1e12 steps over %g", name, dt, tFinal)
|
||||
}
|
||||
if samples < 2 {
|
||||
return 0, 0, base.Errf("%s: at least two samples are needed, got %d", name, samples)
|
||||
}
|
||||
// A non-finite entry flows through the stencils and the solvers'
|
||||
// zero-pivot guards compare false against NaN, so it is refused
|
||||
// up front.
|
||||
for i := range u0.Len() {
|
||||
if v := u0.FloatAt(i); math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return 0, 0, base.Errf("%s: the initial state holds the non-finite value %g at %d", name, v, i)
|
||||
}
|
||||
}
|
||||
return rows, cols, nil
|
||||
}
|
||||
|
||||
// fromSlice wraps a float slice of known length as an array.
|
||||
func fromSlice(vals []float64, n int) *core.Array {
|
||||
out := core.New(core.Float, n)
|
||||
copy(out.RawFloats(), vals[:n])
|
||||
return out
|
||||
}
|
||||
|
||||
// IntegrateHeat2D evolves u_t = κ·Δu on the rectangle by
|
||||
// Peaceman-Rachford alternating direction implicit steps: one
|
||||
// half-step implicit in x against an explicit y neighbour sum, one
|
||||
// implicit in y against an explicit x neighbour sum, each row and
|
||||
// column a tridiagonal solve through SolveTridiagonal. The scheme is
|
||||
// second order in space and time and unconditionally stable, so no
|
||||
// step-size refusal stands between the caller and a coarse first
|
||||
// look. The boundary ring is held at the four constant edge values.
|
||||
// The return is a (samples × rows × cols) array: the initial state,
|
||||
// then the state after each stored interval, the final state forced
|
||||
// into the last sample.
|
||||
func IntegrateHeat2D(u0 *core.Array, kappa, dx, dy, tFinal, dt float64, samples int,
|
||||
boundBottom, boundTop, boundLeft, boundRight float64) (*core.Array, error) {
|
||||
const name = "IntegrateHeat2D"
|
||||
rows, cols, err := pde2dValidate(name, u0, dx, dy, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !(kappa > 0) || math.IsInf(kappa, 0) {
|
||||
return nil, base.Errf("%s: the diffusivity must be positive, got %g", name, kappa)
|
||||
}
|
||||
// The four boundary constants enter the explicit neighbour sums
|
||||
// every step: a non-finite one would flow through the stencils and
|
||||
// the solves and publish an all-NaN history with no error.
|
||||
if math.IsNaN(boundBottom) || math.IsInf(boundBottom, 0) ||
|
||||
math.IsNaN(boundTop) || math.IsInf(boundTop, 0) ||
|
||||
math.IsNaN(boundLeft) || math.IsInf(boundLeft, 0) ||
|
||||
math.IsNaN(boundRight) || math.IsInf(boundRight, 0) {
|
||||
return nil, base.Errf("%s: the boundary values must be finite, got bottom %g, top %g, left %g, right %g",
|
||||
name, boundBottom, boundTop, boundLeft, boundRight)
|
||||
}
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
rx := kappa * h / (2 * dx * dx) // the implicit half-step weight in x
|
||||
ry := kappa * h / (2 * dy * dy) // ...and in y
|
||||
|
||||
u := make([]float64, rows*cols)
|
||||
copy(u, denseFloats(u0))
|
||||
every := steps / (samples - 1)
|
||||
out := core.New(core.Float, samples, rows, cols)
|
||||
into := out.RawFloats()
|
||||
// Sample 0 is the initial state exactly, before any ring
|
||||
// enforcement touches the working state.
|
||||
copy(into, u)
|
||||
written := 1
|
||||
// The working state's horizontal ring rows carry the boundary
|
||||
// constants from here on, so the stencils read their up and down
|
||||
// neighbours straight out of the payload: a ring slot holds exactly
|
||||
// the value the boundary test it replaces would have substituted.
|
||||
// The step loop below rewrites both rows after every half-step,
|
||||
// so the invariant holds for every step, the first included.
|
||||
for c := range cols {
|
||||
u[c] = boundBottom
|
||||
u[(rows-1)*cols+c] = boundTop
|
||||
}
|
||||
|
||||
star := make([]float64, rows*cols)
|
||||
var stepErr error
|
||||
var errMu sync.Mutex
|
||||
// The implicit diagonals are constants of the scheme, one set per
|
||||
// orientation, built once with exactly the values the solves read.
|
||||
lowerX := make([]float64, cols-3)
|
||||
upperX := make([]float64, cols-3)
|
||||
diagX := make([]float64, cols-2)
|
||||
for i := range lowerX {
|
||||
lowerX[i] = -rx
|
||||
upperX[i] = -rx
|
||||
}
|
||||
for i := range diagX {
|
||||
diagX[i] = 1 + 2*rx
|
||||
}
|
||||
lowerY := make([]float64, rows-3)
|
||||
upperY := make([]float64, rows-3)
|
||||
diagY := make([]float64, rows-2)
|
||||
for i := range lowerY {
|
||||
lowerY[i] = -ry
|
||||
upperY[i] = -ry
|
||||
}
|
||||
for i := range diagY {
|
||||
diagY[i] = 1 + 2*ry
|
||||
}
|
||||
// Per-solve line scratch, one set per pdeStepFloor lines of each
|
||||
// orientation. engine.ParallelMin invokes a sweep closure once per
|
||||
// chunk; in parallel the chunk starts differ by at least
|
||||
// pdeStepFloor, so two concurrent chunks never share a set, and a
|
||||
// sweep the engine runs inline reuses set zero serially.
|
||||
triX := make([]triScratch, (rows-2-1)/pdeStepFloor+1)
|
||||
triY := make([]triScratch, (cols-2-1)/pdeStepFloor+1)
|
||||
for i := range triX {
|
||||
triSized(&triX[i], cols-2, cols)
|
||||
}
|
||||
for i := range triY {
|
||||
triSized(&triY[i], rows-2, rows)
|
||||
}
|
||||
for s := 1; s <= steps; s++ {
|
||||
// Half-step one: implicit in x along every interior row. Rows are
|
||||
// independent (a row's solve reads its own and its neighbours'
|
||||
// values and writes the star row), so the sweep is partitioned
|
||||
// over them; each chunk carries its own right-hand side and
|
||||
// solve scratch, and the arithmetic of a row is untouched.
|
||||
engine.ParallelMin(rows-2, pdeStepFloor, func(rs, re int) {
|
||||
tri := &triX[rs/pdeStepFloor]
|
||||
rhs := tri.rhs
|
||||
for r := rs + 1; r < re+1; r++ {
|
||||
// The row's own values and its two neighbours are slices
|
||||
// of the payload, so the stencil reads them by offset
|
||||
// instead of rebuilding the flat index per element. The
|
||||
// horizontal ring rows hold the boundary constants, so
|
||||
// the neighbours need no edge test.
|
||||
row := u[r*cols : (r+1)*cols]
|
||||
up := u[(r+1)*cols : (r+2)*cols]
|
||||
down := u[(r-1)*cols : r*cols]
|
||||
for c := range cols {
|
||||
rhs[c] = row[c] + ry*(up[c]-2*row[c]+down[c])
|
||||
}
|
||||
// Boundary neighbours of the implicit solve: the row's
|
||||
// own left and right ring values enter the right side.
|
||||
rhs[1] += rx * boundLeft
|
||||
rhs[cols-2] += rx * boundRight
|
||||
// The elimination writes the row's interior straight
|
||||
// into the star state; the ring values follow.
|
||||
dst := star[r*cols+1 : r*cols+cols-1]
|
||||
if serr := base.TriSolve(dst, tri.cp, tri.dp, lowerX, diagX, upperX, rhs[1:cols-1]); serr != nil {
|
||||
errMu.Lock()
|
||||
if stepErr == nil {
|
||||
stepErr = base.Errf("%s: %w", name, serr)
|
||||
}
|
||||
errMu.Unlock()
|
||||
return
|
||||
}
|
||||
star[r*cols] = boundLeft
|
||||
star[r*cols+cols-1] = boundRight
|
||||
}
|
||||
})
|
||||
if stepErr != nil {
|
||||
return nil, stepErr
|
||||
}
|
||||
for c := range cols {
|
||||
star[c] = boundBottom
|
||||
star[(rows-1)*cols+c] = boundTop
|
||||
}
|
||||
// Half-step two: implicit in y down every interior column,
|
||||
// with the explicit x neighbours from the star state. Columns are
|
||||
// independent the same way the rows were, and the walk takes them
|
||||
// two at a time: the star state's vertical ring columns hold the
|
||||
// boundary constants, so both lanes read their neighbours
|
||||
// straight out of the payload, and a pair shares its loads, so
|
||||
// each line of the state is fetched once per pair instead of
|
||||
// three times across three column passes.
|
||||
engine.ParallelMin(cols-2, pdeStepFloor, func(cs, ce int) {
|
||||
tri := &triY[cs/pdeStepFloor]
|
||||
rhs := tri.rhs
|
||||
aux := tri.aux
|
||||
dst := tri.dst
|
||||
for c := cs + 1; c < ce+1; c += 2 {
|
||||
if c+1 < ce+1 {
|
||||
// Columns c and c+1: one pass over the rows builds
|
||||
// both right sides, the shared neighbour loads in
|
||||
// registers.
|
||||
for r := range rows {
|
||||
base := r * cols
|
||||
l := star[base+c-1]
|
||||
wm := star[base+c]
|
||||
e := star[base+c+1]
|
||||
rhs[r] = wm + rx*(e-2*wm+l)
|
||||
aux[r] = e + rx*(star[base+c+2]-2*e+wm)
|
||||
}
|
||||
rhs[1] += ry * boundBottom
|
||||
rhs[rows-2] += ry * boundTop
|
||||
if serr := base.TriSolve(dst, tri.cp, tri.dp, lowerY, diagY, upperY, rhs[1:rows-1]); serr != nil {
|
||||
errMu.Lock()
|
||||
if stepErr == nil {
|
||||
stepErr = base.Errf("%s: %w", name, serr)
|
||||
}
|
||||
errMu.Unlock()
|
||||
return
|
||||
}
|
||||
u[c] = boundBottom
|
||||
u[(rows-1)*cols+c] = boundTop
|
||||
// The column's interior comes straight out of the
|
||||
// solve's payload: the values are the ones the
|
||||
// accessor read.
|
||||
for r := 1; r < rows-1; r++ {
|
||||
u[r*cols+c] = dst[r-1]
|
||||
}
|
||||
aux[1] += ry * boundBottom
|
||||
aux[rows-2] += ry * boundTop
|
||||
if serr := base.TriSolve(dst, tri.cp, tri.dp, lowerY, diagY, upperY, aux[1:rows-1]); serr != nil {
|
||||
errMu.Lock()
|
||||
if stepErr == nil {
|
||||
stepErr = base.Errf("%s: %w", name, serr)
|
||||
}
|
||||
errMu.Unlock()
|
||||
return
|
||||
}
|
||||
u[c+1] = boundBottom
|
||||
u[(rows-1)*cols+c+1] = boundTop
|
||||
for r := 1; r < rows-1; r++ {
|
||||
u[r*cols+c+1] = dst[r-1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
// The odd tail column, when the chunk ends on one.
|
||||
for r := range rows {
|
||||
base := r * cols
|
||||
wm := star[base+c]
|
||||
rhs[r] = wm + rx*(star[base+c+1]-2*wm+star[base+c-1])
|
||||
}
|
||||
rhs[1] += ry * boundBottom
|
||||
rhs[rows-2] += ry * boundTop
|
||||
if serr := base.TriSolve(dst, tri.cp, tri.dp, lowerY, diagY, upperY, rhs[1:rows-1]); serr != nil {
|
||||
errMu.Lock()
|
||||
if stepErr == nil {
|
||||
stepErr = base.Errf("%s: %w", name, serr)
|
||||
}
|
||||
errMu.Unlock()
|
||||
return
|
||||
}
|
||||
u[c] = boundBottom
|
||||
u[(rows-1)*cols+c] = boundTop
|
||||
for r := 1; r < rows-1; r++ {
|
||||
u[r*cols+c] = dst[r-1]
|
||||
}
|
||||
}
|
||||
})
|
||||
if stepErr != nil {
|
||||
return nil, stepErr
|
||||
}
|
||||
for r := range rows {
|
||||
u[r*cols] = boundLeft
|
||||
u[r*cols+cols-1] = boundRight
|
||||
}
|
||||
if s%every == 0 && written < samples {
|
||||
copy(into[written*rows*cols:(written+1)*rows*cols], u)
|
||||
written++
|
||||
}
|
||||
}
|
||||
copy(into[(samples-1)*rows*cols:], u)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IntegrateWave2D evolves u_tt = c²·Δu on the rectangle with the
|
||||
// boundary ring held at zero, by the explicit central-difference
|
||||
// stencil from the velocity Verlet family the 1-D wave solver uses:
|
||||
// second order in space and time, with the CFL budget
|
||||
// c·dt·sqrt(1/dx² + 1/dy²) ≤ 1 enforced as an error, because the
|
||||
// explicit stencil has no honest answer past it. The return contract
|
||||
// mirrors IntegrateHeat2D.
|
||||
func IntegrateWave2D(u0, v0 *core.Array, c, dx, dy, tFinal, dt float64, samples int) (*core.Array, error) {
|
||||
const name = "IntegrateWave2D"
|
||||
rows, cols, err := pde2dValidate(name, u0, dx, dy, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v0.NDim() != 2 || v0.Shape()[0] != rows || v0.Shape()[1] != cols {
|
||||
return nil, base.Errf("%s: the velocity must be rank 2 on the same grid, got shape %s", name, base.ShapeText(v0.Shape()))
|
||||
}
|
||||
if v0.Dtype() == core.Complex {
|
||||
return nil, base.Errf("%s: complex velocities are not supported", name)
|
||||
}
|
||||
// As for u0: a non-finite velocity enters the Taylor start and
|
||||
// poisons the three-level stencil without an error.
|
||||
for i := range v0.Len() {
|
||||
if v := v0.FloatAt(i); math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil, base.Errf("%s: the velocity holds the non-finite value %g at %d", name, v, i)
|
||||
}
|
||||
}
|
||||
if !(c > 0) {
|
||||
return nil, base.Errf("%s: the wave speed must be positive, got %g", name, c)
|
||||
}
|
||||
cfl := c * dt * math.Sqrt(1/(dx*dx)+1/(dy*dy))
|
||||
if cfl > 1 {
|
||||
return nil, base.Errf("%s: the CFL number %g exceeds 1 (c = %g, dt = %g, dx = %g, dy = %g); the explicit stencil is unstable there",
|
||||
name, cfl, c, dt, dx, dy)
|
||||
}
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
lapW := (c * h) * (c * h)
|
||||
// The stencil's spacings are constants of the grid; squaring them
|
||||
// once per step is the same product the element loop evaluated.
|
||||
dx2 := dx * dx
|
||||
dy2 := dy * dy
|
||||
|
||||
prev := make([]float64, rows*cols) // u^{s-1}
|
||||
cur := make([]float64, rows*cols) // u^{s}
|
||||
next := make([]float64, rows*cols) // u^{s+1}, the buffer being written
|
||||
copy(cur, denseFloats(u0))
|
||||
every := steps / (samples - 1)
|
||||
out := core.New(core.Float, samples, rows, cols)
|
||||
into := out.RawFloats()
|
||||
// Slot 0 is the initial state at t = 0; slot j is the state after
|
||||
// j·every steps, at the published time j·tFinal/(samples-1), exactly
|
||||
// like IntegrateHeat2D.
|
||||
copy(into, cur)
|
||||
// From there on the boundary ring is held at zero, so clear it in the
|
||||
// working buffer the initial state came in (the other two are born
|
||||
// zeroed): otherwise a recycled buffer would carry the caller's ring
|
||||
// back into the stencil every third step.
|
||||
for r := range rows {
|
||||
cur[r*cols], cur[r*cols+cols-1] = 0, 0
|
||||
}
|
||||
for c := range cols {
|
||||
cur[c], cur[(rows-1)*cols+c] = 0, 0
|
||||
}
|
||||
written := 1
|
||||
// The three buffers cycle through (prev, cur, next), so every stencil
|
||||
// reads u^{s-1} and u^s while writing u^{s+1} into a third buffer: no
|
||||
// point is ever overwritten before its neighbours have read it. The
|
||||
// first iteration is the Taylor start
|
||||
// u¹ = u⁰ + h·v⁰ + h²c²/2·Δu⁰, second order, so the three-level
|
||||
// stencil starts honest, and it reads the untouched u⁰ (the initial
|
||||
// velocity and the Laplacian of u⁰), not a half-updated state.
|
||||
for s := 1; s <= steps; s++ {
|
||||
first := s == 1
|
||||
for r := 1; r < rows-1; r++ {
|
||||
// The row's neighbours are the slices above and below it, so
|
||||
// the stencil reads them by offset instead of rebuilding the
|
||||
// flat index per element. The terms keep their order.
|
||||
mid := cur[r*cols : (r+1)*cols]
|
||||
up := cur[(r+1)*cols : (r+2)*cols]
|
||||
down := cur[(r-1)*cols : r*cols]
|
||||
if first {
|
||||
// The Taylor start reads the untouched u⁰ and its
|
||||
// initial velocity, not a half-updated state.
|
||||
for cc := 1; cc < cols-1; cc++ {
|
||||
i := r*cols + cc
|
||||
lap := (down[cc]-2*mid[cc]+up[cc])/dy2 +
|
||||
(mid[cc-1]-2*mid[cc]+mid[cc+1])/dx2
|
||||
next[i] = mid[cc] + h*v0.FloatAt(i) + 0.5*lapW*lap
|
||||
}
|
||||
continue
|
||||
}
|
||||
for cc := 1; cc < cols-1; cc++ {
|
||||
i := r*cols + cc
|
||||
lap := (down[cc]-2*mid[cc]+up[cc])/dy2 +
|
||||
(mid[cc-1]-2*mid[cc]+mid[cc+1])/dx2
|
||||
next[i] = 2*mid[cc] - prev[i] + lapW*lap
|
||||
}
|
||||
}
|
||||
prev, cur, next = cur, next, prev
|
||||
if s%every == 0 && written < samples {
|
||||
copy(into[written*rows*cols:(written+1)*rows*cols], cur)
|
||||
written++
|
||||
}
|
||||
}
|
||||
copy(into[(samples-1)*rows*cols:], cur)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pdeStepFloor is the per-worker floor for a 2-D step sweep. One item is
|
||||
// one tridiagonal elimination over a stencil row, measured at roughly
|
||||
// half a microsecond on a 32-line grid, so a worker needs about eight
|
||||
// of them before the split pays for its spawn. The floor is a constant
|
||||
// for that reason, not a length-scaled budget. The line scratch is
|
||||
// indexed by start/pdeStepFloor on the same constant: parallel chunk
|
||||
// starts differ by at least the floor, so concurrent chunks never share
|
||||
// a scratch set.
|
||||
const pdeStepFloor = 8
|
||||
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// heatMode builds sin(πx)·sin(πy) on an (n+2)×(n+2) grid over [0,1]²
|
||||
// including the zero boundary ring, the lowest interior mode.
|
||||
func heatMode(t *testing.T, n int) *core.Array {
|
||||
t.Helper()
|
||||
vals := make([]float64, (n+2)*(n+2))
|
||||
for r := range n + 2 {
|
||||
for c := range n + 2 {
|
||||
vals[r*(n+2)+c] = math.Sin(math.Pi*float64(c)/float64(n+1)) *
|
||||
math.Sin(math.Pi*float64(r)/float64(n+1))
|
||||
}
|
||||
}
|
||||
a, err := core.FromFloats(vals, n+2, n+2)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// TestIntegrateHeat2DModeDecay checks the ADI solver on the lowest
|
||||
// mode: with zero boundaries the amplitude decays like
|
||||
// exp(−κ·2π²·t), and the scheme's O(dt²+h²) error must stay inside a
|
||||
// one percent band on a 32-interior grid.
|
||||
func TestIntegrateHeat2DModeDecay(t *testing.T) {
|
||||
const n = 32
|
||||
u0 := heatMode(t, n)
|
||||
const kappa, dt, tFinal = 1.0, 0.005, 0.1
|
||||
history, err := IntegrateHeat2D(u0, kappa, 1.0/float64(n+1), 1.0/float64(n+1),
|
||||
tFinal, dt, 2, 0, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat2D: %v", err)
|
||||
}
|
||||
if history.Shape()[0] != 2 || history.Shape()[1] != n+2 {
|
||||
t.Fatalf("shape %v, want [%d %d %d]", history.Shape(), 2, n+2, n+2)
|
||||
}
|
||||
final := history.Shape()[0] - 1
|
||||
// The interior peak of the final state versus the exact decay.
|
||||
peak := 0.0
|
||||
for r := 1; r <= n; r++ {
|
||||
for c := 1; c <= n; c++ {
|
||||
if v := history.FloatAt(final*(n+2)*(n+2) + r*(n+2) + c); v > peak {
|
||||
peak = v
|
||||
}
|
||||
}
|
||||
}
|
||||
want := math.Exp(-kappa * 2 * math.Pi * math.Pi * tFinal)
|
||||
if math.Abs(peak-want) > 0.01 {
|
||||
t.Fatalf("final peak %.5g, want %.5g", peak, want)
|
||||
}
|
||||
// The boundary ring is held at zero.
|
||||
for c := range n + 2 {
|
||||
if history.FloatAt(final*(n+2)*(n+2)+c) != 0 ||
|
||||
history.FloatAt(final*(n+2)*(n+2)+(n+1)*(n+2)+c) != 0 {
|
||||
t.Fatalf("boundary ring moved at column %d", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateWave2DStandingWave checks the explicit solver on the
|
||||
// lowest standing mode with zero initial velocity against the closed
|
||||
// form of the leapfrog itself. The mode is an exact eigenfunction of the
|
||||
// five-point Laplacian, with eigenvalue mu, and the discrete
|
||||
// characteristic of the scheme is cos theta = 1 - (c*h)²·mu/2, so after
|
||||
// s steps the state is cos(s·theta)·u0. The run below takes 400 steps of
|
||||
// period/400, so its amplitude is cos(400·theta) = -0.4155, not 1: the
|
||||
// mode has not returned to its start at this time.
|
||||
func TestIntegrateWave2DStandingWave(t *testing.T) {
|
||||
const n = 32
|
||||
u0 := heatMode(t, n) // sin(πx)sin(πy) with zero boundary ring
|
||||
v0 := core.New(core.Float, n+2, n+2)
|
||||
const c = 1.0
|
||||
dx := 1.0 / float64(n+1)
|
||||
period := math.Sqrt2 / (c * math.Pi)
|
||||
const steps = 400 // samples = 2 and dt = period/400 force this many steps
|
||||
dt := period / float64(steps)
|
||||
history, err := IntegrateWave2D(u0, v0, c, dx, dx, period, dt, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
h := period / float64(steps)
|
||||
// The discrete eigenvalue of the (1,1) mode, mu = 8/dx²·sin²(π·dx/2)
|
||||
// on this square grid, and the phase 400 steps accumulate.
|
||||
sine := math.Sin(math.Pi * dx / 2)
|
||||
mu := 8 / (dx * dx) * sine * sine
|
||||
amp := math.Cos(float64(steps) * math.Acos(1-0.5*(c*h)*(c*h)*mu))
|
||||
final := history.Shape()[0] - 1
|
||||
worst := 0.0
|
||||
for r := range n + 2 {
|
||||
for cc := range n + 2 {
|
||||
i := final*(n+2)*(n+2) + r*(n+2) + cc
|
||||
if e := math.Abs(history.FloatAt(i) - amp*u0.FloatAt(r*(n+2)+cc)); e > worst {
|
||||
worst = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst > 1e-11 {
|
||||
t.Fatalf("after %.0f steps the worst deviation from cos(%.6f)·u0 is %.4g, want the leapfrog characteristic",
|
||||
float64(steps), amp, worst)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateWave2DCFLRefusal checks the stability budget: a step
|
||||
// past the CFL limit is an error, not a silent blow-up.
|
||||
func TestIntegrateWave2DCFLRefusal(t *testing.T) {
|
||||
const n = 32
|
||||
u0 := heatMode(t, n)
|
||||
v0 := core.New(core.Float, n+2, n+2)
|
||||
dx := 1.0 / float64(n+1)
|
||||
// c·dt·sqrt(1/dx²+1/dy²) = 1·0.05·45.25 ≈ 2.26 > 1.
|
||||
if _, err := IntegrateWave2D(u0, v0, 1, dx, dx, 0.05, 0.05, 2); err == nil {
|
||||
t.Fatal("a CFL-violating step accepted")
|
||||
}
|
||||
if _, err := IntegrateWave2D(u0, core.New(core.Float, 3, 3), 1, dx, dx, 0.05, 0.001, 2); err == nil {
|
||||
t.Fatal("mismatched velocity grid accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// pde2dAnisoMode builds the (p, q) discrete Dirichlet eigenmode of the
|
||||
// five-point Laplacian on a rows×cols grid: sin(π·p·c/(cols−1)) ·
|
||||
// sin(π·q·r/(rows−1)), which vanishes on all four boundary lines.
|
||||
func pde2dAnisoMode(t *testing.T, rows, cols, p, q int) *core.Array {
|
||||
t.Helper()
|
||||
vals := make([]float64, rows*cols)
|
||||
for r := range rows {
|
||||
for c := range cols {
|
||||
vals[r*cols+c] = math.Sin(math.Pi*float64(p)*float64(c)/float64(cols-1)) *
|
||||
math.Sin(math.Pi*float64(q)*float64(r)/float64(rows-1))
|
||||
}
|
||||
}
|
||||
a, err := core.FromFloats(vals, rows, cols)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// anisoMu returns the dimensionless eigenvalues of the undivided second
|
||||
// difference along each axis for the (p, q) mode above.
|
||||
func anisoMu(rows, cols, p, q int) (mx, my float64) {
|
||||
return 4 * math.Pow(math.Sin(math.Pi*float64(p)/2/float64(cols-1)), 2),
|
||||
4 * math.Pow(math.Sin(math.Pi*float64(q)/2/float64(rows-1)), 2)
|
||||
}
|
||||
|
||||
// TestIntegrateWave2DAnisotropicGrid pins the explicit solver on a grid
|
||||
// whose spacings differ between the axes, the case the square-grid tests
|
||||
// cannot see. The (1, 2) mode is an exact eigenfunction of the
|
||||
// five-point Laplacian with eigenvalue λx + λy, where λx carries dx and
|
||||
// λy carries dy, so the leapfrog state after s steps is cos(s·θ)·u0 with
|
||||
// cos θ = 1 − (c·h)²·(λx + λy)/2. A stencil that divides the y
|
||||
// neighbours by dx² instead of dy², or the reverse, moves those
|
||||
// eigenvalues and the amplitude with them.
|
||||
func TestIntegrateWave2DAnisotropicGrid(t *testing.T) {
|
||||
const (
|
||||
rows, cols = 26, 34
|
||||
dx, dy = 0.02, 0.05
|
||||
c = 1.0
|
||||
steps = 60
|
||||
)
|
||||
if dx == dy {
|
||||
t.Fatal("the case needs spacings that differ between the axes")
|
||||
}
|
||||
mx, my := anisoMu(rows, cols, 1, 2)
|
||||
lx, ly := mx/(dx*dx), my/(dy*dy)
|
||||
dt := 0.5 / (c * math.Sqrt(1/(dx*dx)+1/(dy*dy))) // CFL = 1/2
|
||||
tFinal := dt * float64(steps)
|
||||
u0 := pde2dAnisoMode(t, rows, cols, 1, 2)
|
||||
v0 := core.New(core.Float, rows, cols)
|
||||
history, err := IntegrateWave2D(u0, v0, c, dx, dy, tFinal, dt, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
h := tFinal / float64(steps)
|
||||
amp := math.Cos(float64(steps) * math.Acos(1-0.5*(c*h)*(c*h)*(lx+ly)))
|
||||
if math.Abs(amp) < 0.1 {
|
||||
t.Fatalf("the run decays to %.3g; the case needs an amplitude the comparison can see", amp)
|
||||
}
|
||||
final := history.Shape()[0] - 1
|
||||
worst := 0.0
|
||||
for i := range rows * cols {
|
||||
worst = math.Max(worst, math.Abs(history.FloatAt(final*rows*cols+i)-amp*u0.FloatAt(i)))
|
||||
}
|
||||
if worst > 1e-11 {
|
||||
t.Fatalf("after %d steps the worst deviation from cos(θ·%d)·u0 is %.4g (amplitude %.4g), want the leapfrog characteristic",
|
||||
steps, steps, worst, amp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateHeat2DAnisotropicGrid pins the ADI solver the same way:
|
||||
// on an eigenmode the two half steps compose into one amplification
|
||||
// factor per step, (1 − rx·μx)(1 − ry·μy)/((1 + rx·μx)(1 + ry·μy)),
|
||||
// with rx = κ·h/(2·dx²) and ry = κ·h/(2·dy²) against the dimensionless
|
||||
// second-difference eigenvalues. Exchanging the two spacings moves the
|
||||
// factor by orders of magnitude, so a mislabelled axis cannot pass.
|
||||
func TestIntegrateHeat2DAnisotropicGrid(t *testing.T) {
|
||||
const (
|
||||
rows, cols = 26, 34
|
||||
dx, dy = 0.02, 0.05
|
||||
kappa = 1.0
|
||||
steps = 10
|
||||
)
|
||||
if dx == dy {
|
||||
t.Fatal("the case needs spacings that differ between the axes")
|
||||
}
|
||||
mx, my := anisoMu(rows, cols, 1, 2)
|
||||
h := 0.0044
|
||||
tFinal := h * float64(steps)
|
||||
u0 := pde2dAnisoMode(t, rows, cols, 1, 2)
|
||||
history, err := IntegrateHeat2D(u0, kappa, dx, dy, tFinal, h, 2, 0, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat2D: %v", err)
|
||||
}
|
||||
rx := kappa * h / (2 * dx * dx)
|
||||
ry := kappa * h / (2 * dy * dy)
|
||||
amp := math.Pow((1-rx*mx)*(1-ry*my)/((1+rx*mx)*(1+ry*my)), float64(steps))
|
||||
if math.Abs(amp) < 0.05 {
|
||||
t.Fatalf("the run decays to %.3g; the case needs an amplitude the comparison can see", amp)
|
||||
}
|
||||
final := history.Shape()[0] - 1
|
||||
worst := 0.0
|
||||
for i := range rows * cols {
|
||||
worst = math.Max(worst, math.Abs(history.FloatAt(final*rows*cols+i)-amp*u0.FloatAt(i)))
|
||||
}
|
||||
if worst > 1e-12 {
|
||||
t.Fatalf("after %d steps the worst deviation from the ADI amplification %.6g·u0 is %.4g, want the anisotropic factor",
|
||||
steps, amp, worst)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/engine"
|
||||
)
|
||||
|
||||
// The 2-D step sweeps index their per-line tridiagonal scratch by
|
||||
// chunk start divided by the spawn floor, which is only exercised when
|
||||
// the engine actually spawns: on a many-core runner the fixture grids
|
||||
// run inline and a scratch-indexing break passes silently. This pin
|
||||
// forces both regimes and compares every output bit.
|
||||
|
||||
func pinHeat2D(workers int) ([]float64, error) {
|
||||
prev := engine.SetNumWorkers(workers)
|
||||
defer engine.SetNumWorkers(prev)
|
||||
const rows, cols = 20, 20
|
||||
u0 := make([]float64, rows*cols)
|
||||
for r := range rows {
|
||||
for c := range cols {
|
||||
u0[r*cols+c] = math.Sin(float64(c+1)/float64(cols+1)*math.Pi) *
|
||||
math.Sin(float64(r+1)/float64(rows+1)*math.Pi)
|
||||
}
|
||||
}
|
||||
state, err := core.FromFloats(u0, rows, cols)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := IntegrateHeat2D(state, 1, 1.0/21, 1.0/21, 0.02, 0.002, 2, 0, 0, 0, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]float64{}, out.RawFloats()...), nil
|
||||
}
|
||||
|
||||
func TestHeat2DScratchIndexingPinnedAcrossWorkers(t *testing.T) {
|
||||
serial, err := pinHeat2D(1)
|
||||
if err != nil {
|
||||
t.Fatalf("serial: %v", err)
|
||||
}
|
||||
// Two workers spawn on an 18-line sweep (chunk 9 at the floor of
|
||||
// 8); four and thirty-two collapse back to inline runs.
|
||||
for _, w := range []int{2, 4, 32} {
|
||||
got, err := pinHeat2D(w)
|
||||
if err != nil {
|
||||
t.Fatalf("workers=%d: %v", w, err)
|
||||
}
|
||||
if len(got) != len(serial) {
|
||||
t.Fatalf("workers=%d: length %d, want %d", w, len(got), len(serial))
|
||||
}
|
||||
for i := range serial {
|
||||
if math.Float64bits(got[i]) != math.Float64bits(serial[i]) {
|
||||
t.Fatalf("workers=%d element %d: %#x, want %#x",
|
||||
w, i, math.Float64bits(got[i]), math.Float64bits(serial[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// TestHeatEigenmodeDecay pins the analytic solution: the first sine
|
||||
// eigenmode decays as exp(−κ·π²·t/L²).
|
||||
func TestHeatEigenmodeDecay(t *testing.T) {
|
||||
const (
|
||||
n = 49
|
||||
L = 1.0
|
||||
kappa = 0.1
|
||||
)
|
||||
dx := L / float64(n+1)
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = math.Sin(math.Pi * float64(i+1) * dx / L)
|
||||
}
|
||||
u0Arr, _ := core.FromFloats(u0, n)
|
||||
const tFinal = 1.0
|
||||
states, err := IntegrateHeat1D(u0Arr, kappa, dx, tFinal, 0.002, 3, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat1D: %v", err)
|
||||
}
|
||||
decay := math.Exp(-kappa * math.Pi * math.Pi * tFinal / (L * L))
|
||||
final := states.Shape()[0]*n - n
|
||||
for i := range n {
|
||||
got := states.FloatAt(final + i)
|
||||
want := u0[i] * decay
|
||||
if math.Abs(got-want) > 5e-4*decay {
|
||||
t.Fatalf("u[%d] = %.8f, eigenmode says %.8f", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeatConservesConstantWithZeroBounds pins the fixed-point: a
|
||||
// constant field with equal Dirichlet bounds never moves.
|
||||
func TestHeatConservesConstantWithZeroBounds(t *testing.T) {
|
||||
const n = 20
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = 2.5
|
||||
}
|
||||
u0Arr, _ := core.FromFloats(u0, n)
|
||||
states, err := IntegrateHeat1D(u0Arr, 1.0, 0.1, 1.0, 0.05, 5, 2.5, 2.5)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat1D: %v", err)
|
||||
}
|
||||
last := (states.Shape()[0] - 1) * n
|
||||
for i := range n {
|
||||
if math.Abs(states.FloatAt(last+i)-2.5) > 1e-12 {
|
||||
t.Fatalf("constant field drifted: u[%d] = %.12f", i, states.FloatAt(last+i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaveStandingFrequency pins a standing wave: the fundamental mode
|
||||
// u(x, t) = sin(πx)·cos(πct) must return to (minus) itself after half
|
||||
// a period.
|
||||
func TestWaveStandingFrequency(t *testing.T) {
|
||||
const (
|
||||
n = 99
|
||||
L = 1.0
|
||||
c = 1.0
|
||||
)
|
||||
dx := L / float64(n+1)
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = math.Sin(math.Pi * float64(i+1) * dx / L)
|
||||
}
|
||||
u0Arr, _ := core.FromFloats(u0, n)
|
||||
v0, _ := core.FromFloats(make([]float64, n), n)
|
||||
// Half period of the fundamental: T1/2 = L/c.
|
||||
states, err := IntegrateWave1D(u0Arr, v0, c, dx, 1.0, 0.001, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave1D: %v", err)
|
||||
}
|
||||
last := (states.Shape()[0] - 1) * n
|
||||
for i := range n {
|
||||
got := states.FloatAt(last + i)
|
||||
want := -u0[i]
|
||||
if math.Abs(got-want) > 2e-3 {
|
||||
t.Fatalf("standing wave off after half period: u[%d] = %.6f, want %.6f", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaveEnergyBand pins Verlet's bounded energy over many periods.
|
||||
func TestWaveEnergyBand(t *testing.T) {
|
||||
const (
|
||||
n = 79
|
||||
L = 1.0
|
||||
c = 1.0
|
||||
)
|
||||
dx := L / float64(n+1)
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = math.Sin(math.Pi*float64(i+1)*dx/L) + 0.3*math.Sin(3*math.Pi*float64(i+1)*dx/L)
|
||||
}
|
||||
u0Arr, _ := core.FromFloats(u0, n)
|
||||
v0, _ := core.FromFloats(make([]float64, n), n)
|
||||
states, err := IntegrateWave1D(u0Arr, v0, c, dx, 10.0, 0.002, 11)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave1D: %v", err)
|
||||
}
|
||||
energy := func(row int) float64 {
|
||||
e := 0.0
|
||||
for i := range n {
|
||||
e += states.FloatAt(row*n+i) * states.FloatAt(row*n+i)
|
||||
}
|
||||
return e
|
||||
}
|
||||
e0 := energy(0)
|
||||
for row := 1; row < 11; row++ {
|
||||
e := energy(row)
|
||||
if math.Abs(e-e0) > 1e-3*e0 {
|
||||
t.Fatalf("energy drifted: row %d has %.8f vs %.8f", row, e, e0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPDEErrors pins the input gates.
|
||||
func TestPDEErrors(t *testing.T) {
|
||||
u, _ := core.FromFloats([]float64{1, 2, 3}, 3)
|
||||
if _, err := IntegrateHeat1D(u, -1, 0.1, 1, 0.01, 2, 0, 0); err == nil {
|
||||
t.Error("negative diffusivity accepted")
|
||||
}
|
||||
if _, err := IntegrateHeat1D(u, 1, 0.1, 1, 0.01, 1, 0, 0); err == nil {
|
||||
t.Error("one sample accepted")
|
||||
}
|
||||
v, _ := core.FromFloats([]float64{1, 2}, 2)
|
||||
if _, err := IntegrateWave1D(u, v, 1, 0.1, 1, 0.01, 2); err == nil {
|
||||
t.Error("velocity shape mismatch accepted")
|
||||
}
|
||||
if _, err := IntegrateWave1D(u, u, 1, 0.1, 1, 0.2, 2); err == nil {
|
||||
t.Error("CFL violation accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Advection in one space dimension, the transport siblings of
|
||||
// IntegrateHeat1D and IntegrateWave1D: u_t + a·u_x = 0 and the
|
||||
// advection-diffusion equation u_t + a·u_x = D·u_xx. Transport is
|
||||
// where the cheap stencils fail in public: the first-order upwind
|
||||
// flux is monotone but smears a front every step it takes, and the
|
||||
// centred flux that heat uses is oscillatory or worse here. The
|
||||
// middle ground is a flux-limited scheme: an upwind flux whose face
|
||||
// value is raised toward the third-order one by a slope limiter that
|
||||
// switches itself off across discontinuities, keeping the scheme
|
||||
// total variation diminishing.
|
||||
//
|
||||
// The limiter is Koren's third-order one, φ(θ) = max(0, min(2θ,
|
||||
// (2+θ)/3, 2)), stated here as the choice. The face value is the
|
||||
// Sweby flux-limited form u_upwind + ½φ(θ)·(1−|ν|)·Δ, with ν =
|
||||
// |a|·dt/dx the CFL number and Δ the forward difference across the
|
||||
// face: the (1−|ν|) factor is what makes the explicit update total
|
||||
// variation diminishing in the Sweby sense for CFL ≤ 1, and the
|
||||
// third-order branch of φ is what keeps smooth profiles sharp (a
|
||||
// marginally small overshoot past the strict Sweby bound survives as
|
||||
// roundoff-scale noise). The grid convention is the house one: u0
|
||||
// carries the interior cells with dx = L/(n+1), and boundL, boundR
|
||||
// are the fixed values of the ghost cells on the ends. The face
|
||||
// adjacent to the inflow end takes its prescribed value directly
|
||||
// (first order there); on every other face, including the outflow
|
||||
// end, the limiter runs at full strength with the prescribed ghost
|
||||
// value entering its smoothness ratio, and a window too short for a
|
||||
// three-cell stencil forces first order.
|
||||
|
||||
// korenSlope returns the Koren-limited normalised slope φ(θ) for the
|
||||
// smoothness ratio θ: the three branches are the Sweby bounds with
|
||||
// the third-order diagonal (2+θ)/3.
|
||||
func korenSlope(theta float64) float64 {
|
||||
phi := min(2*theta, (2+theta)/3, 2)
|
||||
if phi < 0 {
|
||||
return 0
|
||||
}
|
||||
return phi
|
||||
}
|
||||
|
||||
// advectFaceFlux returns the numerical flux a·u_face across the face
|
||||
// between cells ul (left) and ur (right), with the second neighbours
|
||||
// ull (left of ul) and urr (right of ur) feeding the limiter and nu
|
||||
// the CFL number feeding the time factor. The wind decides the side
|
||||
// the face value is reconstructed from; with limited false the flux
|
||||
// is plain first-order upwind.
|
||||
func advectFaceFlux(a, ull, ul, ur, urr, nu float64, limited bool) float64 {
|
||||
d := ur - ul
|
||||
if a >= 0 {
|
||||
if !limited || d == 0 {
|
||||
return a * ul
|
||||
}
|
||||
theta := (ul - ull) / d
|
||||
return a * (ul + 0.5*korenSlope(theta)*(1-nu)*d)
|
||||
}
|
||||
if !limited || d == 0 {
|
||||
return a * ur
|
||||
}
|
||||
theta := (ur - urr) / d
|
||||
return a * (ur - 0.5*korenSlope(theta)*(1-nu)*d)
|
||||
}
|
||||
|
||||
// advectStep advances dst from src by one explicit step of width h
|
||||
// of the conservative update u −= (h/dx)·(F₊ − F₋), the faces built
|
||||
// from src with the ghost values boundL and boundR on the ends. dst
|
||||
// and src must not alias. Face j sits between cell j−1 and cell j,
|
||||
// so face 0 borders the left ghost and face n the right one.
|
||||
func advectStep(dst, src []float64, a, dx, h, boundL, boundR float64, limited bool, faces []float64) {
|
||||
n := len(src)
|
||||
lambda := h / dx
|
||||
nu := math.Abs(a) * lambda
|
||||
for j := range n + 1 {
|
||||
switch {
|
||||
case j == 0:
|
||||
// The inflow face takes the prescribed ghost value; on an
|
||||
// outflow left end the upwind cell is cell 0, whose
|
||||
// limited face value reaches one cell into the interior.
|
||||
if a >= 0 {
|
||||
faces[j] = a * boundL
|
||||
} else if n < 2 || !limited {
|
||||
faces[j] = a * src[0]
|
||||
} else {
|
||||
faces[j] = advectFaceFlux(a, boundL, boundL, src[0], src[1], nu, true)
|
||||
}
|
||||
case j == n:
|
||||
if a >= 0 {
|
||||
if n < 2 || !limited {
|
||||
faces[j] = a * src[n-1]
|
||||
} else {
|
||||
faces[j] = advectFaceFlux(a, src[n-2], src[n-1], boundR, boundR, nu, true)
|
||||
}
|
||||
} else {
|
||||
faces[j] = a * boundR
|
||||
}
|
||||
default:
|
||||
ull := boundL
|
||||
if j >= 2 {
|
||||
ull = src[j-2]
|
||||
}
|
||||
urr := boundR
|
||||
if j <= n-2 {
|
||||
urr = src[j+1]
|
||||
}
|
||||
faces[j] = advectFaceFlux(a, ull, src[j-1], src[j], urr, nu, limited)
|
||||
}
|
||||
}
|
||||
for i := range n {
|
||||
dst[i] = src[i] - lambda*(faces[i+1]-faces[i])
|
||||
}
|
||||
}
|
||||
|
||||
// advectValidate checks the shared input contract of the transport
|
||||
// solvers and returns the cell count. The grid, step bound, sample
|
||||
// and finiteness gates are pdeValidate's; transport adds a finite
|
||||
// speed and finite ghost values, and enforces the CFL budget
|
||||
// |a|·dt/dx ≤ 1 the explicit update needs, exactly like the wave
|
||||
// solver enforces its own.
|
||||
func advectValidate(name string, u0 *core.Array, a, dx, tFinal, dt float64, samples int, boundL, boundR float64) (int, error) {
|
||||
n, err := pdeValidate(name, u0, dx, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if math.IsNaN(a) || math.IsInf(a, 0) {
|
||||
return 0, base.Errf("%s: the transport speed must be finite, got %g", name, a)
|
||||
}
|
||||
if math.IsNaN(boundL) || math.IsInf(boundL, 0) || math.IsNaN(boundR) || math.IsInf(boundR, 0) {
|
||||
return 0, base.Errf("%s: the ghost values must be finite, got %g and %g", name, boundL, boundR)
|
||||
}
|
||||
cfl := math.Abs(a * dt / dx)
|
||||
if cfl > 1 {
|
||||
return 0, base.Errf("%s: CFL violated, |a·dt/dx| = %.3g > 1", name, cfl)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// IntegrateAdvection1D evolves u_t + a·u_x = 0 over the grid of u0
|
||||
// from t = 0 to tFinal in equal steps of at most dt, and returns the
|
||||
// (samples, n) array of interior states evenly spaced in time,
|
||||
// endpoints included, exactly like IntegrateHeat1D. The flux is the
|
||||
// Koren-limited upwind one described at the top of the file: total
|
||||
// variation diminishing under CFL ≤ 1, third-order at smooth faces
|
||||
// and first order next to the inflow boundary, so a front is carried
|
||||
// sharply where plain upwind would smear it away.
|
||||
func IntegrateAdvection1D(u0 *core.Array, a, dx, tFinal, dt float64, samples int, boundL, boundR float64) (*core.Array, error) {
|
||||
const name = "IntegrateAdvection1D"
|
||||
n, err := advectValidate(name, u0, a, dx, tFinal, dt, samples, boundL, boundR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return advectRun(name, u0, a, dx, tFinal, dt, samples, boundL, boundR, n, true)
|
||||
}
|
||||
|
||||
// IntegrateUpwindAdvection1D evolves the same equation with the
|
||||
// plain first-order upwind flux: monotone under CFL ≤ 1 (no new
|
||||
// extrema, ever) and diffuse, the baseline the limited scheme is
|
||||
// measured against. The return contract mirrors IntegrateAdvection1D.
|
||||
func IntegrateUpwindAdvection1D(u0 *core.Array, a, dx, tFinal, dt float64, samples int, boundL, boundR float64) (*core.Array, error) {
|
||||
const name = "IntegrateUpwindAdvection1D"
|
||||
n, err := advectValidate(name, u0, a, dx, tFinal, dt, samples, boundL, boundR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return advectRun(name, u0, a, dx, tFinal, dt, samples, boundL, boundR, n, false)
|
||||
}
|
||||
|
||||
// advectRun is the shared stepping loop of the two pure-transport
|
||||
// solvers: fixed steps on the pdeSchedule grid, states sampled every
|
||||
// steps/(samples−1) steps with the final state forced into the last
|
||||
// sample.
|
||||
func advectRun(name string, u0 *core.Array, a, dx, tFinal, dt float64, samples int, boundL, boundR float64, n int, limited bool) (*core.Array, error) {
|
||||
u := make([]float64, n)
|
||||
for i := range n {
|
||||
u[i] = u0.FloatAt(i)
|
||||
}
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
every := steps / (samples - 1)
|
||||
out := core.New(core.Float, samples, n)
|
||||
into := out.RawFloats()
|
||||
copy(into, u)
|
||||
written := 1
|
||||
faces := make([]float64, n+1)
|
||||
scratch := make([]float64, n)
|
||||
for s := 1; s <= steps; s++ {
|
||||
advectStep(scratch, u, a, dx, h, boundL, boundR, limited, faces)
|
||||
u, scratch = scratch, u
|
||||
if s%every == 0 && written < samples {
|
||||
copy(into[written*n:(written+1)*n], u)
|
||||
written++
|
||||
}
|
||||
}
|
||||
copy(into[(samples-1)*n:], u)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IntegrateAdvectionDiffusion1D evolves u_t + a·u_x = D·u_xx over the
|
||||
// grid of u0 with the Dirichlet ghost values boundL and boundR. Each
|
||||
// step combines the Koren-limited advection flux, advanced
|
||||
// explicitly, with the Crank-Nicolson second-difference diffusion the
|
||||
// heat solver runs through the shared tridiagonal solve, so the
|
||||
// composition is first order in time and second order in space and
|
||||
// the diffusion side is unconditionally stable. The explicit
|
||||
// advection still answers for its own CFL budget |a|·dt/dx ≤ 1 and
|
||||
// the step is refused past it. With a = 0 the scheme reduces exactly
|
||||
// to IntegrateHeat1D.
|
||||
func IntegrateAdvectionDiffusion1D(u0 *core.Array, a, kappa, dx, tFinal, dt float64, samples int, boundL, boundR float64) (*core.Array, error) {
|
||||
const name = "IntegrateAdvectionDiffusion1D"
|
||||
n, err := advectValidate(name, u0, a, dx, tFinal, dt, samples, boundL, boundR)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !(kappa > 0) || math.IsInf(kappa, 0) {
|
||||
return nil, base.Errf("%s: the diffusivity must be positive, got %g", name, kappa)
|
||||
}
|
||||
u := make([]float64, n)
|
||||
for i := range n {
|
||||
u[i] = u0.FloatAt(i)
|
||||
}
|
||||
steps, h := pdeSchedule(tFinal, dt, samples)
|
||||
r := kappa * h / (dx * dx)
|
||||
lower := make([]float64, n-1)
|
||||
diag := make([]float64, n)
|
||||
upper := make([]float64, n-1)
|
||||
// The implicit left side I − r/2·A is a constant of the scheme,
|
||||
// built once exactly as IntegrateHeat1D builds it, strictly
|
||||
// diagonally dominant for every positive r like the heat system.
|
||||
for i := range n {
|
||||
diag[i] = 1 + r
|
||||
if i < n-1 {
|
||||
lower[i] = -r / 2
|
||||
upper[i] = -r / 2
|
||||
}
|
||||
}
|
||||
// The trapezoidal weight and the elimination scratch are constants
|
||||
// of one solve: every step refills the same right side and the
|
||||
// solution is written straight into the working state.
|
||||
half := r / 2
|
||||
var tri triScratch
|
||||
triSized(&tri, n, n)
|
||||
|
||||
every := steps / (samples - 1)
|
||||
out := core.New(core.Float, samples, n)
|
||||
into := out.RawFloats()
|
||||
copy(into, u)
|
||||
written := 1
|
||||
faces := make([]float64, n+1)
|
||||
advected := make([]float64, n)
|
||||
rhs := tri.rhs
|
||||
for s := 1; s <= steps; s++ {
|
||||
// Explicit advection sub-step on the limited fluxes.
|
||||
advectStep(advected, u, a, dx, h, boundL, boundR, true, faces)
|
||||
// Crank-Nicolson diffusion sub-step on the advected state,
|
||||
// the Dirichlet neighbours entering as known data on both
|
||||
// sides, in the heat solver's own arithmetic.
|
||||
for i := range n {
|
||||
um, up := boundL, boundR
|
||||
if i > 0 {
|
||||
um = advected[i-1]
|
||||
}
|
||||
if i < n-1 {
|
||||
up = advected[i+1]
|
||||
}
|
||||
rhs[i] = advected[i] + half*(um-2*advected[i]+up)
|
||||
if i == 0 {
|
||||
rhs[i] += half * boundL
|
||||
}
|
||||
if i == n-1 {
|
||||
rhs[i] += half * boundR
|
||||
}
|
||||
}
|
||||
if serr := base.TriSolve(u, tri.cp, tri.dp, lower, diag, upper, rhs); serr != nil {
|
||||
return nil, base.Errf("%s: %w", name, serr)
|
||||
}
|
||||
if s%every == 0 && written < samples {
|
||||
copy(into[written*n:(written+1)*n], u)
|
||||
written++
|
||||
}
|
||||
}
|
||||
copy(into[(samples-1)*n:], u)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// advectGrid builds the interior cells of [0, 1] with n cells and the
|
||||
// matching cell centres.
|
||||
func advectGrid(n int) (dx float64, centres []float64) {
|
||||
dx = 1 / float64(n+1)
|
||||
centres = make([]float64, n)
|
||||
for i := range n {
|
||||
centres[i] = float64(i+1) * dx
|
||||
}
|
||||
return dx, centres
|
||||
}
|
||||
|
||||
// advectL1 returns the L1 error of the final sample against exact.
|
||||
func advectL1(t *testing.T, states *core.Array, n int, exact func(x float64) float64) float64 {
|
||||
t.Helper()
|
||||
last := (states.Shape()[0] - 1) * n
|
||||
_, centres := advectGrid(n)
|
||||
sum := 0.0
|
||||
for i := range n {
|
||||
sum += math.Abs(states.FloatAt(last+i) - exact(centres[i]))
|
||||
}
|
||||
return sum / float64(n)
|
||||
}
|
||||
|
||||
// TestAdvectionUpwindMonotone pins the discrete maximum principle of
|
||||
// both schemes: a monotone profile transported at CFL 0.9 stays
|
||||
// monotone and inside its initial range, sample after sample.
|
||||
func TestAdvectionUpwindMonotone(t *testing.T) {
|
||||
for _, limited := range []bool{false, true} {
|
||||
name := "upwind"
|
||||
if limited {
|
||||
name = "Koren"
|
||||
}
|
||||
n := 96
|
||||
dx, centres := advectGrid(n)
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = 1 - centres[i]
|
||||
}
|
||||
u0Arr, err := core.FromFloats(u0, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfl := 0.9
|
||||
a := 1.0
|
||||
dt := cfl * dx
|
||||
run := func() (*core.Array, error) {
|
||||
if limited {
|
||||
return IntegrateAdvection1D(u0Arr, a, dx, 0.3, dt, 4, 1, 0)
|
||||
}
|
||||
return IntegrateUpwindAdvection1D(u0Arr, a, dx, 0.3, dt, 4, 1, 0)
|
||||
}
|
||||
states, err := run()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
rows := states.Shape()[0]
|
||||
for r := range rows {
|
||||
prev := math.Inf(1)
|
||||
for i := range n {
|
||||
v := states.FloatAt(r*n + i)
|
||||
if v < -1e-12 || v > 1+1e-12 {
|
||||
t.Fatalf("%s: sample %d cell %d left the range [0, 1]: %g", name, r, i, v)
|
||||
}
|
||||
if v > prev+1e-12 {
|
||||
t.Fatalf("%s: sample %d stops being non-increasing at cell %d: %g after %g",
|
||||
name, r, i, v, prev)
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
}
|
||||
// The transported ramp also keeps its shape: the last sample
|
||||
// tracks the shifted exact ramp to within the scheme's smear.
|
||||
last := (rows - 1) * n
|
||||
shift := 0.3
|
||||
for i := range n {
|
||||
x := centres[i] - shift
|
||||
want := 1.0
|
||||
if x > 0 {
|
||||
want = 1 - x
|
||||
}
|
||||
if d := math.Abs(states.FloatAt(last+i) - want); d > 0.02 {
|
||||
t.Fatalf("%s: ramp cell %d: %g, want %.6g", name, i, states.FloatAt(last+i), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvectionSquareWaveLimiterBeatsUpwind pins the limiter's reason
|
||||
// for being: a square pulse carried at CFL 0.9 comes out visibly
|
||||
// sharper under the Koren flux than under plain upwind, measured as
|
||||
// the L1 error ratio against the exact shifted pulse.
|
||||
func TestAdvectionSquareWaveLimiterBeatsUpwind(t *testing.T) {
|
||||
n := 256
|
||||
dx, centres := advectGrid(n)
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = 0.0
|
||||
if centres[i] >= 0.3 && centres[i] <= 0.7 {
|
||||
u0[i] = 1.0
|
||||
}
|
||||
}
|
||||
u0Arr, err := core.FromFloats(u0, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := 1.0
|
||||
cfl := 0.9
|
||||
dt := cfl * dx
|
||||
const tFinal = 0.2
|
||||
upwind, err := IntegrateUpwindAdvection1D(u0Arr, a, dx, tFinal, dt, 2, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("upwind: %v", err)
|
||||
}
|
||||
koren, err := IntegrateAdvection1D(u0Arr, a, dx, tFinal, dt, 2, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Koren: %v", err)
|
||||
}
|
||||
exact := func(x float64) float64 {
|
||||
if x >= 0.5 && x <= 0.9 {
|
||||
return 1.0
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
errUpwind := advectL1(t, upwind, n, exact)
|
||||
errKoren := advectL1(t, koren, n, exact)
|
||||
t.Logf("square pulse: upwind L1 %.4g, Koren L1 %.4g, ratio %.2f", errUpwind, errKoren, errUpwind/errKoren)
|
||||
if errUpwind/errKoren < 2.0 {
|
||||
t.Fatalf("limiter advantage %.2f, want at least 2x over upwind", errUpwind/errKoren)
|
||||
}
|
||||
// Both stay monotone in the sense that matters for a pulse: no
|
||||
// undershoot below the initial range.
|
||||
last := n
|
||||
for i := range n {
|
||||
for _, states := range []*core.Array{upwind, koren} {
|
||||
if v := states.FloatAt(last + i); v < -1e-12 || v > 1+1e-12 {
|
||||
t.Fatalf("scheme left the pulse range: %g at cell %d", v, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvectionSmoothTransportOrder pins the documented orders: at
|
||||
// fixed CFL 0.9 the upwind L1 error halves as the grid halves (first
|
||||
// order) and the Koren error improves second order or better (measured
|
||||
// ratio past 3 at every pair), staying below the upwind error
|
||||
// throughout. Three grid levels separate the two rates: a pair alone
|
||||
// cannot tell a second-order Koren from a degraded one.
|
||||
func TestAdvectionSmoothTransportOrder(t *testing.T) {
|
||||
a := 1.0
|
||||
cfl := 0.9
|
||||
const tFinal = 0.3
|
||||
gaussian := func(x float64) float64 { return math.Exp(-math.Pow((x-0.35)/0.1, 2)) }
|
||||
previousUpwind, previousKoren := 0.0, 0.0
|
||||
for _, n := range []int{100, 200, 400} {
|
||||
dx, centres := advectGrid(n)
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = gaussian(centres[i])
|
||||
}
|
||||
u0Arr, err := core.FromFloats(u0, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dt := cfl * dx / math.Abs(a)
|
||||
upwind, err := IntegrateUpwindAdvection1D(u0Arr, a, dx, tFinal, dt, 2, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("upwind n=%d: %v", n, err)
|
||||
}
|
||||
koren, err := IntegrateAdvection1D(u0Arr, a, dx, tFinal, dt, 2, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Koren n=%d: %v", n, err)
|
||||
}
|
||||
shift := a * tFinal
|
||||
exact := func(x float64) float64 { return gaussian(x - shift) }
|
||||
eUpwind := advectL1(t, upwind, n, exact)
|
||||
eKoren := advectL1(t, koren, n, exact)
|
||||
t.Logf("n=%3d: upwind L1 %.3g, Koren L1 %.3g", n, eUpwind, eKoren)
|
||||
if eKoren > eUpwind {
|
||||
t.Fatalf("n=%d: Koren error %.3g above upwind %.3g", n, eKoren, eUpwind)
|
||||
}
|
||||
if previousUpwind > 0 {
|
||||
if r := previousUpwind / eUpwind; r < 1.5 || r > 3.0 {
|
||||
t.Fatalf("n=%d: upwind refinement ratio %.2f, want about 2", n, r)
|
||||
}
|
||||
if r := previousKoren / eKoren; r < 3.0 || r > 6.0 {
|
||||
t.Fatalf("n=%d: Koren refinement ratio %.2f, want the second-order rate the limiter carries", n, r)
|
||||
}
|
||||
}
|
||||
previousUpwind, previousKoren = eUpwind, eKoren
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvectionDiffusionMatchesHeatWhenAZero pins the reduction: with
|
||||
// a = 0 the advection-diffusion solver performs exactly the
|
||||
// Crank-Nicolson steps of IntegrateHeat1D, so the two histories agree
|
||||
// to the last bit.
|
||||
func TestAdvectionDiffusionMatchesHeatWhenAZero(t *testing.T) {
|
||||
n := 32
|
||||
dx := 1 / float64(n+1)
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = math.Sin(math.Pi * float64(i+1) * dx)
|
||||
}
|
||||
u0Arr, err := core.FromFloats(u0, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heat, err := IntegrateHeat1D(u0Arr, 0.05, dx, 0.5, 0.004, 3, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat1D: %v", err)
|
||||
}
|
||||
adv, err := IntegrateAdvectionDiffusion1D(u0Arr, 0, 0.05, dx, 0.5, 0.004, 3, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateAdvectionDiffusion1D: %v", err)
|
||||
}
|
||||
for i := range heat.Len() {
|
||||
if heat.FloatAt(i) != adv.FloatAt(i) {
|
||||
t.Fatalf("sample %d differs: heat %.17g, advection-diffusion %.17g",
|
||||
i, heat.FloatAt(i), adv.FloatAt(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvectionDiffusionConvergence pins the documented orders of the
|
||||
// combination: the split step is first order in time and second order
|
||||
// in space, so the coupled refinement at fixed CFL shows the two
|
||||
// mixed, an L1 error shrinking by roughly 2.5 to 3 per halving. The
|
||||
// exact solution is the drifting heat kernel
|
||||
// sqrt(w0/w)·exp(−(x−x0−at)²/w), w = w0 + 4Dt, whose boundary values
|
||||
// are zero to well below the measured errors.
|
||||
func TestAdvectionDiffusionConvergence(t *testing.T) {
|
||||
const (
|
||||
a = 0.3
|
||||
probD = 0.005
|
||||
x0 = 0.3
|
||||
tFinal = 0.6
|
||||
)
|
||||
exact := func(t, x float64) float64 {
|
||||
w := 0.01 + 4*probD*t
|
||||
return math.Sqrt(0.01/w) * math.Exp(-math.Pow(x-x0-a*t, 2)/w)
|
||||
}
|
||||
run := func(n int, cfl float64) float64 {
|
||||
dx := 1 / float64(n+1)
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = exact(0, float64(i+1)*dx)
|
||||
}
|
||||
u0Arr, err := core.FromFloats(u0, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
states, err := IntegrateAdvectionDiffusion1D(u0Arr, a, probD, dx, tFinal, cfl*dx/a, 2, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("n=%d: %v", n, err)
|
||||
}
|
||||
return advectL1(t, states, n, func(x float64) float64 { return exact(tFinal, x) })
|
||||
}
|
||||
for _, cfl := range []float64{0.9, 0.3} {
|
||||
previous := 0.0
|
||||
for _, n := range []int{64, 128, 256} {
|
||||
e := run(n, cfl)
|
||||
t.Logf("CFL %.2f n=%3d: L1 %.4g", cfl, n, e)
|
||||
if previous > 0 {
|
||||
if r := previous / e; r < 2.2 || r > 3.7 {
|
||||
t.Fatalf("CFL %.2f: refinement ratio %.2f at n=%d, want the mixed time-space rate about 2.7",
|
||||
cfl, r, n)
|
||||
}
|
||||
}
|
||||
previous = e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvectionCFLRefusal(t *testing.T) {
|
||||
n := 10
|
||||
dx := 1 / float64(n+1)
|
||||
u0, _ := core.FromFloats(make([]float64, n), n)
|
||||
// CFL = 1.5 for a = 1.
|
||||
dt := 1.5 * dx
|
||||
if _, err := IntegrateAdvection1D(u0, 1, dx, 0.1, dt, 2, 0, 0); err == nil || !stringsContains(err, "CFL violated") {
|
||||
t.Fatalf("Koren CFL violation: %v", err)
|
||||
}
|
||||
if _, err := IntegrateUpwindAdvection1D(u0, -1, dx, 0.1, dt, 2, 0, 0); err == nil || !stringsContains(err, "CFL violated") {
|
||||
t.Fatalf("upwind CFL violation: %v", err)
|
||||
}
|
||||
if _, err := IntegrateAdvectionDiffusion1D(u0, 1, 0.01, dx, 0.1, dt, 2, 0, 0); err == nil || !stringsContains(err, "CFL violated") {
|
||||
t.Fatalf("advection-diffusion CFL violation: %v", err)
|
||||
}
|
||||
// The boundary value at the CFL edge is accepted.
|
||||
if _, err := IntegrateAdvection1D(u0, 1, dx, 0.1, dx, 2, 0, 0); err != nil {
|
||||
t.Fatalf("CFL = 1 refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvectionErrors(t *testing.T) {
|
||||
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{1, 2, 3}, 3, 1), 1, 0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "rank-1") {
|
||||
t.Fatalf("a rank-2 initial state: %v", err)
|
||||
}
|
||||
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{1, 2, 3}), math.NaN(), 0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "finite") {
|
||||
t.Fatalf("a NaN speed: %v", err)
|
||||
}
|
||||
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{1, 2, 3}), 1, 0.1, 1, 0.01, 2, math.Inf(1), 0); err == nil || !stringsContains(err, "finite") {
|
||||
t.Fatalf("an infinite ghost value: %v", err)
|
||||
}
|
||||
if _, err := IntegrateUpwindAdvection1D(mustFloats(t, []float64{1, 2, 3}), 1, -0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "positive") {
|
||||
t.Fatalf("a negative spacing: %v", err)
|
||||
}
|
||||
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{1, 2, 3}), 1, 0.1, 1, 0.01, 1, 0, 0); err == nil || !stringsContains(err, "two samples") {
|
||||
t.Fatalf("one sample: %v", err)
|
||||
}
|
||||
if _, err := IntegrateAdvection1D(mustFloats(t, []float64{math.NaN()}), 1, 0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatalf("a NaN initial cell: %v", err)
|
||||
}
|
||||
if _, err := IntegrateAdvectionDiffusion1D(mustFloats(t, []float64{1, 2, 3}), 1, 0, 0.1, 1, 0.01, 2, 0, 0); err == nil || !stringsContains(err, "diffusivity") {
|
||||
t.Fatalf("zero diffusivity: %v", err)
|
||||
}
|
||||
// A single interior cell is refused by the limiter's stencil need
|
||||
// only for the limited boundary faces, so n = 1 must still run on
|
||||
// the upwind path with first order there.
|
||||
if _, err := IntegrateUpwindAdvection1D(mustFloats(t, []float64{0.5}), 1, 0.1, 0.05, 0.05, 2, 1, 0); err != nil {
|
||||
t.Fatalf("a single-cell upwind run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdvectionLeftwardTransport pins the mirror branch: with a < 0
|
||||
// the inflow is the right boundary, and both schemes must transport a
|
||||
// monotone leftward ramp without new extrema, with the ghost value
|
||||
// feeding in from the right.
|
||||
func TestAdvectionLeftwardTransport(t *testing.T) {
|
||||
n := 96
|
||||
dx, centres := advectGrid(n)
|
||||
u0 := make([]float64, n)
|
||||
for i := range n {
|
||||
u0[i] = centres[i]
|
||||
}
|
||||
u0Arr, err := core.FromFloats(u0, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := -1.0
|
||||
dt := 0.9 * dx
|
||||
for _, limited := range []bool{false, true} {
|
||||
name := "upwind"
|
||||
run := func() (*core.Array, error) {
|
||||
if limited {
|
||||
name = "Koren"
|
||||
return IntegrateAdvection1D(u0Arr, a, dx, 0.2, dt, 3, 0, 1)
|
||||
}
|
||||
return IntegrateUpwindAdvection1D(u0Arr, a, dx, 0.2, dt, 3, 0, 1)
|
||||
}
|
||||
states, err := run()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
for r := range states.Shape()[0] {
|
||||
for i := range n {
|
||||
v := states.FloatAt(r*n + i)
|
||||
if v < -1e-9 || v > 1+1e-9 {
|
||||
t.Fatalf("%s: sample %d cell %d left the range: %g", name, r, i, v)
|
||||
}
|
||||
if i > 0 && states.FloatAt(r*n+i) < states.FloatAt(r*n+i-1)-1e-9 {
|
||||
t.Fatalf("%s: sample %d grows a new extremum at cell %d: %g after %g", name, r, i, v, states.FloatAt(r*n+i-1))
|
||||
}
|
||||
}
|
||||
}
|
||||
// The exact ramp is x + 0.2, cut at the inflow value 1.
|
||||
last := (states.Shape()[0] - 1) * n
|
||||
for i := range n {
|
||||
want := math.Min(centres[i]+0.2, 1)
|
||||
if d := math.Abs(states.FloatAt(last+i) - want); d > 0.02 {
|
||||
t.Fatalf("%s: cell %d: %.6g, want %.6g", name, i, states.FloatAt(last+i), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// 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"
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Numerical quadrature: the definite integral of a function over an
|
||||
// interval. The library integrates ODEs but until now not plain
|
||||
// integrals, which every data-reduction pipeline needs.
|
||||
//
|
||||
// The scheme is adaptive Gauss-Legendre with an honest internal
|
||||
// error estimate: every subinterval is evaluated by a 21-point rule
|
||||
// and a 10-point rule over the same interval, the gap between the two
|
||||
// is that subinterval's error, and the subinterval with the largest
|
||||
// error is bisected until the summed error meets the tolerance. The
|
||||
// 21 and 10 point nodes come from Newton's method on the Legendre
|
||||
// recurrence, so the whole construction is derived in the library
|
||||
// rather than imported as a table. Infinite bounds map onto (0, 1) by
|
||||
// rational substitution before the rule runs; an integrand over an
|
||||
// infinite interval has to decay to zero for this to converge.
|
||||
|
||||
// QuadratureOptions tunes Integrate. RelTol ≤ 0 means 1e-10, AbsTol
|
||||
// ≤ 0 means 1e-12, MaxIntervals ≤ 0 means 256.
|
||||
type QuadratureOptions struct {
|
||||
RelTol float64
|
||||
AbsTol float64
|
||||
MaxIntervals int
|
||||
}
|
||||
|
||||
// gaussLegendreEntry holds the cached nodes and weights of the
|
||||
// n-point Gauss-Legendre rule over [-1, 1].
|
||||
type gaussLegendreEntry struct {
|
||||
nodes []float64
|
||||
weights []float64
|
||||
}
|
||||
|
||||
var (
|
||||
gaussLegendreCacheMu sync.Mutex
|
||||
gaussLegendreCache = map[int]gaussLegendreEntry{}
|
||||
)
|
||||
|
||||
// GaussLegendreNodes returns the nodes and weights of the n-point
|
||||
// Gauss-Legendre rule over [-1, 1], exact for polynomials up to
|
||||
// degree 2n−1. Nodes come out ascending. n must be between 1 and 128.
|
||||
// The nodes are the roots of the n-th Legendre polynomial, found by
|
||||
// Newton's method on the three-term recurrence, which reaches
|
||||
// rounding-level accuracy in a handful of iterations per node. The
|
||||
// returned slices are a shared cache: they must be treated as
|
||||
// read-only, because a write would poison every later quadrature run
|
||||
// on the same node count.
|
||||
func GaussLegendreNodes(n int) (nodes, weights []float64, err error) {
|
||||
if n < 1 || n > 128 {
|
||||
return nil, nil, base.Errf("GaussLegendreNodes: n must be between 1 and 128, got %d", n)
|
||||
}
|
||||
gaussLegendreCacheMu.Lock()
|
||||
entry, ok := gaussLegendreCache[n]
|
||||
gaussLegendreCacheMu.Unlock()
|
||||
if ok {
|
||||
return entry.nodes, entry.weights, nil
|
||||
}
|
||||
nodes = make([]float64, n)
|
||||
weights = make([]float64, n)
|
||||
for i := range n {
|
||||
// The trigonometric starting guess separates the roots well
|
||||
// enough that Newton never jumps to a neighbour.
|
||||
x := -math.Cos(math.Pi * (float64(i) + 0.75) / (float64(n) + 0.5))
|
||||
var p, dp float64
|
||||
for range 100 {
|
||||
p, dp = legendrePair(n, x)
|
||||
dx := p / dp
|
||||
x -= dx
|
||||
if math.Abs(dx) <= 1e-16*(1+math.Abs(x)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if math.Abs(p) > 1e-10 {
|
||||
return nil, nil, base.Errf("GaussLegendreNodes: Newton failed to converge for n=%d", n)
|
||||
}
|
||||
nodes[i] = x
|
||||
weights[i] = 2 / ((1 - x*x) * dp * dp)
|
||||
}
|
||||
entry = gaussLegendreEntry{nodes: nodes, weights: weights}
|
||||
gaussLegendreCacheMu.Lock()
|
||||
gaussLegendreCache[n] = entry
|
||||
gaussLegendreCacheMu.Unlock()
|
||||
return nodes, weights, nil
|
||||
}
|
||||
|
||||
// legendrePair evaluates P_n(x) and P_n'(x) by the three-term
|
||||
// recurrence and its derivative identity.
|
||||
func legendrePair(n int, x float64) (p, dp float64) {
|
||||
p = 1
|
||||
if n == 0 {
|
||||
return 1, 0
|
||||
}
|
||||
pm := 0.0
|
||||
for k := 1; k <= n; k++ {
|
||||
pm, p = p, ((2*float64(k)-1)*x*p-float64(k-1)*pm)/float64(k)
|
||||
}
|
||||
dp = float64(n) * (x*p - pm) / (x*x - 1)
|
||||
return p, dp
|
||||
}
|
||||
|
||||
// IntegrateFunction returns the definite integral of f over [a, b] with an
|
||||
// estimate of the absolute error. Infinite bounds are accepted: a = −∞
|
||||
// or b = +∞ (or both) integrate over the whole tail under a rational
|
||||
// substitution, which asks the integrand to decay to zero. A reversed
|
||||
// interval (a > b) integrates in the negative direction. A tolerance
|
||||
// that cannot be met within MaxIntervals subintervals is an error,
|
||||
// never a silent approximation.
|
||||
//
|
||||
// Every sampled scheme has one blind spot: a feature entirely inside
|
||||
// the gaps of the first rule's nodes, say a peak far narrower than
|
||||
// (b−a)/n, produces small values everywhere it samples and is missed
|
||||
// with a small error estimate. Split known sharp features into their
|
||||
// own IntegrateFunction calls.
|
||||
func IntegrateFunction(f func(x float64) (float64, error), a, b float64, opts QuadratureOptions) (float64, float64, error) {
|
||||
if opts.RelTol <= 0 {
|
||||
opts.RelTol = 1e-10
|
||||
}
|
||||
if opts.AbsTol <= 0 {
|
||||
opts.AbsTol = 1e-12
|
||||
}
|
||||
if opts.MaxIntervals <= 0 {
|
||||
opts.MaxIntervals = 256
|
||||
}
|
||||
if math.IsNaN(a) || math.IsNaN(b) {
|
||||
return 0, 0, base.Errf("Integrate: bounds must not be NaN")
|
||||
}
|
||||
sign := 1.0
|
||||
if b < a {
|
||||
a, b = b, a
|
||||
sign = -1
|
||||
}
|
||||
if a == b {
|
||||
return 0, 0, nil
|
||||
}
|
||||
// Map every bound combination onto a plain finite interval with a
|
||||
// wrapped integrand carrying the substitution's Jacobian.
|
||||
lo, hi := 0.0, 1.0
|
||||
g := f
|
||||
switch {
|
||||
case a == math.Inf(-1) && b == math.Inf(1):
|
||||
g = func(t float64) (float64, error) {
|
||||
u := t - 0.5
|
||||
den := 0.25 - u*u
|
||||
if den <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
x := 2 * u / den
|
||||
fx, ferr := f(x)
|
||||
if ferr != nil {
|
||||
return 0, ferr
|
||||
}
|
||||
return fx * (0.5 + 2*u*u) / (den * den), nil
|
||||
}
|
||||
case a == math.Inf(-1):
|
||||
g = func(t float64) (float64, error) {
|
||||
x := b - t/(1-t)
|
||||
fx, ferr := f(x)
|
||||
if ferr != nil {
|
||||
return 0, ferr
|
||||
}
|
||||
return fx / (1 - t) / (1 - t), nil
|
||||
}
|
||||
case b == math.Inf(1):
|
||||
g = func(t float64) (float64, error) {
|
||||
x := a + t/(1-t)
|
||||
fx, ferr := f(x)
|
||||
if ferr != nil {
|
||||
return 0, ferr
|
||||
}
|
||||
return fx / (1 - t) / (1 - t), nil
|
||||
}
|
||||
default:
|
||||
lo, hi = a, b
|
||||
}
|
||||
|
||||
nodes10, w10, err := GaussLegendreNodes(10)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
nodes21, w21, err := GaussLegendreNodes(21)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
rule := func(g func(float64) (float64, error), l, r float64, xs, ws []float64) (float64, error) {
|
||||
mid, half := (l+r)/2, (r-l)/2
|
||||
total := 0.0
|
||||
for i := range xs {
|
||||
fx, ferr := g(mid + half*xs[i])
|
||||
if ferr != nil {
|
||||
return 0, ferr
|
||||
}
|
||||
total += ws[i] * fx
|
||||
}
|
||||
return total * half, nil
|
||||
}
|
||||
|
||||
// Each leaf carries its 21-point value and its error, the gap to
|
||||
// the 10-point rule over the same interval.
|
||||
type leaf struct {
|
||||
l, r, value, err float64
|
||||
}
|
||||
measure := func(l, r float64) (leaf, error) {
|
||||
v21, err := rule(g, l, r, nodes21, w21)
|
||||
if err != nil {
|
||||
return leaf{}, err
|
||||
}
|
||||
v10, err := rule(g, l, r, nodes10, w10)
|
||||
if err != nil {
|
||||
return leaf{}, err
|
||||
}
|
||||
// An infinite integrand value would poison errSum with NaN and
|
||||
// slip through the loop condition (NaN comparisons are false),
|
||||
// publishing a bogus integral, so both non-finite kinds are
|
||||
// refused exactly as IntegrateND refuses them.
|
||||
if math.IsNaN(v21) || math.IsNaN(v10) || math.IsInf(v21, 0) || math.IsInf(v10, 0) {
|
||||
return leaf{}, base.Errf("the integrand returned a non-finite value on [%g, %g]", l, r)
|
||||
}
|
||||
return leaf{l: l, r: r, value: v21, err: math.Abs(v21 - v10)}, nil
|
||||
}
|
||||
|
||||
first, err := measure(lo, hi)
|
||||
if err != nil {
|
||||
return 0, 0, base.Errf("Integrate: %w", err)
|
||||
}
|
||||
leaves := []leaf{first}
|
||||
budget := func() (total, errSum float64) {
|
||||
for _, s := range leaves {
|
||||
total += s.value
|
||||
errSum += s.err
|
||||
}
|
||||
return total, errSum
|
||||
}
|
||||
value, errSum := budget()
|
||||
for errSum > math.Max(opts.AbsTol, opts.RelTol*math.Abs(value)) {
|
||||
if len(leaves) >= opts.MaxIntervals {
|
||||
return 0, 0, base.Errf("Integrate: error estimate %g exceeds the tolerance within %d subintervals",
|
||||
errSum, opts.MaxIntervals)
|
||||
}
|
||||
// Bisect the leaf that contributes the most error.
|
||||
worst := 0
|
||||
for i, s := range leaves {
|
||||
if s.err > leaves[worst].err {
|
||||
worst = i
|
||||
}
|
||||
}
|
||||
w := leaves[worst]
|
||||
left, lerr := measure(w.l, (w.l+w.r)/2)
|
||||
if lerr != nil {
|
||||
return 0, 0, base.Errf("Integrate: %w", lerr)
|
||||
}
|
||||
right, rerr := measure((w.l+w.r)/2, w.r)
|
||||
if rerr != nil {
|
||||
return 0, 0, base.Errf("Integrate: %w", rerr)
|
||||
}
|
||||
leaves[worst] = left
|
||||
leaves = append(leaves, right)
|
||||
value, errSum = budget()
|
||||
}
|
||||
return sign * value, errSum, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Benchmarks for the adaptive cubature's low-dimensional workloads,
|
||||
// the ones whose bisection loop turns often enough against a cheap
|
||||
// integrand that the worst-box selection is a visible share of the
|
||||
// cost. The three-dimensional workload lives in bench_test.go.
|
||||
|
||||
// BenchmarkIntegrateND1D runs the oscillatory task of
|
||||
// TestCubatureMatches1D: a degenerate dimension whose boxes bisect
|
||||
// for sixteen evaluations apiece, so the selection runs at its
|
||||
// cheapest per box.
|
||||
func BenchmarkIntegrateND1D(b *testing.B) {
|
||||
f := func(x []float64) float64 { return math.Exp(-x[0]) * math.Cos(3*x[0]) }
|
||||
lo := []float64{0}
|
||||
hi := []float64{5}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateND(f, lo, hi, CubatureOptions{Tolerance: 1e-12}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkIntegrateND2D runs the Gaussian of TestCubatureGaussian at
|
||||
// its test tolerance: the subdivision reaches into the thousands of
|
||||
// boxes, the regime where the worst-box choice dominates over the
|
||||
// arithmetic inside each box.
|
||||
func BenchmarkIntegrateND2D(b *testing.B) {
|
||||
f := func(x []float64) float64 { return math.Exp(-x[0]*x[0] - x[1]*x[1]) }
|
||||
lo := []float64{-3, -3}
|
||||
hi := []float64{3, 3}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateND(f, lo, hi, CubatureOptions{Tolerance: 1e-11}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkIntegrateFilon runs the oscillatory integral over enough
|
||||
// carrier wavelengths that the automatic panel count keeps the weights
|
||||
// busy: the amplitude stays smooth, so the per-panel cost is the node
|
||||
// sweep and the phase rotation.
|
||||
func BenchmarkIntegrateFilon(b *testing.B) {
|
||||
f := func(x float64) (float64, error) { return math.Exp(-0.1 * x), nil }
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, _, err := IntegrateFilon(f, 0, 100, 40*math.Pi, FilonOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func quadValue(t *testing.T, f func(float64) (float64, error), a, b float64, opts QuadratureOptions) (float64, float64) {
|
||||
t.Helper()
|
||||
v, errEst, err := IntegrateFunction(f, a, b, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Integrate: %v", err)
|
||||
}
|
||||
return v, errEst
|
||||
}
|
||||
|
||||
func TestIntegratePolynomials(t *testing.T) {
|
||||
// Degree 14 on 21 points: exact by construction.
|
||||
v, _ := quadValue(t, func(x float64) (float64, error) { return math.Pow(x, 7), nil }, 0, 1, QuadratureOptions{})
|
||||
if math.Abs(v-1.0/8) > 1e-14 {
|
||||
t.Fatalf("integral of x^7 = %.16g, want 0.125", v)
|
||||
}
|
||||
v, _ = quadValue(t, func(x float64) (float64, error) { return math.Pow(x, 14), nil }, -1, 1, QuadratureOptions{})
|
||||
if math.Abs(v-2.0/15) > 1e-14 {
|
||||
t.Fatalf("integral of x^14 = %.16g, want %g", v, 2.0/15)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrateSmooth(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
f func(float64) (float64, error)
|
||||
a, b float64
|
||||
want float64
|
||||
}{
|
||||
{"sine over a period", func(x float64) (float64, error) { return math.Sin(x), nil }, 0, math.Pi, 2},
|
||||
{"exponential", func(x float64) (float64, error) { return math.Exp(x), nil }, -1, 1, 2 * math.Sinh(1)},
|
||||
{"arctangent derivative", func(x float64) (float64, error) { return 1 / (1 + x*x), nil }, 0, 1, math.Pi / 4},
|
||||
{"gaussian", func(x float64) (float64, error) { return math.Exp(-x * x), nil }, 0, 5, 0.5 * math.Sqrt(math.Pi)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
v, est := quadValue(t, c.f, c.a, c.b, QuadratureOptions{})
|
||||
if math.Abs(v-c.want) > 1e-11 {
|
||||
t.Fatalf("%s: %.14g, want %.14g", c.name, v, c.want)
|
||||
}
|
||||
if math.Abs(v-c.want) > 10*est+1e-14 {
|
||||
t.Fatalf("%s: error estimate %g understates the true error %g", c.name, est, math.Abs(v-c.want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrateOscillatory(t *testing.T) {
|
||||
v, _ := quadValue(t, func(x float64) (float64, error) { return math.Sin(x), nil }, 0, 10*math.Pi, QuadratureOptions{})
|
||||
if math.Abs(v) > 1e-9 {
|
||||
t.Fatalf("ten sine periods integrate to %g, want 0", v)
|
||||
}
|
||||
v, _ = quadValue(t, func(x float64) (float64, error) { return math.Sin(30*x) * math.Exp(-x), nil },
|
||||
0, 40, QuadratureOptions{RelTol: 1e-9})
|
||||
want := 30.0 / 901.0 // exact over [0, inf): 30/(30^2 + 1)
|
||||
if math.Abs(v-want) > 1e-8 {
|
||||
t.Fatalf("damped oscillation = %.14g, want %.14g", v, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrateSharpPeak(t *testing.T) {
|
||||
// A Lorentzian a hundred times narrower than the interval forces
|
||||
// deep subdivision; the answer must land on the exact closed form
|
||||
// 2·arctan(100). (A peak narrower than the first rule's node
|
||||
// spacing would be invisible to any sampled scheme, which the doc
|
||||
// contract states.)
|
||||
v, _ := quadValue(t, func(x float64) (float64, error) {
|
||||
return 10 / (1 + 100*x*x), nil
|
||||
}, -10, 10, QuadratureOptions{RelTol: 1e-10})
|
||||
if want := 2 * math.Atan(100); math.Abs(v-want) > 1e-9 {
|
||||
t.Fatalf("narrow Lorentzian = %.12g, want %.12g", v, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrateInfiniteBounds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
f func(float64) (float64, error)
|
||||
a, b float64
|
||||
want float64
|
||||
}{
|
||||
{"exponential tail", func(x float64) (float64, error) { return math.Exp(-x), nil }, 0, math.Inf(1), 1},
|
||||
{"cauchy tail", func(x float64) (float64, error) { return 1 / (1 + x*x), nil }, 0, math.Inf(1), math.Pi / 2},
|
||||
{"full gaussian", func(x float64) (float64, error) { return math.Exp(-x * x), nil }, math.Inf(-1), math.Inf(1), math.Sqrt(math.Pi)},
|
||||
{"negative tail", func(x float64) (float64, error) { return math.Exp(x), nil }, math.Inf(-1), 0, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
v, _ := quadValue(t, c.f, c.a, c.b, QuadratureOptions{})
|
||||
if math.Abs(v-c.want) > 1e-9 {
|
||||
t.Fatalf("%s: %.14g, want %.14g", c.name, v, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrateOrientation(t *testing.T) {
|
||||
sin := func(x float64) (float64, error) { return math.Sin(x), nil }
|
||||
forward, _ := quadValue(t, sin, 0, math.Pi, QuadratureOptions{})
|
||||
backward, _ := quadValue(t, sin, math.Pi, 0, QuadratureOptions{})
|
||||
if math.Abs(backward+forward) > 1e-14 {
|
||||
t.Fatalf("reversed integral = %g, want %g", backward, -forward)
|
||||
}
|
||||
if v, _ := quadValue(t, sin, 2, 2, QuadratureOptions{}); v != 0 {
|
||||
t.Fatalf("empty interval = %g, want 0", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrateErrors(t *testing.T) {
|
||||
boom := func(x float64) (float64, error) {
|
||||
if x > 0.5 {
|
||||
return 0, base.Errf("integrand failed at %g", x)
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
if _, _, err := IntegrateFunction(boom, 0, 1, QuadratureOptions{}); err == nil {
|
||||
t.Fatal("integrand error: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateFunction(func(x float64) (float64, error) { return x, nil },
|
||||
math.NaN(), 1, QuadratureOptions{}); err == nil {
|
||||
t.Fatal("NaN bound: want an error")
|
||||
}
|
||||
// A flat integrand over an infinite interval diverges: the
|
||||
// adaptation must report it, not return a number.
|
||||
if _, _, err := IntegrateFunction(func(x float64) (float64, error) { return 1, nil },
|
||||
0, math.Inf(1), QuadratureOptions{MaxIntervals: 8}); err == nil {
|
||||
t.Fatal("divergent integral: want an error")
|
||||
}
|
||||
// A tolerance no budget can meet must be reported.
|
||||
if _, _, err := IntegrateFunction(func(x float64) (float64, error) { return math.Exp(-1000 * (x - 0.5) * (x - 0.5)), nil },
|
||||
0, 1, QuadratureOptions{RelTol: 1e-16, AbsTol: 0, MaxIntervals: 4}); err == nil {
|
||||
t.Fatal("exhausted budget: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGaussLegendreNodes(t *testing.T) {
|
||||
nodes, weights, err := GaussLegendreNodes(8)
|
||||
if err != nil {
|
||||
t.Fatalf("GaussLegendreNodes: %v", err)
|
||||
}
|
||||
total := 0.0
|
||||
for i := range 8 {
|
||||
total += weights[i]
|
||||
if math.Abs(nodes[i]+nodes[7-i]) > 1e-14 {
|
||||
t.Fatalf("nodes %d and %d are not symmetric", i, 7-i)
|
||||
}
|
||||
if i > 0 && nodes[i] <= nodes[i-1] {
|
||||
t.Fatalf("nodes are not ascending at %d", i)
|
||||
}
|
||||
}
|
||||
if math.Abs(total-2) > 1e-14 {
|
||||
t.Fatalf("weight sum = %.16g, want 2", total)
|
||||
}
|
||||
// A 16th degree polynomial integrates exactly on 8 points.
|
||||
moment := 0.0
|
||||
for i := range 8 {
|
||||
moment += weights[i] * math.Pow(nodes[i], 14)
|
||||
}
|
||||
if math.Abs(moment-2.0/15) > 1e-14 {
|
||||
t.Fatalf("moment of x^14 = %.16g, want %g", moment, 2.0/15)
|
||||
}
|
||||
// The cache returns the same arrays.
|
||||
again, _, err := GaussLegendreNodes(8)
|
||||
if err != nil {
|
||||
t.Fatalf("cached call: %v", err)
|
||||
}
|
||||
if &again[0] != &nodes[0] {
|
||||
t.Fatal("cached nodes are not the cached arrays")
|
||||
}
|
||||
if _, _, err := GaussLegendreNodes(0); err == nil {
|
||||
t.Fatal("n = 0: want an error")
|
||||
}
|
||||
if _, _, err := GaussLegendreNodes(129); err == nil {
|
||||
t.Fatal("n = 129: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateFunctionNaNIsError pins the NaN contract: an integrand
|
||||
// that yields NaN makes IntegrateFunction return an error instead of
|
||||
// a silent NaN integral (the first estimate and every bisected leaf
|
||||
// are checked).
|
||||
func TestIntegrateFunctionNaNIsError(t *testing.T) {
|
||||
f := func(x float64) (float64, error) {
|
||||
if math.Abs(x) > 0.25 {
|
||||
return math.NaN(), nil
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
if _, _, err := IntegrateFunction(f, -1, 1, QuadratureOptions{}); err == nil {
|
||||
t.Fatal("expected an error for an integrand that returns NaN")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Symplectic integration for separable Hamiltonian systems, where the
|
||||
// energy splits as H(q, p) = T(p) + V(q): Newtonian mechanics, N-body
|
||||
// gravity, molecular dynamics. The adaptive Runge-Kutta pair that
|
||||
// drives IntegrateODE is accurate per step but dissipates energy
|
||||
// systematically, so a two-hundred-period orbit spirals inward or
|
||||
// outward; the leapfrog structure below is symplectic, which means it
|
||||
// conserves a shadow Hamiltonian exactly and keeps the true energy
|
||||
// oscillating in a bounded band forever. That long-time fidelity, not
|
||||
// per-step accuracy, is what separates integrators for celestial
|
||||
// mechanics and molecular dynamics from general-purpose ones.
|
||||
//
|
||||
// The scheme is velocity Verlet, a kick-drift-kick leapfrog of second
|
||||
// order: half a momentum kick, a full position drift, another half
|
||||
// kick with the force at the new position. Unit masses are assumed
|
||||
// (p is the velocity); scale the momentum by the masses beforehand or
|
||||
// fold them into the acceleration.
|
||||
|
||||
// IntegrateVerlet integrates a separable Hamiltonian system with unit
|
||||
// masses over an even time grid: accel returns the acceleration
|
||||
// −∂V/∂q at a position, q0 and p0 are the initial position and
|
||||
// momentum (velocity), and steps fixes the number of equal steps, so
|
||||
// the i-th returned pair sits at t0 + i·h with h = (t1−t0)/steps.
|
||||
// positions[0] is q0 and momenta[0] is p0. The state may be float64
|
||||
// or float32, read through per-element accessors, and the returned
|
||||
// arrays are float64. The step size stays fixed by design:
|
||||
// adaptivity would destroy the symplectic property the method exists
|
||||
// for. A negative or zero-length acceleration vector, a mismatched
|
||||
// pair, a complex or int state, or an empty state is an error.
|
||||
func IntegrateVerlet(accel func(q *core.Array) (*core.Array, error),
|
||||
t0, t1 float64, q0, p0 *core.Array, steps int) (positions, momenta []*core.Array, err error) {
|
||||
if steps <= 0 {
|
||||
return nil, nil, base.Errf("IntegrateVerlet: steps must be ≥ 1, got %d", steps)
|
||||
}
|
||||
if q0.Dtype() == core.Complex || p0.Dtype() == core.Complex {
|
||||
return nil, nil, base.Errf("IntegrateVerlet: complex states are not supported")
|
||||
}
|
||||
// The whole integer class follows Int into the standing refusal:
|
||||
// bool and the narrow widths carry a discrete state, which has no
|
||||
// place in a continuous integrator, and the wording is Int's own.
|
||||
if integerState(q0.Dtype()) || integerState(p0.Dtype()) {
|
||||
return nil, nil, base.Errf("IntegrateVerlet: int states cannot integrate, use float or float32 states")
|
||||
}
|
||||
if q0.NDim() != 1 || p0.NDim() != 1 || q0.Len() != p0.Len() {
|
||||
return nil, nil, base.Errf("IntegrateVerlet: position and momentum must be vectors of equal length, got %s and %s",
|
||||
base.ShapeText(q0.Shape()), base.ShapeText(p0.Shape()))
|
||||
}
|
||||
n := q0.Len()
|
||||
if n == 0 {
|
||||
return nil, nil, base.Errf("IntegrateVerlet: the state must not be empty")
|
||||
}
|
||||
// Read the initial state element-wise: RawFloats backs float64
|
||||
// payloads only, so a float32 state would come through as nil.
|
||||
// A non-finite entry is refused up front: it would propagate
|
||||
// through every kick and drift silently.
|
||||
q := make([]float64, n)
|
||||
p := make([]float64, n)
|
||||
for i := range n {
|
||||
q[i] = q0.FloatAt(i)
|
||||
p[i] = p0.FloatAt(i)
|
||||
if math.IsNaN(q[i]) || math.IsInf(q[i], 0) {
|
||||
return nil, nil, base.Errf("IntegrateVerlet: q0 holds the non-finite value %g at %d", q[i], i)
|
||||
}
|
||||
if math.IsNaN(p[i]) || math.IsInf(p[i], 0) {
|
||||
return nil, nil, base.Errf("IntegrateVerlet: p0 holds the non-finite value %g at %d", p[i], i)
|
||||
}
|
||||
}
|
||||
a := make([]float64, n)
|
||||
// One cached read-only view serves every acceleration call: the
|
||||
// position slice is the run's own buffer, stable for the whole
|
||||
// integration, so the wrapper is built once per run.
|
||||
var views odeViews
|
||||
eval := func(x []float64, out []float64) error {
|
||||
v, err := accel(views.of(x))
|
||||
if err != nil {
|
||||
return base.Errf("IntegrateVerlet: %w", err)
|
||||
}
|
||||
if v.NDim() != 1 || v.Len() != n {
|
||||
return base.Errf("IntegrateVerlet: accel returned shape %s, want a vector of length %d",
|
||||
base.ShapeText(v.Shape()), n)
|
||||
}
|
||||
readVector(out, v)
|
||||
// Like RK4: a non-finite acceleration would flow through the
|
||||
// kicks silently, and the published trajectory would be NaN
|
||||
// with a nil error.
|
||||
for i := range n {
|
||||
if math.IsNaN(out[i]) || math.IsInf(out[i], 0) {
|
||||
return base.Errf("IntegrateVerlet: accel returned the non-finite value %g at coordinate %d", out[i], i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := eval(q, a); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
h := (t1 - t0) / float64(steps)
|
||||
positions = make([]*core.Array, steps+1)
|
||||
momenta = make([]*core.Array, steps+1)
|
||||
positions[0] = arrayFromVector(q)
|
||||
momenta[0] = arrayFromVector(p)
|
||||
for s := 1; s <= steps; s++ {
|
||||
// Kick, drift, kick: two half kicks bracket the drift, so the
|
||||
// force is evaluated once per step.
|
||||
for i := range n {
|
||||
p[i] += h / 2 * a[i]
|
||||
q[i] += h * p[i]
|
||||
}
|
||||
if err := eval(q, a); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for i := range n {
|
||||
p[i] += h / 2 * a[i]
|
||||
}
|
||||
positions[s] = arrayFromVector(q)
|
||||
momenta[s] = arrayFromVector(p)
|
||||
}
|
||||
return positions, momenta, nil
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
"sourcedock.dev/petrbalvin/tensor/optim"
|
||||
)
|
||||
|
||||
// Higher-order symplectic integration, extending the velocity-Verlet
|
||||
// leapfrog of symplectic.go in the two directions it cannot go by
|
||||
// itself: to fourth order while staying explicit (Yoshida's
|
||||
// composition of three leapfrog sub-steps), and to non-separable
|
||||
// Hamiltonians at all (the implicit midpoint rule, whose implicit
|
||||
// stage is a root find per step). Both keep the leapfrog's reason for
|
||||
// being: the exact conservation of a shadow Hamiltonian, so the true
|
||||
// energy oscillates in a bounded band instead of drifting, over
|
||||
// arbitrarily long runs, at a fixed step by design.
|
||||
|
||||
// yoshidaW1 and yoshidaW0 are Yoshida's triple-jump weights: the
|
||||
// composition V(w1·h)·V(w0·h)·V(w1·h) of three velocity-Verlet
|
||||
// sub-steps is fourth order exactly when w0 + 2·w1 = 1, and the
|
||||
// classic choice w1 = 1/(2 − ∛2), w0 = −∛2/(2 − ∛2) satisfies that
|
||||
// identity with w1 positive and w0 negative (the middle sub-step runs
|
||||
// backwards in time, which is what buys the order).
|
||||
var (
|
||||
yoshidaW1 = 1 / (2 - math.Cbrt(2))
|
||||
yoshidaW0 = -math.Cbrt(2) * yoshidaW1
|
||||
)
|
||||
|
||||
// IntegrateYoshida4 integrates a separable Hamiltonian system with
|
||||
// unit masses over an even time grid by Yoshida's fourth-order
|
||||
// composition of the kick-drift-kick leapfrog: three velocity-Verlet
|
||||
// sub-steps of widths w1·h, w0·h and w1·h per step, with the weights
|
||||
// above. The contract is IntegrateVerlet's: accel returns the
|
||||
// acceleration −∂V/∂q at a position, q0 and p0 are the initial
|
||||
// position and momentum (velocity), steps fixes the number of equal
|
||||
// steps h = (t1−t0)/steps, positions[s] and momenta[s] sit at
|
||||
// t0 + s·h, and the state may be float64 or float32. The step size
|
||||
// stays fixed by design. The error contract is IntegrateVerlet's
|
||||
// too: a mismatched pair, a complex or int state, an empty state or
|
||||
// a non-finite acceleration is an error, never a silently corrupted
|
||||
// trajectory.
|
||||
func IntegrateYoshida4(accel func(q *core.Array) (*core.Array, error),
|
||||
t0, t1 float64, q0, p0 *core.Array, steps int) (positions, momenta []*core.Array, err error) {
|
||||
const name = "IntegrateYoshida4"
|
||||
if steps <= 0 {
|
||||
return nil, nil, base.Errf("%s: steps must be ≥ 1, got %d", name, steps)
|
||||
}
|
||||
q, p, n, verr := verletPrelude(name, q0, p0)
|
||||
if verr != nil {
|
||||
return nil, nil, verr
|
||||
}
|
||||
eval := verletForce(name, accel, n)
|
||||
a := make([]float64, n)
|
||||
if err := eval(q, a); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
h := (t1 - t0) / float64(steps)
|
||||
positions = make([]*core.Array, steps+1)
|
||||
momenta = make([]*core.Array, steps+1)
|
||||
positions[0] = arrayFromVector(q)
|
||||
momenta[0] = arrayFromVector(p)
|
||||
weights := [3]float64{yoshidaW1, yoshidaW0, yoshidaW1}
|
||||
for s := 1; s <= steps; s++ {
|
||||
// Three kick-drift-kick sub-steps. The acceleration that ends
|
||||
// one sub-step is exactly the one the next sub-step's first
|
||||
// kick needs, so the whole step costs three force evaluations.
|
||||
for _, w := range weights {
|
||||
tau := w * h
|
||||
for i := range n {
|
||||
p[i] += tau / 2 * a[i]
|
||||
q[i] += tau * p[i]
|
||||
}
|
||||
if err := eval(q, a); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for i := range n {
|
||||
p[i] += tau / 2 * a[i]
|
||||
}
|
||||
}
|
||||
positions[s] = arrayFromVector(q)
|
||||
momenta[s] = arrayFromVector(p)
|
||||
}
|
||||
return positions, momenta, nil
|
||||
}
|
||||
|
||||
// verletPrelude is the validation the Verlet family shares: the
|
||||
// dtype, shape and finiteness gates of IntegrateVerlet and the
|
||||
// element-wise read of the initial state (RawFloats backs float64
|
||||
// payloads only, so a float32 state would come through as nil). It
|
||||
// returns the working position and momentum and the state length.
|
||||
func verletPrelude(name string, q0, p0 *core.Array) (q, p []float64, n int, err error) {
|
||||
if q0.Dtype() == core.Complex || p0.Dtype() == core.Complex {
|
||||
return nil, nil, 0, base.Errf("%s: complex states are not supported", name)
|
||||
}
|
||||
// The whole integer class follows Int into the standing refusal:
|
||||
// bool and the narrow widths carry a discrete state, which has no
|
||||
// place in a continuous integrator, and the wording is Int's own.
|
||||
if integerState(q0.Dtype()) || integerState(p0.Dtype()) {
|
||||
return nil, nil, 0, base.Errf("%s: int states cannot integrate, use float or float32 states", name)
|
||||
}
|
||||
if q0.NDim() != 1 || p0.NDim() != 1 || q0.Len() != p0.Len() {
|
||||
return nil, nil, 0, base.Errf("%s: position and momentum must be vectors of equal length, got %s and %s",
|
||||
name, base.ShapeText(q0.Shape()), base.ShapeText(p0.Shape()))
|
||||
}
|
||||
n = q0.Len()
|
||||
if n == 0 {
|
||||
return nil, nil, 0, base.Errf("%s: the state must not be empty", name)
|
||||
}
|
||||
q = make([]float64, n)
|
||||
p = make([]float64, n)
|
||||
for i := range n {
|
||||
q[i] = q0.FloatAt(i)
|
||||
p[i] = p0.FloatAt(i)
|
||||
if math.IsNaN(q[i]) || math.IsInf(q[i], 0) {
|
||||
return nil, nil, 0, base.Errf("%s: q0 holds the non-finite value %g at %d", name, q[i], i)
|
||||
}
|
||||
if math.IsNaN(p[i]) || math.IsInf(p[i], 0) {
|
||||
return nil, nil, 0, base.Errf("%s: p0 holds the non-finite value %g at %d", name, p[i], i)
|
||||
}
|
||||
}
|
||||
return q, p, n, nil
|
||||
}
|
||||
|
||||
// integerState reports whether dt is one of the integer-class state
|
||||
// dtypes the symplectic family refuses: bool and the narrow integer
|
||||
// widths follow Int into the standing "int states cannot integrate"
|
||||
// refusal, exactly as the round's follow-Int rule requires. The float
|
||||
// dtypes, float16 included, keep the treatment they carry today.
|
||||
func integerState(dt core.Dtype) bool {
|
||||
switch dt {
|
||||
case core.Bool, core.Int, core.Int8, core.Uint8, core.Int16, core.Uint16, core.Int32, core.Uint32:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// verletForce wraps the acceleration callback the way the Verlet
|
||||
// family evaluates it: shape-checked, non-finite-refused, and read
|
||||
// into a plain float64 buffer. One cached view per wrapper serves
|
||||
// every call: the family evaluates the force at the same position
|
||||
// buffer throughout a run, so the wrapper is built once.
|
||||
func verletForce(name string, accel func(q *core.Array) (*core.Array, error), n int) func(x []float64, out []float64) error {
|
||||
var views odeViews
|
||||
return func(x []float64, out []float64) error {
|
||||
v, err := accel(views.of(x))
|
||||
if err != nil {
|
||||
return base.Errf("%s: %w", name, err)
|
||||
}
|
||||
if v.NDim() != 1 || v.Len() != n {
|
||||
return base.Errf("%s: accel returned shape %s, want a vector of length %d",
|
||||
name, base.ShapeText(v.Shape()), n)
|
||||
}
|
||||
readVector(out, v)
|
||||
// A non-finite acceleration would flow through the kicks
|
||||
// silently, and the published trajectory would be NaN with a
|
||||
// nil error.
|
||||
for i := range n {
|
||||
if math.IsNaN(out[i]) || math.IsInf(out[i], 0) {
|
||||
return base.Errf("%s: accel returned the non-finite value %g at coordinate %d", name, out[i], i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MidpointOptions tunes the per-step implicit stage of
|
||||
// IntegrateMidpoint. Tolerance ≤ 0 means 1e-13 (the stage solve is a
|
||||
// root find, and the energy band the rule is famous for wants it
|
||||
// tight); MaxIterations ≤ 0 means 100.
|
||||
type MidpointOptions struct {
|
||||
Tolerance float64
|
||||
MaxIterations int
|
||||
}
|
||||
|
||||
// IntegrateMidpoint integrates the general Hamiltonian flow
|
||||
// dz/dt = J·∇H(z), z = (q, p), by the implicit midpoint rule
|
||||
// z_{n+1} = z_n + h·J·∇H((z_n + z_{n+1})/2) over an even time grid.
|
||||
// gradH returns the gradient of H as the stacked vector
|
||||
// (∂H/∂q, ∂H/∂p); H itself never has to be separable, which is the
|
||||
// rule's claim over the leapfrog family. Each step's implicit stage
|
||||
// is solved by the library's damped Newton root find for systems
|
||||
// (optim.FindRootSystem under the given options), seeded with the
|
||||
// current state, so the per-step work is a handful of gradient
|
||||
// evaluations. Equilibria map to themselves exactly, and every
|
||||
// quadratic invariant of the flow, H included when H is quadratic,
|
||||
// is conserved to rounding. The contract otherwise mirrors
|
||||
// IntegrateVerlet: q0 and p0 are the initial position and momentum,
|
||||
// steps fixes the number of equal steps h = (t1−t0)/steps,
|
||||
// positions[s] and momenta[s] sit at t0 + s·h, the step size stays
|
||||
// fixed by design, and a mismatched pair, a complex or int state, an
|
||||
// empty state, a non-finite gradient or a root find that cannot
|
||||
// converge is an error, never a silent answer.
|
||||
func IntegrateMidpoint(gradH func(z *core.Array) (*core.Array, error),
|
||||
t0, t1 float64, q0, p0 *core.Array, steps int, opts MidpointOptions) (positions, momenta []*core.Array, err error) {
|
||||
const name = "IntegrateMidpoint"
|
||||
if opts.Tolerance <= 0 {
|
||||
opts.Tolerance = 1e-13
|
||||
}
|
||||
if opts.MaxIterations <= 0 {
|
||||
opts.MaxIterations = 100
|
||||
}
|
||||
if steps <= 0 {
|
||||
return nil, nil, base.Errf("%s: steps must be ≥ 1, got %d", name, steps)
|
||||
}
|
||||
q, p, n, verr := verletPrelude(name, q0, p0)
|
||||
if verr != nil {
|
||||
return nil, nil, verr
|
||||
}
|
||||
m := 2 * n
|
||||
z := make([]float64, m)
|
||||
copy(z, q)
|
||||
copy(z[n:], p)
|
||||
// One cached read-only view serves every gradient call: mid is the
|
||||
// run's own stable buffer, so the wrapper is built once.
|
||||
var views odeViews
|
||||
// The gradient evaluator with the midpoint rule's finiteness gate:
|
||||
// a non-finite gradient would flow through the stage equation
|
||||
// silently.
|
||||
gradient := func(gz []float64, out []float64) error {
|
||||
v, err := gradH(views.of(gz))
|
||||
if err != nil {
|
||||
return base.Errf("%s: %w", name, err)
|
||||
}
|
||||
if v.NDim() != 1 || v.Len() != m {
|
||||
return base.Errf("%s: gradH returned shape %s, want a vector of length %d",
|
||||
name, base.ShapeText(v.Shape()), m)
|
||||
}
|
||||
readVector(out, v)
|
||||
for i := range m {
|
||||
if math.IsNaN(out[i]) || math.IsInf(out[i], 0) {
|
||||
return base.Errf("%s: gradH returned the non-finite value %g at coordinate %d", name, out[i], i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
mid := make([]float64, m)
|
||||
grad := make([]float64, m)
|
||||
h := (t1 - t0) / float64(steps)
|
||||
// The stage residual: x is the candidate z_{n+1}, and the flow
|
||||
// J·∇H flips the two halves with a sign: q' = ∂H/∂p, p' = −∂H/∂q.
|
||||
// FindRootSystem copies its input before every residual call and
|
||||
// reads the returned residual before the next one, so reusing mid,
|
||||
// grad and out across calls is the same arithmetic the fresh
|
||||
// buffers would give.
|
||||
out := make([]float64, m)
|
||||
stage := func(x *core.Array) (*core.Array, error) {
|
||||
for i := range m {
|
||||
mid[i] = (z[i] + x.FloatAt(i)) / 2
|
||||
}
|
||||
if err := gradient(mid, grad); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range n {
|
||||
out[i] = x.FloatAt(i) - z[i] - h*grad[n+i]
|
||||
out[n+i] = x.FloatAt(n+i) - z[n+i] + h*grad[i]
|
||||
}
|
||||
return wrapVector(out), nil
|
||||
}
|
||||
positions = make([]*core.Array, steps+1)
|
||||
momenta = make([]*core.Array, steps+1)
|
||||
positions[0] = arrayFromVector(z[:n])
|
||||
momenta[0] = arrayFromVector(z[n:])
|
||||
for s := 1; s <= steps; s++ {
|
||||
solution, _, rerr := optim.FindRootSystem(stage, wrapVector(z), optim.RootSystemOptions{
|
||||
Tolerance: opts.Tolerance,
|
||||
MaxIterations: opts.MaxIterations,
|
||||
})
|
||||
if rerr != nil {
|
||||
return nil, nil, base.Errf("%s: %w", name, rerr)
|
||||
}
|
||||
for i := range m {
|
||||
z[i] = solution.FloatAt(i)
|
||||
}
|
||||
positions[s] = arrayFromVector(z[:n])
|
||||
momenta[s] = arrayFromVector(z[n:])
|
||||
}
|
||||
return positions, momenta, nil
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/base"
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// TestYoshidaWeightsIdentity pins the triple-jump weights: the
|
||||
// composition V(w1·h)·V(w0·h)·V(w1·h) is fourth order exactly when
|
||||
// w0 + 2·w1 = 1, with w1 positive and w0 negative.
|
||||
func TestYoshidaWeightsIdentity(t *testing.T) {
|
||||
if math.Abs(yoshidaW0+2*yoshidaW1-1) > 1e-15 {
|
||||
t.Fatalf("w0 + 2·w1 = %.17g, want 1", yoshidaW0+2*yoshidaW1)
|
||||
}
|
||||
if yoshidaW1 <= 0 || yoshidaW0 >= 0 {
|
||||
t.Fatalf("weights %g and %g, want w1 positive and w0 negative", yoshidaW0, yoshidaW1)
|
||||
}
|
||||
c := math.Cbrt(2)
|
||||
if math.Abs(yoshidaW1-1/(2-c)) > 1e-15 || math.Abs(yoshidaW0+c/(2-c)) > 1e-15 {
|
||||
t.Fatalf("weights %.17g and %.17g are not the triple-jump choice", yoshidaW0, yoshidaW1)
|
||||
}
|
||||
}
|
||||
|
||||
// harmonicEnergy returns the energy of a one-dimensional harmonic
|
||||
// state.
|
||||
func harmonicEnergy(q, p []float64, omega float64) float64 {
|
||||
return 0.5 * (p[0]*p[0] + omega*omega*q[0]*q[0])
|
||||
}
|
||||
|
||||
// yoshidaEnergyBand integrates the harmonic oscillator over periods
|
||||
// and returns the largest energy deviation from the initial one.
|
||||
func yoshidaEnergyBand(t *testing.T, perPeriod, periods int, verlet bool) float64 {
|
||||
t.Helper()
|
||||
const omega = 1.0
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
return core.MulF(q, -omega*omega), nil
|
||||
}
|
||||
q0 := mustFloats(t, []float64{1})
|
||||
p0 := mustFloats(t, []float64{0})
|
||||
steps := perPeriod * periods
|
||||
var positions, momenta []*core.Array
|
||||
var err error
|
||||
if verlet {
|
||||
positions, momenta, err = IntegrateVerlet(accel, 0, float64(periods)*2*math.Pi, q0, p0, steps)
|
||||
} else {
|
||||
positions, momenta, err = IntegrateYoshida4(accel, 0, float64(periods)*2*math.Pi, q0, p0, steps)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("integrator: %v", err)
|
||||
}
|
||||
e0 := harmonicEnergy([]float64{positions[0].FloatAt(0)}, []float64{momenta[0].FloatAt(0)}, omega)
|
||||
worst := 0.0
|
||||
q := make([]float64, 1)
|
||||
p := make([]float64, 1)
|
||||
for s := range positions {
|
||||
q[0] = positions[s].FloatAt(0)
|
||||
p[0] = momenta[s].FloatAt(0)
|
||||
if d := math.Abs(harmonicEnergy(q, p, omega) - e0); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
// TestYoshidaHarmonicFourthOrder pins the order: the harmonic
|
||||
// oscillator's energy-band error scales as h⁴ for Yoshida 4 (ratios
|
||||
// near 16 on halved steps) where Verlet's scales as h² (ratios near
|
||||
// 4), measured on the same problem.
|
||||
func TestYoshidaHarmonicFourthOrder(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
verlet bool
|
||||
low float64
|
||||
high float64
|
||||
}{{"Verlet", true, 3, 5.5}, {"Yoshida4", false, 12, 20}} {
|
||||
previous := 0.0
|
||||
for _, perPeriod := range []int{20, 40, 80} {
|
||||
band := yoshidaEnergyBand(t, perPeriod, 4, tc.verlet)
|
||||
t.Logf("%s steps/period %d: energy band %.3g", tc.name, perPeriod, band)
|
||||
if previous > 0 {
|
||||
if r := previous / band; r < tc.low || r > tc.high {
|
||||
t.Fatalf("%s: error ratio %.2f at %d steps/period, want the band [%g, %g]",
|
||||
tc.name, r, perPeriod, tc.low, tc.high)
|
||||
}
|
||||
}
|
||||
previous = band
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestYoshidaKeplerEnergyBand pins the long-time fidelity: the
|
||||
// Kepler two-body problem on an eccentric orbit keeps its energy in
|
||||
// a narrow band over twenty periods, and the orbit closes.
|
||||
func TestYoshidaKeplerEnergyBand(t *testing.T) {
|
||||
const eccentricity = 0.5
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
x, y := q.FloatAt(0), q.FloatAt(1)
|
||||
r3 := math.Pow(x*x+y*y, 1.5)
|
||||
return core.FromFloats([]float64{-x / r3, -y / r3}, 2)
|
||||
}
|
||||
q0 := mustFloats(t, []float64{1 + eccentricity, 0})
|
||||
p0 := mustFloats(t, []float64{0, math.Sqrt((1 - eccentricity) / (1 + eccentricity))})
|
||||
periods := 20
|
||||
stepsPerPeriod := 200
|
||||
positions, momenta, err := IntegrateYoshida4(accel, 0, float64(periods)*2*math.Pi, q0, p0, periods*stepsPerPeriod)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateYoshida4: %v", err)
|
||||
}
|
||||
energy := func(s int) float64 {
|
||||
vx := momenta[s].FloatAt(0)
|
||||
vy := momenta[s].FloatAt(1)
|
||||
x := positions[s].FloatAt(0)
|
||||
y := positions[s].FloatAt(1)
|
||||
return 0.5*(vx*vx+vy*vy) - 1/math.Hypot(x, y)
|
||||
}
|
||||
e0 := energy(0)
|
||||
worst := 0.0
|
||||
for s := range positions {
|
||||
if d := math.Abs(energy(s) - e0); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("Kepler e=0.5 over %d periods: energy band %.3g (E0 = %.6g)", periods, worst, e0)
|
||||
if worst > 1e-4*math.Abs(e0) {
|
||||
t.Fatalf("energy drifted by %.3g over %d periods", worst, periods)
|
||||
}
|
||||
// The closing error is the accumulated per-period phase error,
|
||||
// fourth order in h, not an energy drift: at 200 steps per period
|
||||
// it stays a few thousandths of the orbit radius.
|
||||
last := len(positions) - 1
|
||||
if math.Hypot(positions[last].FloatAt(0)-(1+eccentricity), positions[last].FloatAt(1)) > 5e-3 {
|
||||
t.Fatalf("the orbit did not close: q = (%.8g, %.8g)",
|
||||
positions[last].FloatAt(0), positions[last].FloatAt(1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestYoshidaErrors(t *testing.T) {
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
return core.MulF(q, -1), nil
|
||||
}
|
||||
q0 := mustFloats(t, []float64{1})
|
||||
p0 := mustFloats(t, []float64{0})
|
||||
if _, _, err := IntegrateYoshida4(accel, 0, 1, q0, p0, 0); err == nil {
|
||||
t.Fatal("zero steps: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateYoshida4(accel, 0, 1, q0, mustFloats(t, []float64{0, 1}), 5); err == nil {
|
||||
t.Fatal("mismatched vectors: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateYoshida4(accel, 0, 1, mustFloats(t, []float64{}), mustFloats(t, []float64{}), 5); err == nil {
|
||||
t.Fatal("empty state: want an error")
|
||||
}
|
||||
boom := func(*core.Array) (*core.Array, error) { return nil, base.Errf("accel failed") }
|
||||
if _, _, err := IntegrateYoshida4(boom, 0, 1, q0, p0, 5); err == nil || !stringsContains(err, "accel failed") {
|
||||
t.Fatal("a failing accel: want the error propagated")
|
||||
}
|
||||
wrong := func(*core.Array) (*core.Array, error) { return mustFloats(t, []float64{1, 1}), nil }
|
||||
if _, _, err := IntegrateYoshida4(wrong, 0, 1, q0, p0, 5); err == nil {
|
||||
t.Fatal("wrong accel shape: want an error")
|
||||
}
|
||||
// A non-finite acceleration is refused instead of publishing NaNs.
|
||||
nan := func(*core.Array) (*core.Array, error) { return mustFloats(t, []float64{math.NaN()}), nil }
|
||||
if _, _, err := IntegrateYoshida4(nan, 0, 1, q0, p0, 5); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatal("a NaN accel: want an error")
|
||||
}
|
||||
// Int and complex states are refused, float32 states integrate.
|
||||
pi0, err := core.FromInts([]int64{1}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := IntegrateYoshida4(accel, 0, 1, pi0, p0, 5); err == nil {
|
||||
t.Fatal("an int state: want an error")
|
||||
}
|
||||
q32, err := core.FromFloat32s([]float32{1}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p32, err := core.FromFloat32s([]float32{0}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := IntegrateYoshida4(accel, 0, 1, q32, p32, 5); err != nil {
|
||||
t.Fatalf("a float32 state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// midpointQP is the gradient of H(q, p) = q·p: the flow is
|
||||
// q' = q, p' = −p, whose midpoint solution is the Cayley transform
|
||||
// q_n = q0·((1+h/2)/(1−h/2))ⁿ, p_n = p0·((1−h/2)/(1+h/2))ⁿ, and
|
||||
// H = q·p is conserved exactly.
|
||||
func midpointQP(z *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{z.FloatAt(1), z.FloatAt(0)}, 2)
|
||||
}
|
||||
|
||||
// TestMidpointCayleyExact pins the analytic solution: for the
|
||||
// nonseparable H = q·p the midpoint iterates must land on the Cayley
|
||||
// transform and conserve H to rounding.
|
||||
func TestMidpointCayleyExact(t *testing.T) {
|
||||
const (
|
||||
q0 = 1.2
|
||||
p0 = 0.7
|
||||
steps = 50
|
||||
t1 = 1.0
|
||||
)
|
||||
h := t1 / steps
|
||||
positions, momenta, err := IntegrateMidpoint(midpointQP, 0, t1,
|
||||
mustFloats(t, []float64{q0}), mustFloats(t, []float64{p0}), steps, MidpointOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateMidpoint: %v", err)
|
||||
}
|
||||
up := (1 + h/2) / (1 - h/2)
|
||||
down := (1 - h/2) / (1 + h/2)
|
||||
for s := range positions {
|
||||
q := positions[s].FloatAt(0)
|
||||
p := momenta[s].FloatAt(0)
|
||||
wantQ := q0 * math.Pow(up, float64(s))
|
||||
wantP := p0 * math.Pow(down, float64(s))
|
||||
if math.Abs(q-wantQ) > 1e-12 || math.Abs(p-wantP) > 1e-12 {
|
||||
t.Fatalf("step %d: (q, p) = (%.14g, %.14g), want (%.14g, %.14g)", s, q, p, wantQ, wantP)
|
||||
}
|
||||
if math.Abs(q*p-q0*p0) > 1e-12 {
|
||||
t.Fatalf("step %d: H = %.17g, want %.17g", s, q*p, q0*p0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMidpointFixedPointExact pins the equilibrium property: an
|
||||
// equilibrium of the flow is a fixed point of the midpoint map,
|
||||
// exactly, with no roundoff drift.
|
||||
func TestMidpointFixedPointExact(t *testing.T) {
|
||||
positions, momenta, err := IntegrateMidpoint(midpointQP, 0, 0.5,
|
||||
mustFloats(t, []float64{0}), mustFloats(t, []float64{0}), 3, MidpointOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateMidpoint: %v", err)
|
||||
}
|
||||
for s := range positions {
|
||||
if positions[s].FloatAt(0) != 0 || momenta[s].FloatAt(0) != 0 {
|
||||
t.Fatalf("step %d: the fixed point moved to (%g, %g)",
|
||||
s, positions[s].FloatAt(0), momenta[s].FloatAt(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMidpointNonseparableBand pins the energy band on a genuinely
|
||||
// nonseparable two-degree system: H = ½(q₁²+1)(p₁²+1) +
|
||||
// ½(q₂²+1)(p₂²+1) cannot be written as T(p) + V(q), and the midpoint
|
||||
// rule must keep H inside a bounded band over the whole run.
|
||||
func TestMidpointNonseparableBand(t *testing.T) {
|
||||
gradH := func(z *core.Array) (*core.Array, error) {
|
||||
q1, q2 := z.FloatAt(0), z.FloatAt(1)
|
||||
p1, p2 := z.FloatAt(2), z.FloatAt(3)
|
||||
return core.FromFloats([]float64{
|
||||
q1 * (p1*p1 + 1), q2 * (p2*p2 + 1),
|
||||
p1 * (q1*q1 + 1), p2 * (q2*q2 + 1),
|
||||
}, 4)
|
||||
}
|
||||
hamiltonian := func(q1, q2, p1, p2 float64) float64 {
|
||||
return 0.5*(q1*q1+1)*(p1*p1+1) + 0.5*(q2*q2+1)*(p2*p2+1)
|
||||
}
|
||||
q0 := mustFloats(t, []float64{0.5, -0.4})
|
||||
p0 := mustFloats(t, []float64{0.3, 0.8})
|
||||
h0 := hamiltonian(0.5, -0.4, 0.3, 0.8)
|
||||
positions, momenta, err := IntegrateMidpoint(gradH, 0, 10, q0, p0, 1000, MidpointOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateMidpoint: %v", err)
|
||||
}
|
||||
worst := 0.0
|
||||
for s := range positions {
|
||||
h := hamiltonian(positions[s].FloatAt(0), positions[s].FloatAt(1),
|
||||
momenta[s].FloatAt(0), momenta[s].FloatAt(1))
|
||||
if d := math.Abs(h - h0); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
t.Logf("nonseparable two-degree band over t = 10 at h = 0.01: %.3g (H0 = %.6g)", worst, h0)
|
||||
if worst > 1e-3*h0 {
|
||||
t.Fatalf("energy drifted by %.3g (H0 = %.6g)", worst, h0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidpointErrors(t *testing.T) {
|
||||
q0 := mustFloats(t, []float64{1})
|
||||
p0 := mustFloats(t, []float64{0})
|
||||
if _, _, err := IntegrateMidpoint(midpointQP, 0, 1, q0, p0, 0, MidpointOptions{}); err == nil {
|
||||
t.Fatal("zero steps: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateMidpoint(midpointQP, 0, 1, q0, mustFloats(t, []float64{0, 1}), 5, MidpointOptions{}); err == nil {
|
||||
t.Fatal("mismatched vectors: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateMidpoint(midpointQP, 0, 1, mustFloats(t, []float64{}), mustFloats(t, []float64{}), 5, MidpointOptions{}); err == nil {
|
||||
t.Fatal("empty state: want an error")
|
||||
}
|
||||
pi0, err := core.FromInts([]int64{1}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := IntegrateMidpoint(midpointQP, 0, 1, pi0, p0, 5, MidpointOptions{}); err == nil {
|
||||
t.Fatal("an int state: want an error")
|
||||
}
|
||||
boom := func(*core.Array) (*core.Array, error) { return nil, base.Errf("gradH failed") }
|
||||
if _, _, err := IntegrateMidpoint(boom, 0, 1, q0, p0, 5, MidpointOptions{}); err == nil || !stringsContains(err, "gradH failed") {
|
||||
t.Fatal("a failing gradient: want the error propagated")
|
||||
}
|
||||
wrong := func(*core.Array) (*core.Array, error) { return mustFloats(t, []float64{1}), nil }
|
||||
if _, _, err := IntegrateMidpoint(wrong, 0, 1, q0, p0, 5, MidpointOptions{}); err == nil {
|
||||
t.Fatal("wrong gradient shape: want an error")
|
||||
}
|
||||
nan := func(*core.Array) (*core.Array, error) { return mustFloats(t, []float64{math.NaN(), 1}), nil }
|
||||
if _, _, err := IntegrateMidpoint(nan, 0, 1, q0, p0, 5, MidpointOptions{}); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatal("a NaN gradient: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerletFamilyComplexRefusal pins the dtype contract across the
|
||||
// whole family: complex states are refused everywhere.
|
||||
func TestVerletFamilyComplexRefusal(t *testing.T) {
|
||||
accel := func(q *core.Array) (*core.Array, error) { return core.MulF(q, -1), nil }
|
||||
gradH := func(z *core.Array) (*core.Array, error) { return core.FromFloats([]float64{0, 0}, 2) }
|
||||
qi, err := core.FromFloat32s([]float32{1}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pi, err := core.FromFloat32s([]float32{0}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := IntegrateYoshida4(accel, 0, 1, qi, pi, 5); err != nil {
|
||||
t.Fatalf("a float32 Yoshida state: %v", err)
|
||||
}
|
||||
if _, _, err := IntegrateMidpoint(gradH, 0, 1, qi, pi, 5, MidpointOptions{}); err != nil {
|
||||
t.Fatalf("a float32 midpoint state: %v", err)
|
||||
}
|
||||
// A complex state is refused on both integrators.
|
||||
complexArr := core.New(core.Complex, 1)
|
||||
zeros := mustFloats(t, []float64{0})
|
||||
if _, _, err := IntegrateYoshida4(accel, 0, 1, complexArr, zeros, 5); err == nil || !stringsContains(err, "complex") {
|
||||
t.Fatal("a complex Yoshida state: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateMidpoint(gradH, 0, 1, complexArr, zeros, 5, MidpointOptions{}); err == nil || !stringsContains(err, "complex") {
|
||||
t.Fatal("a complex midpoint state: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerletFamilyNonFiniteRefusal pins the up-front refusal of
|
||||
// non-finite initial states on both new integrators.
|
||||
func TestVerletFamilyNonFiniteRefusal(t *testing.T) {
|
||||
accel := func(q *core.Array) (*core.Array, error) { return core.MulF(q, -1), nil }
|
||||
gradH := func(z *core.Array) (*core.Array, error) { return core.FromFloats([]float64{0, 0}, 2) }
|
||||
nanQ := mustFloats(t, []float64{math.NaN()})
|
||||
p0 := mustFloats(t, []float64{0})
|
||||
if _, _, err := IntegrateYoshida4(accel, 0, 1, nanQ, p0, 5); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatal("a NaN position: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateMidpoint(gradH, 0, 1, nanQ, p0, 5, MidpointOptions{}); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatal("a NaN position: want an error")
|
||||
}
|
||||
q0 := mustFloats(t, []float64{1})
|
||||
nanP := mustFloats(t, []float64{math.Inf(-1)})
|
||||
if _, _, err := IntegrateYoshida4(accel, 0, 1, q0, nanP, 5); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatal("an infinite momentum: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateMidpoint(gradH, 0, 1, q0, nanP, 5, MidpointOptions{}); err == nil || !stringsContains(err, "non-finite") {
|
||||
t.Fatal("an infinite momentum: want an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// 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 (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIntegrateVerletHarmonicEnergy runs the harmonic oscillator over
|
||||
// fifty periods: the true energy must stay inside a bounded band the
|
||||
// whole time (the symplectic property) and the orbit must close on
|
||||
// itself to second-order accuracy.
|
||||
func TestIntegrateVerletHarmonicEnergy(t *testing.T) {
|
||||
const omega = 1.0
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
return core.MulF(q, -omega*omega), nil
|
||||
}
|
||||
const periods = 50
|
||||
// With h per period the energy band sits at O((ωh)²/8)·E; 300
|
||||
// steps per period put it under the 1e-4 bar.
|
||||
stepsPerPeriod := 400
|
||||
q0 := mustFloats(t, []float64{1})
|
||||
p0 := mustFloats(t, []float64{0})
|
||||
positions, momenta, err := IntegrateVerlet(accel, 0, periods*2*math.Pi, q0, p0, periods*stepsPerPeriod)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateVerlet: %v", err)
|
||||
}
|
||||
energy := func(q, p float64) float64 {
|
||||
return 0.5 * (p*p + omega*omega*q*q)
|
||||
}
|
||||
e0 := energy(positions[0].FloatAt(0), momenta[0].FloatAt(0))
|
||||
worst := 0.0
|
||||
for s := range positions {
|
||||
e := energy(positions[s].FloatAt(0), momenta[s].FloatAt(0))
|
||||
if d := math.Abs(e - e0); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
// The band must stay narrow relative to the energy itself, over
|
||||
// fifty periods, where a non-symplectic scheme drifts away.
|
||||
if worst > 1e-4*e0 {
|
||||
t.Fatalf("energy drifted by %g (energy %g) over %d periods", worst, e0, periods)
|
||||
}
|
||||
last := positions[len(positions)-1].FloatAt(0)
|
||||
if math.Abs(last-1) > 1e-3 {
|
||||
t.Fatalf("after a whole number of periods q = %.10g, want ≈ 1", last)
|
||||
}
|
||||
// Second order: halving the step must cut the closing error by
|
||||
// roughly four.
|
||||
q1, _, err := IntegrateVerlet(accel, 0, 2*math.Pi, q0, p0, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateVerlet coarse: %v", err)
|
||||
}
|
||||
q2, _, err := IntegrateVerlet(accel, 0, 2*math.Pi, q0, p0, 40)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateVerlet fine: %v", err)
|
||||
}
|
||||
eCoarse := math.Abs(q1[len(q1)-1].FloatAt(0) - 1)
|
||||
eFine := math.Abs(q2[len(q2)-1].FloatAt(0) - 1)
|
||||
if eFine > 0.35*eCoarse {
|
||||
t.Fatalf("convergence order looks wrong: coarse %g, fine %g", eCoarse, eFine)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateVerletPendulum checks a nonlinear system: the pendulum
|
||||
// with E = p²/2 − cos q must keep its energy bounded, including near
|
||||
// the separatrix where the force is far from linear.
|
||||
func TestIntegrateVerletPendulum(t *testing.T) {
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
return mustFloats(t, []float64{-math.Sin(q.FloatAt(0))}), nil
|
||||
}
|
||||
q0 := mustFloats(t, []float64{2.5})
|
||||
p0 := mustFloats(t, []float64{0})
|
||||
positions, momenta, err := IntegrateVerlet(accel, 0, 200, q0, p0, 20000)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateVerlet: %v", err)
|
||||
}
|
||||
energyOf := func(q, p float64) float64 {
|
||||
return 0.5*p*p - math.Cos(q)
|
||||
}
|
||||
e0 := energyOf(positions[0].FloatAt(0), momenta[0].FloatAt(0))
|
||||
worst := 0.0
|
||||
for s := range positions {
|
||||
e := energyOf(positions[s].FloatAt(0), momenta[s].FloatAt(0))
|
||||
if d := math.Abs(e - e0); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
if worst > 1e-3*math.Abs(e0) {
|
||||
t.Fatalf("pendulum energy drifted by %g (energy %g)", worst, e0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrateVerletErrors(t *testing.T) {
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
return core.MulF(q, -1), nil
|
||||
}
|
||||
q0 := mustFloats(t, []float64{1})
|
||||
p0 := mustFloats(t, []float64{0})
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 1, q0, p0, 0); err == nil {
|
||||
t.Fatal("zero steps: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 1, q0, mustFloats(t, []float64{0, 1}), 5); err == nil {
|
||||
t.Fatal("mismatched vectors: want an error")
|
||||
}
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 1, mustFloats(t, []float64{}), mustFloats(t, []float64{}), 5); err == nil {
|
||||
t.Fatal("empty state: want an error")
|
||||
}
|
||||
boom := func(*core.Array) (*core.Array, error) { return nil, base.Errf("accel failed") }
|
||||
if _, _, err := IntegrateVerlet(boom, 0, 1, q0, p0, 5); err == nil {
|
||||
t.Fatal("accel error: want an error")
|
||||
}
|
||||
wrong := func(*core.Array) (*core.Array, error) { return mustFloats(t, []float64{1, 1}), nil }
|
||||
if _, _, err := IntegrateVerlet(wrong, 0, 1, q0, p0, 5); err == nil {
|
||||
t.Fatal("wrong accel shape: want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateVerletFloat32State pins the dtype contract: a float32
|
||||
// state integrates (read per element, widened exactly) and lands where
|
||||
// the float64 run lands; RawFloats is nil for float32, so the old
|
||||
// code silently integrated zeros.
|
||||
func TestIntegrateVerletFloat32State(t *testing.T) {
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
return core.MulF(q, -1), nil
|
||||
}
|
||||
q0, err := core.FromFloat32s([]float32{1}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloat32s: %v", err)
|
||||
}
|
||||
p0, err := core.FromFloat32s([]float32{0}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloat32s: %v", err)
|
||||
}
|
||||
positions, momenta, err := IntegrateVerlet(accel, 0, 2*math.Pi, q0, p0, 400)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateVerlet: %v", err)
|
||||
}
|
||||
if got := positions[400].FloatAt(0); math.Abs(got-1) > 1e-3 {
|
||||
t.Fatalf("float32 state closed at %.6g, want ≈ 1", got)
|
||||
}
|
||||
if got := momenta[0].FloatAt(0); got != 0 {
|
||||
t.Fatalf("initial momentum = %g, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateVerletIntStateErrors pins the refusal of int states:
|
||||
// they have no place in a continuous integrator and must error rather
|
||||
// than panic or read as zeros.
|
||||
func TestIntegrateVerletIntStateErrors(t *testing.T) {
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
return core.MulF(q, -1), nil
|
||||
}
|
||||
qi, err := core.FromInts([]int64{1}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("FromInts: %v", err)
|
||||
}
|
||||
pi, err := core.FromInts([]int64{0}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("FromInts: %v", err)
|
||||
}
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 1, qi, pi, 5); err == nil {
|
||||
t.Fatal("expected an error for an int state")
|
||||
}
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 1, mustFloats(t, []float64{1}), pi, 5); err == nil {
|
||||
t.Fatal("expected an error for an int momentum with a float position")
|
||||
}
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 1, qi, mustFloats(t, []float64{0}), 5); err == nil {
|
||||
t.Fatal("expected an error for an int position with a float momentum")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Regression tests for the integrators: the
|
||||
// absolute cubature tolerance, the collapse floor of the ODE steppers
|
||||
// and the sample times of the PDE evolutions.
|
||||
|
||||
// TestCubatureScalesWithMagnitude pins the stopping rule: a smooth
|
||||
// integrand of large magnitude must converge, not exhaust the budget
|
||||
// chasing an absolute bound below the rounding floor of the sum.
|
||||
func TestCubatureScalesWithMagnitude(t *testing.T) {
|
||||
const want = 1e6 // ∫∫ 1e6 over the unit square
|
||||
got, err := IntegrateND(func([]float64) float64 { return want }, []float64{0, 0}, []float64{1, 1}, CubatureOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateND of a constant: %v", err)
|
||||
}
|
||||
if math.Abs(got-want) > 1e-6*want {
|
||||
t.Fatalf("IntegrateND = %v, want %v", got, want)
|
||||
}
|
||||
// A small integral keeps its absolute accuracy.
|
||||
small, err := IntegrateND(func([]float64) float64 { return 1e-8 }, []float64{0, 0}, []float64{1, 1}, CubatureOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateND of a small constant: %v", err)
|
||||
}
|
||||
if math.Abs(small-1e-8) > 1e-12 {
|
||||
t.Fatalf("IntegrateND = %v, want 1e-8", small)
|
||||
}
|
||||
}
|
||||
|
||||
// TestODETinySpan pins the collapse rule: a span far below the absolute
|
||||
// time scale is integrable, and the stepper must not refuse it.
|
||||
func TestODETinySpan(t *testing.T) {
|
||||
zero := func(float64, *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{0}, 1)
|
||||
}
|
||||
y0, err := core.FromFloats([]float64{1}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, span := range []float64{1e-13, 1e-15, 1e-20} {
|
||||
end, err := IntegrateODE(zero, 0, span, y0, ODEOptions{MaxSteps: 100000})
|
||||
if err != nil {
|
||||
t.Fatalf("span %g: %v", span, err)
|
||||
}
|
||||
if got := end.FloatAt(0); got != 1 {
|
||||
t.Fatalf("span %g: y = %v, want 1", span, got)
|
||||
}
|
||||
}
|
||||
// A real decay over a tiny span: y' = −y, y(1e-12) = exp(−1e-12).
|
||||
const span = 1e-12
|
||||
decay := func(_ float64, y *core.Array) (*core.Array, error) {
|
||||
return core.FromFloats([]float64{-y.FloatAt(0)}, 1)
|
||||
}
|
||||
end, err := IntegrateODE(decay, 0, span, y0, ODEOptions{MaxSteps: 100000})
|
||||
if err != nil {
|
||||
t.Fatalf("decay over %g: %v", span, err)
|
||||
}
|
||||
if got, want := end.FloatAt(0), math.Exp(-span); math.Abs(got-want) > 1e-12 {
|
||||
t.Fatalf("y = %.17g, want %.17g", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// sineMode returns the interior grid of sin(π·x) on [0, 1] with n
|
||||
// points, the eigenmode of the Dirichlet Laplacian.
|
||||
func sineMode(t *testing.T, n int) *core.Array {
|
||||
t.Helper()
|
||||
u := make([]float64, n)
|
||||
for i := range n {
|
||||
x := float64(i+1) / float64(n+1)
|
||||
u[i] = math.Sin(math.Pi * x)
|
||||
}
|
||||
a, err := core.FromFloats(u, n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// TestPDESchedule pins the step schedule directly, because the
|
||||
// physics tests below pass with either schedule for a fine enough
|
||||
// step: the published times must be j·tFinal/(samples−1) exactly, so
|
||||
// the step count is a multiple of samples−1 and the last step lands on
|
||||
// tFinal. `dt` is an upper bound, never a divisor to be honoured
|
||||
// blindly.
|
||||
func TestPDESchedule(t *testing.T) {
|
||||
cases := []struct {
|
||||
tFinal, dt float64
|
||||
samples int
|
||||
}{
|
||||
{1, 0.3, 3}, {1, 0.3, 5}, {1, 0.7, 4}, {2.5, 0.4, 3}, {0.1, 0.3, 2}, {1, 1, 3}, {1, 0.01, 8},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
steps, h := pdeSchedule(tc.tFinal, tc.dt, tc.samples)
|
||||
if steps <= 0 || h <= 0 {
|
||||
t.Fatalf("pdeSchedule(%v, %v, %d) = %d steps of %v", tc.tFinal, tc.dt, tc.samples, steps, h)
|
||||
}
|
||||
if float64(steps)*h != tc.tFinal {
|
||||
t.Errorf("pdeSchedule(%v, %v, %d): %d steps of %v reach %v",
|
||||
tc.tFinal, tc.dt, tc.samples, steps, h, float64(steps)*h)
|
||||
}
|
||||
if steps%(tc.samples-1) != 0 {
|
||||
t.Errorf("pdeSchedule(%v, %v, %d): %d steps do not divide by %d",
|
||||
tc.tFinal, tc.dt, tc.samples, steps, tc.samples-1)
|
||||
}
|
||||
if h > tc.dt {
|
||||
t.Errorf("pdeSchedule(%v, %v, %d): the step %v exceeds the bound %v",
|
||||
tc.tFinal, tc.dt, tc.samples, h, tc.dt)
|
||||
}
|
||||
every := steps / (tc.samples - 1)
|
||||
for j := range tc.samples {
|
||||
got := float64(j*every) * h
|
||||
want := tc.tFinal * float64(j) / float64(tc.samples-1)
|
||||
if math.Abs(got-want) > 1e-12*tc.tFinal {
|
||||
t.Errorf("sample %d at %v, want %v", j, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeatSamplesLandOnTheirTimes checks the values at the published
|
||||
// times; the schedule itself is pinned above.
|
||||
// published states are the states at t = 0, tFinal/2 and tFinal, which
|
||||
// the single sine mode turns into an exact decay ratio. The step is
|
||||
// chosen for accuracy (r = κ·h/dx² ≈ 0.45) and does not divide tFinal,
|
||||
// so the schedule has to round the count up.
|
||||
func TestHeatSamplesLandOnTheirTimes(t *testing.T) {
|
||||
const (
|
||||
kappa = 1.0
|
||||
n = 128
|
||||
)
|
||||
u0 := sineMode(t, n)
|
||||
dx := 1.0 / float64(n+1)
|
||||
dt := 0.9 * 0.5 * dx * dx
|
||||
got, err := IntegrateHeat1D(u0, kappa, dx, 1, dt, 3, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateHeat1D: %v", err)
|
||||
}
|
||||
if s := got.Shape(); s[0] != 3 || s[1] != n {
|
||||
t.Fatalf("shape %v, want [3 %d]", s, n)
|
||||
}
|
||||
decay := func(tm float64) float64 { return math.Exp(-kappa * math.Pi * math.Pi * tm) }
|
||||
for j := range n {
|
||||
first := got.FloatAt(j)
|
||||
if math.Abs(first-u0.FloatAt(j)) > 1e-12 {
|
||||
t.Fatalf("sample 0 is not the initial state at %d", j)
|
||||
}
|
||||
mid := got.FloatAt(n + j)
|
||||
wantMid := first * decay(0.5)
|
||||
if rel := math.Abs(mid-wantMid) / wantMid; rel > 1e-3 {
|
||||
t.Fatalf("sample 1 at %d: %v, want %v (relative %.2g): the time is not 0.5", j, mid, wantMid, rel)
|
||||
}
|
||||
last := got.FloatAt(2*n + j)
|
||||
wantLast := first * decay(1.0)
|
||||
if rel := math.Abs(last-wantLast) / wantLast; rel > 1e-3 {
|
||||
t.Fatalf("sample 2 at %d: %v, want %v (relative %.2g): the time is not 1", j, last, wantLast, rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaveSamplesLandOnTheirTimes does the same for the wave equation
|
||||
// (the schedule is pinned above):
|
||||
// the standing mode is cos(π·c·t)·sin(π·x), so the sample at tFinal = 1
|
||||
// with c = 1 is the initial state negated and the one at 0.5 is zero.
|
||||
func TestWaveSamplesLandOnTheirTimes(t *testing.T) {
|
||||
const n = 128
|
||||
u0 := sineMode(t, n)
|
||||
v0, err := core.FromFloats(make([]float64, n), n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dx := 1.0 / float64(n+1)
|
||||
got, err := IntegrateWave1D(u0, v0, 1, dx, 1, 0.9*dx, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave1D: %v", err)
|
||||
}
|
||||
for j := range n {
|
||||
last := got.FloatAt(2*n + j)
|
||||
want := -u0.FloatAt(j) // cos(π·1) = −1
|
||||
if math.Abs(last-want) > 5e-3*math.Abs(want) {
|
||||
t.Fatalf("sample 2 at %d: %v, want %v: the last sample is not at t = 1", j, last, want)
|
||||
}
|
||||
mid := got.FloatAt(n + j)
|
||||
if math.Abs(mid) > 1e-2*math.Abs(u0.FloatAt(j)) {
|
||||
t.Fatalf("sample 1 at %d: %v, want near zero at t = 0.5", j, mid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
// The tridiagonal scratch the PDE step sweeps reuse. The Crank-
|
||||
// Nicolson and Peaceman-Rachford schemes solve one tridiagonal system
|
||||
// per line per step against matrices that are constants of the scheme,
|
||||
// so the elimination scratch belongs to the solve, not to the line:
|
||||
// the sweeps below allocate it once and reuse it across every line and
|
||||
// every step, where the library's array-level SolveTridiagonal
|
||||
// allocated its working vectors per call. The elimination itself is
|
||||
// internal/base's TriSolve, shared with the public solver, so the
|
||||
// arithmetic exists once.
|
||||
|
||||
// triScratch holds one tridiagonal solve's reusable buffers: the
|
||||
// right-hand side, the eliminated superdiagonal and right side of the
|
||||
// Thomas algorithm, and the solution buffer for callers whose output
|
||||
// is not written straight into their state. aux is a second right-side
|
||||
// lane for the sweeps that build two neighbouring systems in one pass.
|
||||
// Every buffer is fully overwritten before the kernel reads it, except
|
||||
// cp, whose prefix is written and read in the same sweep order the
|
||||
// fresh buffers saw.
|
||||
type triScratch struct {
|
||||
rhs, cp, dp, dst, aux []float64
|
||||
}
|
||||
|
||||
// triSized sizes s for a system of sys unknowns whose right side is a
|
||||
// line of lineLen entries, reusing storage that is already large
|
||||
// enough. The solution buffer dst is sized for callers that scatter
|
||||
// the result elsewhere; a caller writing straight into its state leaves
|
||||
// it unread.
|
||||
func triSized(s *triScratch, sys, lineLen int) {
|
||||
s.rhs = sizedBuf(s.rhs, lineLen)
|
||||
s.aux = sizedBuf(s.aux, lineLen)
|
||||
s.cp = sizedBuf(s.cp, sys)
|
||||
s.dp = sizedBuf(s.dp, sys)
|
||||
s.dst = sizedBuf(s.dst, sys)
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Regression pins for integrate/pde2d.go: the three-buffer leapfrog
|
||||
// rotation, the pristine read of the Taylor start, the published-sample
|
||||
// floor and schedule, and the complex-velocity guard. The oracles here
|
||||
// are derived from the stencil itself, not from the library's own
|
||||
// paths.
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// pde2dSteps re-derives the documented schedule contract independently
|
||||
// of the implementation's pdeSchedule: ceil(tFinal/dt) steps, rounded up
|
||||
// to a multiple of the sampling interval samples-1, all of size
|
||||
// tFinal/steps, so every published time j·tFinal/(samples−1) is a step
|
||||
// boundary. The returned every = steps/(samples−1) steps sit between two
|
||||
// published samples.
|
||||
func pde2dSteps(tFinal, dt float64, samples int) (steps, every int, h float64) {
|
||||
steps = max(int(math.Ceil(tFinal/dt)), samples-1)
|
||||
if rem := steps % (samples - 1); rem != 0 {
|
||||
steps += samples - 1 - rem
|
||||
}
|
||||
return steps, steps / (samples - 1), tFinal / float64(steps)
|
||||
}
|
||||
|
||||
// pinSineMode builds sin(kx·π·x)·sin(ky·π·y) on a rows×cols grid whose
|
||||
// ring is zero, sampled with x = c·dx and y = r·dy.
|
||||
func pinSineMode(t *testing.T, rows, cols, kx, ky int) *core.Array {
|
||||
t.Helper()
|
||||
vals := make([]float64, rows*cols)
|
||||
for r := range rows {
|
||||
for c := range cols {
|
||||
vals[r*cols+c] = math.Sin(float64(kx)*math.Pi*float64(c)/float64(cols-1)) *
|
||||
math.Sin(float64(ky)*math.Pi*float64(r)/float64(rows-1))
|
||||
}
|
||||
}
|
||||
a, err := core.FromFloats(vals, rows, cols)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// modeMu is the eigenvalue the five-point Laplacian gives that
|
||||
// mode: Δmode = −μ·mode with μ = 4/dx²·sin²(kxπ/(2(cols−1))) +
|
||||
// 4/dy²·sin²(kyπ/(2(rows−1))).
|
||||
func modeMu(rows, cols, kx, ky int, dx, dy float64) float64 {
|
||||
sx := math.Sin(float64(kx) * math.Pi / (2 * float64(cols-1)))
|
||||
sy := math.Sin(float64(ky) * math.Pi / (2 * float64(rows-1)))
|
||||
return 4/(dx*dx)*sx*sx + 4/(dy*dy)*sy*sy
|
||||
}
|
||||
|
||||
// point3x3 is the exact trajectory of the single interior point of
|
||||
// a 3x3 grid with the zero ring: its four neighbours are all boundary
|
||||
// values, so the five-point Laplacian is exactly −μ·u with
|
||||
// μ = 2/dx² + 2/dy² and the leapfrog collapses to the scalar recurrence
|
||||
// u_{s+1} = 2u_s − u_{s−1} + (c·h)²·(−μ·u_s), started by the Taylor step
|
||||
// u¹ = u⁰ + h·v⁰ + (c·h)²/2·(−μ·u⁰). Its closed form is cos(s·θ) with
|
||||
// cos θ = 1 − (c·h)²·μ/2.
|
||||
func point3x3(steps int, h, c, dx, dy, u0, v0 float64) (hist []float64, theta float64) {
|
||||
mu := 2/(dx*dx) + 2/(dy*dy)
|
||||
lapW := (c * h) * (c * h)
|
||||
theta = math.Acos(1 - 0.5*lapW*mu)
|
||||
hist = make([]float64, steps+1)
|
||||
hist[0] = u0
|
||||
hist[1] = u0 + h*v0 + 0.5*lapW*(-mu*u0)
|
||||
for s := 2; s <= steps; s++ {
|
||||
hist[s] = 2*hist[s-1] - hist[s-2] + lapW*(-mu*hist[s-1])
|
||||
}
|
||||
return hist, theta
|
||||
}
|
||||
|
||||
// singlePoint3x3 builds the 3x3 initial state whose one interior
|
||||
// point carries u = 1 and whose ring is zero.
|
||||
func singlePoint3x3(t *testing.T) *core.Array {
|
||||
t.Helper()
|
||||
vals := make([]float64, 9)
|
||||
vals[4] = 1
|
||||
a, err := core.FromFloats(vals, 3, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// TestWave2DLeapfrogScalarTrajectory pins the three-buffer rotation. On
|
||||
// the 3x3 grid the interior point has no interior neighbour, so the
|
||||
// whole trajectory IntegrateWave2D returns must equal the scalar
|
||||
// leapfrog step by step. The old rotation (prev, cur = cur, next) left
|
||||
// next aliased to cur after two swaps, which replaces the second-order
|
||||
// recurrence with the first-order map u next = u + lapW*lap(u): step 201 came
|
||||
// back as +0.7253864115 where the leapfrog gives −0.1460225271.
|
||||
func TestWave2DLeapfrogScalarTrajectory(t *testing.T) {
|
||||
const dx, dy, c = 0.5, 0.5, 1.0
|
||||
const tFinal, dt, samples = 2.0, 0.01, 202
|
||||
steps, every, h := pde2dSteps(tFinal, dt, samples)
|
||||
if steps != 201 || every != 1 {
|
||||
t.Fatalf("schedule %d steps of %v, every %d, want 201 steps every 1", steps, h, every)
|
||||
}
|
||||
u0 := singlePoint3x3(t)
|
||||
v0 := core.New(core.Float, 3, 3)
|
||||
hist, err := IntegrateWave2D(u0, v0, c, dx, dy, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
ref, theta := point3x3(steps, h, c, dx, dy, 1, 0)
|
||||
worst, worstAt := 0.0, 0
|
||||
for j := range samples {
|
||||
got := hist.FloatAt(j*9 + 4)
|
||||
if e := math.Abs(got - ref[j*every]); e > worst {
|
||||
worst, worstAt = e, j
|
||||
}
|
||||
}
|
||||
if worst > 1e-12 {
|
||||
t.Fatalf("sample %d = %.12f, want the scalar leapfrog %.12f (worst deviation %.3e over the whole history)",
|
||||
worstAt, hist.FloatAt(worstAt*9+4), ref[worstAt*every], worst)
|
||||
}
|
||||
// The recurrence itself is the closed form cos(s·θ), so the oracle is
|
||||
// pinned to the discrete characteristic and not to the implementation.
|
||||
worst, worstAt = 0.0, 0
|
||||
for s := range steps + 1 {
|
||||
want := math.Cos(float64(s) * theta)
|
||||
if e := math.Abs(ref[s] - want); e > worst {
|
||||
worst, worstAt = e, s
|
||||
}
|
||||
}
|
||||
if worst > 1e-11 {
|
||||
t.Fatalf("the reference recurrence deviates from cos(s·θ) by %.3e at step %d", worst, worstAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWave2DTaylorStartReadsUntouchedState pins the start-up read. The
|
||||
// first step is the Taylor start, and it must read the untouched u⁰:
|
||||
// computed into the same slice it reads (the old form wrote cur in place),
|
||||
// the Laplacian at an interior point picks up already-updated left and
|
||||
// upper neighbours. On this exact eigenmode with v⁰ = 0 the first
|
||||
// published sample is cos θ·u⁰ to rounding, and the in-place start moved
|
||||
// it by 1.4e-7 (relative to the unit amplitude at the centre).
|
||||
func TestWave2DTaylorStartReadsUntouchedState(t *testing.T) {
|
||||
const n = 9
|
||||
const c = 1.0
|
||||
dx := 1.0 / float64(n-1)
|
||||
const tFinal, dt, samples = 0.008, 0.004, 3
|
||||
steps, every, h := pde2dSteps(tFinal, dt, samples)
|
||||
if steps != 2 || every != 1 {
|
||||
t.Fatalf("schedule %d steps of %v, every %d, want 2 steps every 1", steps, h, every)
|
||||
}
|
||||
u0 := pinSineMode(t, n, n, 1, 1)
|
||||
v0 := core.New(core.Float, n, n)
|
||||
hist, err := IntegrateWave2D(u0, v0, c, dx, dx, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
mu := modeMu(n, n, 1, 1, dx, dx)
|
||||
cosTheta := 1 - 0.5*(c*h)*(c*h)*mu
|
||||
worst, worstAt := 0.0, 0
|
||||
for r := range n {
|
||||
for cc := range n {
|
||||
i := r*n + cc
|
||||
want := cosTheta * u0.FloatAt(i)
|
||||
if e := math.Abs(hist.FloatAt(n*n+i) - want); e > worst {
|
||||
worst, worstAt = e, i
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst > 1e-13 {
|
||||
t.Fatalf("the Taylor start deviates from cos θ·u⁰ by %.3e at point %d (row %d, column %d): %.12f, want %.12f",
|
||||
worst, worstAt, worstAt/n, worstAt%n, hist.FloatAt(n*n+worstAt), cosTheta*u0.FloatAt(worstAt))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWave2DSamplesFloorTwo pins the floors of the published-sample
|
||||
// argument. One sample leaves steps/(samples−1) with a zero divisor, so
|
||||
// both 2-D entry points must refuse it the way the 1-D solvers do
|
||||
// ("at least two samples are needed"), not panic with an integer divide
|
||||
// by zero as the earlier revision did.
|
||||
func TestWave2DSamplesFloorTwo(t *testing.T) {
|
||||
u0 := singlePoint3x3(t)
|
||||
v0 := core.New(core.Float, 3, 3)
|
||||
for _, samples := range []int{1, 0} {
|
||||
_, err := IntegrateHeat2D(u0, 1, 0.25, 0.25, 0.1, 0.01, samples, 0, 0, 0, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("IntegrateHeat2D accepted samples = %d", samples)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least two samples are needed") {
|
||||
t.Fatalf("IntegrateHeat2D samples = %d: error %q, want the at-least-two wording", samples, err)
|
||||
}
|
||||
_, err = IntegrateWave2D(u0, v0, 1, 0.25, 0.25, 0.1, 0.01, samples)
|
||||
if err == nil {
|
||||
t.Fatalf("IntegrateWave2D accepted samples = %d", samples)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least two samples are needed") {
|
||||
t.Fatalf("IntegrateWave2D samples = %d: error %q, want the at-least-two wording", samples, err)
|
||||
}
|
||||
}
|
||||
// Two samples are still a valid call: the endpoints alone.
|
||||
if _, err := IntegrateWave2D(u0, v0, 1, 0.25, 0.25, 0.1, 0.01, 2); err != nil {
|
||||
t.Fatalf("IntegrateWave2D with samples = 2: %v", err)
|
||||
}
|
||||
if _, err := IntegrateHeat2D(u0, 1, 0.25, 0.25, 0.1, 0.01, 2, 0, 0, 0, 0); err != nil {
|
||||
t.Fatalf("IntegrateHeat2D with samples = 2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWave2DComplexVelocityRefusal pins the dtype guard on the initial
|
||||
// velocity. The earlier revision checked only the rank and shape of v0,
|
||||
// so a complex array reached v0.FloatAt and panicked inside the Taylor
|
||||
// start ("index out of range [6] with length 0"); the 1-D solver refuses
|
||||
// the same input with a message, which is the behaviour mirrored here.
|
||||
func TestWave2DComplexVelocityRefusal(t *testing.T) {
|
||||
u0, err := core.FromFloats(make([]float64, 25), 5, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
v0 := core.New(core.Complex, 5, 5)
|
||||
_, err = IntegrateWave2D(u0, v0, 1, 0.25, 0.25, 0.1, 0.01, 3)
|
||||
if err == nil {
|
||||
t.Fatal("IntegrateWave2D accepted a complex velocity array")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "complex velocities are not supported") {
|
||||
t.Fatalf("IntegrateWave2D: error %q, want the unsupported-velocity wording", err)
|
||||
}
|
||||
// The same wording the 1-D wave solver uses for the same input.
|
||||
u1 := core.New(core.Float, 5)
|
||||
v1 := core.New(core.Complex, 5)
|
||||
_, err = IntegrateWave1D(u1, v1, 1, 0.25, 0.1, 0.01, 3)
|
||||
if err == nil || !strings.Contains(err.Error(), "complex velocities are not supported") {
|
||||
t.Fatalf("IntegrateWave1D: error %v, want the same refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWave2DSlotsLandOnTheirTimes pins the published-sample schedule with
|
||||
// an interval wider than one step. With samples = 5 over 8 steps the
|
||||
// interval is every = 2, so slot j must hold the state after 2j steps, at
|
||||
// the time 2j·h = j·tFinal/(samples−1). The earlier revision published
|
||||
// the Taylor start (step 1) in slot 1 instead of step 2, shifting every
|
||||
// slot from 1 to samples−2 off its published time.
|
||||
func TestWave2DSlotsLandOnTheirTimes(t *testing.T) {
|
||||
const dx, dy, c = 0.5, 0.5, 1.0
|
||||
const tFinal, dt, samples = 0.08, 0.01, 5
|
||||
steps, every, h := pde2dSteps(tFinal, dt, samples)
|
||||
if steps != 8 || every != 2 {
|
||||
t.Fatalf("schedule %d steps of %v, every %d, want 8 steps every 2", steps, h, every)
|
||||
}
|
||||
u0 := singlePoint3x3(t)
|
||||
v0 := core.New(core.Float, 3, 3)
|
||||
hist, err := IntegrateWave2D(u0, v0, c, dx, dy, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
ref, _ := point3x3(steps, h, c, dx, dy, 1, 0)
|
||||
for j := range samples {
|
||||
// The published time must be the step boundary j·every, which is
|
||||
// what makes the slots a uniform time grid with both endpoints.
|
||||
time, boundary := float64(j)*tFinal/float64(samples-1), float64(j*every)*h
|
||||
if e := math.Abs(time - boundary); e > 1e-15 {
|
||||
t.Fatalf("slot %d: published time %v is %d steps (%v) into the run, off by %.3e",
|
||||
j, time, j*every, boundary, e)
|
||||
}
|
||||
got, want := hist.FloatAt(j*9+4), ref[j*every]
|
||||
if e := math.Abs(got - want); e > 1e-12 {
|
||||
t.Fatalf("slot %d (t = %v) = %.12f, want the state after %d steps %.12f (off by %.3e)",
|
||||
j, time, got, j*every, want, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWave2DRingHeldAtZero pins the documented ring contract against the
|
||||
// buffer recycling of the rotation fix: the solver holds the boundary
|
||||
// ring at zero, so a caller's own ring values may not reach the stencil
|
||||
// at any published sample. A run whose state is all ones (ring 1) must
|
||||
// therefore agree, from slot 1 on, with the run whose ring is zero, and
|
||||
// the returned ring must be zero there. The earlier revision read the
|
||||
// caller's ring into the first two Laplacians, and the three-buffer
|
||||
// rotation makes that slip possible every third step unless the recycled
|
||||
// buffer is cleared.
|
||||
func TestWave2DRingHeldAtZero(t *testing.T) {
|
||||
const n = 5
|
||||
const c = 1.0
|
||||
dx := 1.0 / float64(n-1)
|
||||
const tFinal, dt, samples = 0.04, 0.01, 5
|
||||
steps, every, _ := pde2dSteps(tFinal, dt, samples)
|
||||
if steps != 4 || every != 1 {
|
||||
t.Fatalf("schedule %d steps, every %d, want 4 steps every 1", steps, every)
|
||||
}
|
||||
ones := make([]float64, n*n)
|
||||
for i := range ones {
|
||||
ones[i] = 1
|
||||
}
|
||||
cleared := make([]float64, n*n)
|
||||
copy(cleared, ones)
|
||||
for r := range n {
|
||||
cleared[r*n], cleared[r*n+n-1] = 0, 0
|
||||
}
|
||||
for cc := range n {
|
||||
cleared[cc], cleared[(n-1)*n+cc] = 0, 0
|
||||
}
|
||||
ringed, err := core.FromFloats(ones, n, n)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
bare, err := core.FromFloats(cleared, n, n)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFloats: %v", err)
|
||||
}
|
||||
v0 := core.New(core.Float, n, n)
|
||||
got, err := IntegrateWave2D(ringed, v0, c, dx, dx, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
want, err := IntegrateWave2D(bare, v0, c, dx, dx, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
for j := 1; j < samples; j++ {
|
||||
for i := range n * n {
|
||||
r, cc := i/n, i%n
|
||||
g := got.FloatAt(j*n*n + i)
|
||||
if e := math.Abs(g - want.FloatAt(j*n*n+i)); e > 1e-15 {
|
||||
t.Fatalf("slot %d point (row %d, column %d) = %.16f, want %.16f: the caller's ring reached the stencil (off by %.3e)",
|
||||
j, r, cc, g, want.FloatAt(j*n*n+i), e)
|
||||
}
|
||||
if (r == 0 || r == n-1 || cc == 0 || cc == n-1) && g != 0 {
|
||||
t.Fatalf("slot %d point (row %d, column %d) = %v, want the ring held at zero", j, r, cc, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWave2DStandingModeMatchesDiscreteEigenvalue is the acceptance test
|
||||
// for the leapfrog. The (1,1) sine mode of the 9x9 grid is an exact
|
||||
// eigenfunction of the five-point Laplacian, so with zero initial
|
||||
// velocity every published sample must be cos(ω·t_j)·u⁰, where the
|
||||
// discrete characteristic of the scheme is
|
||||
// cos(ω·h) = 1 − (c·h)²·μ/2 and μ is the eigenvalue derived above. The
|
||||
// earlier revision returned +0.9250145127 at t = 1 where the discrete
|
||||
// solution is −0.2935539474 (monotone decay instead of oscillation), and
|
||||
// its in-place Taylor start missed the first sample by 1.4e-7.
|
||||
func TestWave2DStandingModeMatchesDiscreteEigenvalue(t *testing.T) {
|
||||
const n = 9
|
||||
const c = 1.0
|
||||
dx := 1.0 / float64(n-1)
|
||||
const tFinal, dt, samples = 1.0, 0.004, 253
|
||||
steps, every, h := pde2dSteps(tFinal, dt, samples)
|
||||
if steps != 252 || every != 1 {
|
||||
t.Fatalf("schedule %d steps of %v, every %d, want 252 steps every 1", steps, h, every)
|
||||
}
|
||||
u0 := pinSineMode(t, n, n, 1, 1)
|
||||
v0 := core.New(core.Float, n, n)
|
||||
hist, err := IntegrateWave2D(u0, v0, c, dx, dx, tFinal, dt, samples)
|
||||
if err != nil {
|
||||
t.Fatalf("IntegrateWave2D: %v", err)
|
||||
}
|
||||
mu := modeMu(n, n, 1, 1, dx, dx)
|
||||
theta := math.Acos(1 - 0.5*(c*h)*(c*h)*mu) // ω = θ/h, and t_j = j·every·h
|
||||
worst, worstAt, worstSlot := 0.0, 0, 0
|
||||
for j := range samples {
|
||||
amp := math.Cos(float64(j*every) * theta)
|
||||
for i := range n * n {
|
||||
got := hist.FloatAt(j*n*n + i)
|
||||
if e := math.Abs(got - amp*u0.FloatAt(i)); e > worst {
|
||||
worst, worstAt, worstSlot = e, i, j
|
||||
}
|
||||
}
|
||||
}
|
||||
if worst > 1e-11 {
|
||||
t.Fatalf("sample %d (t = %v) point %d = %.12f, want cos(ω·t)·u⁰ = %.12f (worst %.3e over every published sample)",
|
||||
worstSlot, float64(worstSlot)*tFinal/float64(samples-1), worstAt,
|
||||
hist.FloatAt(worstSlot*n*n+worstAt),
|
||||
math.Cos(float64(worstSlot*every)*theta)*u0.FloatAt(worstAt), worst)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrateRefusesInfiniteIntegrand pins the non-finite gate: an
|
||||
// integrand returning +Inf used to poison the error sum with NaN,
|
||||
// whose comparisons are false, and the integral came back as Inf with
|
||||
// a nil error.
|
||||
func TestIntegrateRefusesInfiniteIntegrand(t *testing.T) {
|
||||
inf := func(x float64) (float64, error) { return math.Inf(1), nil }
|
||||
if _, _, err := IntegrateFunction(inf, 0, 1, QuadratureOptions{}); err == nil {
|
||||
t.Fatal("IntegrateFunction with an infinite integrand returned no error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integrate
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||||
)
|
||||
|
||||
// Benchmarks for the library-side cost of the solver drivers: the
|
||||
// same workloads the package benchmarks run, with the fixture's own
|
||||
// per-call allocation removed, so the allocation count a run reports
|
||||
// is the library's alone. Every fixture derivative refills one output
|
||||
// array the driver copies out of immediately, which the f contract
|
||||
// allows: no solver retains the returned array.
|
||||
|
||||
// wavePooledDecay builds the diagonal stiff system y' = rate·(i+1)·y_i
|
||||
// with an f that refills one shared output array per call.
|
||||
func wavePooledDecay(n int, rate float64) func(t float64, y *core.Array) (*core.Array, error) {
|
||||
rates := make([]float64, n)
|
||||
for i := range rates {
|
||||
rates[i] = rate * float64(i+1)
|
||||
}
|
||||
out := core.New(core.Float, n)
|
||||
vals := out.RawFloats()
|
||||
return func(t float64, y *core.Array) (*core.Array, error) {
|
||||
ys := y.RawFloats()
|
||||
for i := range n {
|
||||
vals[i] = rates[i] * ys[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// waveVector wraps a fixed literal as a rank-1 array.
|
||||
func waveVector(b *testing.B, vals []float64) *core.Array {
|
||||
b.Helper()
|
||||
a, err := core.FromFloats(vals, len(vals))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// waveOnes returns a vector of n ones.
|
||||
func waveOnes(n int) []float64 {
|
||||
vals := make([]float64, n)
|
||||
for i := range vals {
|
||||
vals[i] = 1
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// BenchmarkWaveLibraryRK4 runs the fixed-step RK4 loop over the linear
|
||||
// system of BenchmarkIntegrateRK4, with the derivative evaluation
|
||||
// allocation-free: the reported allocations are the driver's own.
|
||||
func BenchmarkWaveLibraryRK4(b *testing.B) {
|
||||
const n = 16
|
||||
f := wavePooledDecay(n, -0.25)
|
||||
start := waveVector(b, waveOnes(n))
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateRK4(f, 0, 10, start, 2000); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWaveLibraryBDF2 runs the variable-step BDF2 loop over the
|
||||
// stiff diagonal system of BenchmarkIntegrateBDF2, allocation-free on
|
||||
// the fixture side.
|
||||
func BenchmarkWaveLibraryBDF2(b *testing.B) {
|
||||
const n = 4
|
||||
f := wavePooledDecay(n, -100)
|
||||
start := waveVector(b, waveOnes(n))
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateBDF2(f, 0, 1, start, ODEOptions{}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWaveLibraryBDFVar runs the variable-order BDF loop over the
|
||||
// stiff diagonal system of BenchmarkBDFVarStiff, allocation-free on the
|
||||
// fixture side.
|
||||
func BenchmarkWaveLibraryBDFVar(b *testing.B) {
|
||||
const n = 32
|
||||
f := wavePooledDecay(n, -100)
|
||||
start := waveVector(b, waveOnes(n))
|
||||
opts := BDFVarOptions{RelTol: 1e-6, AbsTol: 1e-9}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateBDFVar(f, 0, 1, start, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWaveLibraryROS4 runs the ROS4 loop over the stiff diagonal
|
||||
// system of BenchmarkROS4Stiff, allocation-free on the fixture side.
|
||||
func BenchmarkWaveLibraryROS4(b *testing.B) {
|
||||
const n = 32
|
||||
f := wavePooledDecay(n, -100)
|
||||
start := waveVector(b, waveOnes(n))
|
||||
opts := ODEOptions{RelTol: 1e-6, AbsTol: 1e-9}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, err := IntegrateROS4(f, 0, 1, start, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWaveLibraryVerlet runs the velocity Verlet loop over the
|
||||
// coupled oscillators of BenchmarkIntegrateVerlet, with the
|
||||
// acceleration evaluation allocation-free: the reported allocations
|
||||
// are the driver's own.
|
||||
func BenchmarkWaveLibraryVerlet(b *testing.B) {
|
||||
n := 32
|
||||
q0 := waveOnes(n)
|
||||
for i := range q0 {
|
||||
q0[i] = math.Sin(float64(i))
|
||||
}
|
||||
out := core.New(core.Float, n)
|
||||
vals := out.RawFloats()
|
||||
accel := func(q *core.Array) (*core.Array, error) {
|
||||
qs := q.RawFloats()
|
||||
for i := range n {
|
||||
l, r := 0.0, 0.0
|
||||
if i > 0 {
|
||||
l = qs[i-1]
|
||||
}
|
||||
if i < n-1 {
|
||||
r = qs[i+1]
|
||||
}
|
||||
vals[i] = l - 2*qs[i] + r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
qs := waveVector(b, q0)
|
||||
ps := waveVector(b, waveOnes(n))
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
if _, _, err := IntegrateVerlet(accel, 0, 10, qs, ps, 500); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user