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

179 lines
6.2 KiB
Go
Raw Permalink Blame History

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