Files
tensor/integrate/tolerance_pins_test.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

197 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package integrate
import (
"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)
}
}
}