Files

371 lines
14 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// 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")
}
}