feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user