Files

392 lines
15 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
// 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")
}
}