Files
tensor/integrate/wave2d_pins_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

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