Files
tensor/signal/poisson_test.go
T

144 lines
5.4 KiB
Go
Raw 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 signal
import "sourcedock.dev/petrbalvin/tensor/internal/core"
import (
"math"
"testing"
)
// poissonGrid samples a function of (x, y) on the periodic square
// [0, lx] × [0, ly] as a (rows × cols) array, matching the sampling
// SolvePoissonPeriodic documents.
func poissonGrid(t *testing.T, fn func(x, y float64) float64, rows, cols int, lx, ly float64) *core.Array {
t.Helper()
vals := make([]float64, rows*cols)
for r := range rows {
for c := range cols {
vals[r*cols+c] = fn(float64(c)*lx/float64(cols), float64(r)*ly/float64(rows))
}
}
return mustFloats(t, vals, rows, cols)
}
// poissonError returns the largest absolute difference between the
// solution and an exact function of (x, y) on the same grid.
func poissonError(u *core.Array, want func(x, y float64) float64, rows, cols int, lx, ly float64) float64 {
worst := 0.0
for r := range rows {
for c := range cols {
d := math.Abs(u.FloatAt(r*cols+c) - want(float64(c)*lx/float64(cols), float64(r)*ly/float64(rows)))
worst = math.Max(worst, d)
}
}
return worst
}
// TestSolvePoissonPeriodicModes checks the plane-wave answers the
// diagonalisation gets exactly: for u = sin x·sin y the Laplacian
// gives −2u, and for u = sin 3x·cos 2y it gives −13u. Both land on
// round-off.
func TestSolvePoissonPeriodicModes(t *testing.T) {
const lx, ly = 2 * math.Pi, 2 * math.Pi
cases := []struct {
f, u func(x, y float64) float64
}{
{
f: func(x, y float64) float64 { return 2 * math.Sin(x) * math.Sin(y) },
u: func(x, y float64) float64 { return math.Sin(x) * math.Sin(y) },
},
{
f: func(x, y float64) float64 { return 13 * math.Sin(3*x) * math.Cos(2*y) },
u: func(x, y float64) float64 { return math.Sin(3*x) * math.Cos(2*y) },
},
}
for k, tc := range cases {
grid := poissonGrid(t, tc.f, 16, 16, lx, ly)
u, err := SolvePoissonPeriodic(grid, lx, ly)
if err != nil {
t.Fatalf("case %d: SolvePoissonPeriodic: %v", k, err)
}
if u.Dtype() == core.Complex {
t.Fatalf("case %d: the solution must be real", k)
}
if got := poissonError(u, tc.u, 16, 16, lx, ly); got > 1e-10 {
t.Fatalf("case %d: max error %.3g, want round-off", k, got)
}
}
}
// TestSolvePoissonPeriodicSpectral shows the convergence is spectral:
// for u = sin x·e^{sin y}, whose Fourier coefficients decay faster
// than any power, refining the grid drives the error from visible to
// round-off in one refinement.
func TestSolvePoissonPeriodicSpectral(t *testing.T) {
const lx, ly = 2 * math.Pi, 2 * math.Pi
f := func(x, y float64) float64 {
return math.Sin(x) * math.Exp(math.Sin(y)) * (math.Sin(y)*math.Sin(y) + math.Sin(y))
}
u := func(x, y float64) float64 { return math.Sin(x) * math.Exp(math.Sin(y)) }
errAt := func(n int) float64 {
sol, err := SolvePoissonPeriodic(poissonGrid(t, f, n, n, lx, ly), lx, ly)
if err != nil {
t.Fatalf("SolvePoissonPeriodic(%d): %v", n, err)
}
return poissonError(sol, u, n, n, lx, ly)
}
coarse, fine, finer := errAt(16), errAt(32), errAt(64)
if coarse < 1e-9 {
t.Skipf("coarse grid already at round-off (%v)", coarse)
}
if fine >= coarse {
t.Fatalf("refining the grid did not help: %.3g then %.3g", coarse, fine)
}
if fine > 1e-11 || finer > 1e-11 {
t.Fatalf("spectral accuracy not reached: %.3g, %.3g", fine, finer)
}
}
// TestSolvePoissonPeriodicErrors pins the validation contract: the
// zero-mean compatibility condition, the shape and size of the grid,
// and positive side lengths.
func TestSolvePoissonPeriodicErrors(t *testing.T) {
const lx, ly = 2 * math.Pi, 2 * math.Pi
constant := poissonGrid(t, func(x, y float64) float64 { return 1 }, 8, 8, lx, ly)
if _, err := SolvePoissonPeriodic(constant, lx, ly); err == nil {
t.Fatal("expected an error for a right-hand side with nonzero mean")
}
rank1 := mustFloats(t, []float64{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8}, 8)
if _, err := SolvePoissonPeriodic(rank1, lx, ly); err == nil {
t.Fatal("expected an error for a rank-1 right-hand side")
}
small := poissonGrid(t, func(x, y float64) float64 { return x }, 1, 8, lx, ly)
if _, err := SolvePoissonPeriodic(small, lx, ly); err == nil {
t.Fatal("expected an error for a grid dimension below 2")
}
grid := poissonGrid(t, func(x, y float64) float64 { return math.Sin(x) }, 8, 8, lx, ly)
if _, err := SolvePoissonPeriodic(grid, 0, ly); err == nil {
t.Fatal("expected an error for a zero side length")
}
}
// TestSolvePoissonPeriodicDtypeContract pins the dtype contract: the
// solver computes in float64 end to end, so a float32, int or complex
// right-hand side is an error rather than a silently all-zero
// solution (RawFloats is nil for those dtypes).
func TestSolvePoissonPeriodicDtypeContract(t *testing.T) {
const lx, ly = 2 * math.Pi, 2 * math.Pi
f32, _ := core.FromFloat32s([]float32{1, 2, 3, 4, 5, 6, 7, 8, 8, 7, 6, 5, 4, 3, 2, 1}, 4, 4)
if _, err := SolvePoissonPeriodic(f32, lx, ly); err == nil {
t.Fatal("expected an error for a float32 right-hand side")
}
ints, _ := core.FromInts([]int64{1, 2, 3, 4, 5, 6, 7, 8, 8, 7, 6, 5, 4, 3, 2, 1}, 4, 4)
if _, err := SolvePoissonPeriodic(ints, lx, ly); err == nil {
t.Fatal("expected an error for an int right-hand side")
}
cplx, _ := core.FromComplexes(make([]complex128, 16), 4, 4)
if _, err := SolvePoissonPeriodic(cplx, lx, ly); err == nil {
t.Fatal("expected an error for a complex right-hand side")
}
}