Files

802 lines
26 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
// Pins for the convolution fast-path window arithmetic, the Decimate
// output-length rule, the float32 and int dtype paths of
// Resample/Decimate/IDWT and the SolvePoissonNeumann solve.
package signal
import (
"fmt"
"math"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// TestConv1DStride1TapWindowPastBlock drives a kernel tap whose whole
// window lies outside an output block at unit stride: the stride == 1
// fast path must skip the tap instead of slicing a range whose high
// bound is under its low one (the finding's "slice bounds out of
// range" panic). The result is pinned against the direct convolution
// definition, so the skipped taps are also checked to contribute
// nothing.
func TestConv1DStride1TapWindowPastBlock(t *testing.T) {
const (
n = 202
factor = 3
padding = 200
)
// 202 samples, kernel [1, 2, 3], padding 200: lOut = 600 spans two
// 512-wide blocks, and block 1 sits past the last tap's reach.
vals := make([]float64, n)
for i := range vals {
vals[i] = float64(i%7) - 3
}
x := mustFloats(t, vals, 1, 1, n)
k := mustFloats(t, []float64{1, 2, 3}, 1, 1, factor)
out, err := Conv1D(x, k, nil, 1, padding, 1)
if err != nil {
t.Fatalf("Conv1D: %v", err)
}
if got := out.Shape(); got[0] != 1 || got[1] != 1 || got[2] != 600 {
t.Fatalf("Conv1D: shape %v, want [1 1 600]", got)
}
for ol := range 600 {
want := 0.0
for kl := range factor {
idx := ol + kl - padding
if idx < 0 || idx >= n {
continue
}
want += vals[idx] * float64(kl+1)
}
if got := out.FloatAt(ol); got != want {
t.Fatalf("Conv1D: out[%d] = %g, want %g (the direct definition)", ol, got, want)
}
}
}
// TestConv2DStride1PaddingOnlyTapWindow drives a 2-D kernel tap whose
// window lies entirely in the padding: the stride == 1 fast path must
// skip it rather than slice rank 1 into the tap row.
func TestConv2DStride1PaddingOnlyTapWindow(t *testing.T) {
x := mustFloats(t, []float64{1}, 1, 1, 1, 1)
k := mustFloats(t, []float64{1, 2, 3, 4, 5}, 1, 1, 1, 5)
out, err := Conv2D(x, k, nil, 1, 2)
if err != nil {
t.Fatalf("Conv2D: %v", err)
}
if got := out.Shape(); got[0] != 1 || got[1] != 1 || got[2] != 5 || got[3] != 1 {
t.Fatalf("Conv2D: shape %v, want [1 1 5 1]", got)
}
// Only the kernel's centre tap overlaps the single input sample;
// the taps at kw 0, 1, 3 and 4 reach no output at all.
for oh := range 5 {
want := 0.0
if oh == 2 {
want = 3 // x[0] * k[2]
}
if got := out.FloatAt(oh); got != want {
t.Fatalf("Conv2D: out[0,0,%d,0] = %g, want %g", oh, got, want)
}
}
}
// TestConv3DStride1PaddingOnlyTapWindow drives the 3-D twin of the
// same padding-only tap window.
func TestConv3DStride1PaddingOnlyTapWindow(t *testing.T) {
x := mustFloats(t, []float64{1}, 1, 1, 1, 1, 1)
k := mustFloats(t, []float64{1, 2, 3, 4, 5}, 1, 1, 1, 1, 5)
out, err := Conv3D(x, k, nil, 1, [3]int{0, 0, 2}, [3]int{1, 1, 1})
if err != nil {
t.Fatalf("Conv3D: %v", err)
}
if got := out.Shape(); got[0] != 1 || got[1] != 1 || got[2] != 1 || got[3] != 1 || got[4] != 1 {
t.Fatalf("Conv3D: shape %v, want [1 1 1 1 1]", got)
}
if got := out.FloatAt(0); got != 3 {
t.Fatalf("Conv3D: out[0] = %g, want 3 (x[0] * k[2])", got)
}
}
// TestDecimateOutLenOutOfRange calls Decimate with a custom tap count
// whose filter delay plus first kept index runs to or past the end of
// the filtered signal: the truncated division used to round a negative
// numerator toward zero, producing one output sample read out of
// range.
func TestDecimateOutLenOutOfRange(t *testing.T) {
cases := []struct {
name string
n, factor, taps int
}{
{"ReportedSmallFactor", 26, 8, 25},
{"FactorLargerThanN", 10, 100, 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
vals := make([]float64, tc.n)
for i := range vals {
vals[i] = math.Sin(0.3 * float64(i))
}
x := mustFloats(t, vals, tc.n)
out, err := Decimate(x, tc.factor, tc.taps)
if err != nil {
t.Fatalf("Decimate(n=%d, factor=%d, taps=%d): %v", tc.n, tc.factor, tc.taps, err)
}
want := decimateOutLen(tc.n, tc.factor, tc.taps)
if out.Len() != want {
t.Fatalf("Decimate(n=%d, factor=%d, taps=%d) = %d samples, want %d",
tc.n, tc.factor, tc.taps, out.Len(), want)
}
t.Logf("Decimate(n=%d, factor=%d, taps=%d) -> %d samples", tc.n, tc.factor, tc.taps, out.Len())
})
}
}
// TestResampleDecimateFloat32Input feeds float32 and int series into
// Resample and Decimate: both read the payload without a dtype guard,
// so a non-float input panicked on the nil float64 payload instead of
// widening through FloatAt like DWT and the rest of the package. The
// results are pinned to the float64 run on the same values.
func TestResampleDecimateFloat32Input(t *testing.T) {
const n = 256
f64 := make([]float64, n)
f32 := make([]float32, n)
for i := range n {
f32[i] = float32(math.Cos(2 * math.Pi * 3 * float64(i) / float64(n)))
// The float64 twin holds the widened float32 values, so the
// two runs see bit-identical inputs.
f64[i] = float64(f32[i])
}
a32 := mustFloat32s(t, f32, n)
a64 := mustFloats(t, f64, n)
t.Run("ResampleFloat32", func(t *testing.T) {
got, err := Resample(a32, 3, 1, 0)
if err != nil {
t.Fatalf("Resample(float32): %v", err)
}
want, err := Resample(a64, 3, 1, 0)
if err != nil {
t.Fatalf("Resample(float64): %v", err)
}
if got.Len() != want.Len() {
t.Fatalf("Resample(float32) = %d samples, want %d", got.Len(), want.Len())
}
for i := range want.Len() {
// FloatAt widens exactly, so both runs must agree bit
// for bit on the same input values.
if g, w := got.FloatAt(i), want.FloatAt(i); g != w {
t.Fatalf("Resample sample %d = %g, want %g", i, g, w)
}
}
})
t.Run("DecimateFloat32", func(t *testing.T) {
got, err := Decimate(a32, 2, 0)
if err != nil {
t.Fatalf("Decimate(float32): %v", err)
}
want, err := Decimate(a64, 2, 0)
if err != nil {
t.Fatalf("Decimate(float64): %v", err)
}
if got.Len() != want.Len() {
t.Fatalf("Decimate(float32) = %d samples, want %d", got.Len(), want.Len())
}
// FilterApply keeps the float32 dtype, so its output samples
// are the float64 ones rounded to float32 on store; the
// decimation picks the same ones. The comparison is exact.
for i := range want.Len() {
w := float64(float32(want.FloatAt(i)))
if g := got.FloatAt(i); g != w {
t.Fatalf("Decimate sample %d = %g, want %g", i, g, w)
}
}
})
t.Run("Int", func(t *testing.T) {
ivals := make([]int64, n)
fvals := make([]float64, n)
for i := range n {
ivals[i] = int64(i%7) - 3
fvals[i] = float64(ivals[i])
}
ai, err := core.FromInts(ivals, n)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
af := mustFloats(t, fvals, n)
gi, err := Resample(ai, 2, 1, 0)
if err != nil {
t.Fatalf("Resample(int): %v", err)
}
gf, err := Resample(af, 2, 1, 0)
if err != nil {
t.Fatalf("Resample(float64): %v", err)
}
for i := range gf.Len() {
if g, w := gi.FloatAt(i), gf.FloatAt(i); g != w {
t.Fatalf("Resample int sample %d = %g, want %g", i, g, w)
}
}
di, err := Decimate(ai, 4, 0)
if err != nil {
t.Fatalf("Decimate(int): %v", err)
}
df, err := Decimate(af, 4, 0)
if err != nil {
t.Fatalf("Decimate(float64): %v", err)
}
for i := range df.Len() {
if g, w := di.FloatAt(i), df.FloatAt(i); g != w {
t.Fatalf("Decimate int sample %d = %g, want %g", i, g, w)
}
}
})
}
// TestIDWTFloat32Coefficients inverts a float32 coefficient array:
// DWT accepts float32 through FloatAt, and IDWT reading the nil
// float64 payload instead returned all zeros with no error. The
// float32 and float64 runs must agree sample for sample.
func TestIDWTFloat32Coefficients(t *testing.T) {
// A genuine packed coefficient array, so the assertion covers a
// realistic layout: the DWT of a ramp at one level.
ramp := make([]float64, 8)
for i := range ramp {
ramp[i] = float64(i + 1)
}
coef, err := DWT(mustFloats(t, ramp, 8), 1)
if err != nil {
t.Fatalf("DWT: %v", err)
}
f32 := make([]float32, 8)
f64 := make([]float64, 8)
for i := range 8 {
v := coef.FloatAt(i)
f32[i] = float32(v)
f64[i] = float64(f32[i])
}
got, err := IDWT(mustFloat32s(t, f32, 8), 1)
if err != nil {
t.Fatalf("IDWT(float32): %v", err)
}
want, err := IDWT(mustFloats(t, f64, 8), 1)
if err != nil {
t.Fatalf("IDWT(float64): %v", err)
}
nonzero := 0
for i := range 8 {
g, w := got.FloatAt(i), want.FloatAt(i)
if g != w {
t.Fatalf("IDWT sample %d = %g, want %g", i, g, w)
}
if g != 0 {
nonzero++
}
}
if nonzero == 0 {
t.Fatal("IDWT returned all zeros for a float32 coefficient array")
}
}
// mustFloat32s builds a float32 array, failing the test on a bad shape.
func mustFloat32s(t *testing.T, vals []float32, shape ...int) *core.Array {
t.Helper()
a, err := core.FromFloat32s(vals, shape...)
if err != nil {
t.Fatalf("FromFloat32s(%v, %v): %v", vals, shape, err)
}
return a
}
// decimateOutLen mirrors the documented output rule for the tap count
// the caller passes: the filter delay plus the first kept sample must
// leave whole factors behind it. It is what the regression test
// compares the library against, written from the rule rather than from
// the implementation.
func decimateOutLen(n, factor, taps int) int {
if taps%2 == 0 {
taps++
}
if taps >= n {
return 0
}
delay := (taps - 1) / 2
// A kept sample needs m + delay <= n - 1 for some multiple m of
// factor with m >= delay, i.e. floor((n-1-delay)/factor) - skip + 1
// samples exist below the end of the signal.
skip := (delay + factor - 1) / factor
lastMultiple := (n - 1 - delay) / factor
if lastMultiple < skip {
return 0
}
return lastMultiple - skip + 1
}
// assertFinite fails the test on the first non-finite sample: a
// maximum-error loop cannot see a NaN, because every comparison
// against one is false, which is how the shipped manufactured test
// passed on an all-NaN solve. Every solve assertion below runs after
// this check.
func assertFinite(t *testing.T, what string, vals []float64) {
t.Helper()
for i, v := range vals {
if math.IsNaN(v) || math.IsInf(v, 0) {
t.Fatalf("%s: sample %d is %g, the result must be finite", what, i, v)
}
}
}
// stencilTap is one entry of a mirrored-stencil operator row: the
// column index and the coefficient in units of 1/h².
type stencilTap struct {
j int
v float64
}
// mirroredStencilRows returns the taps of the ghost-mirror operator's
// row i of an N-point axis: [2,-2] on the boundary, where the mirrored
// neighbour outside the interval stands in for zero slope, and
// [-1,2,-1] inside. No code in the library shares these taps; they are
// the definition the regression tests hold the solve against.
func mirroredStencilRows(n, i int) []stencilTap {
switch {
case i == 0:
return []stencilTap{{0, 2}, {1, -2}}
case i == n-1:
return []stencilTap{{n - 2, -2}, {n - 1, 2}}
default:
return []stencilTap{{i - 1, -1}, {i, 2}, {i + 1, -1}}
}
}
// mirroredStencilApply applies the 2-D mirrored operator to a
// row-major grid.
func mirroredStencilApply(u []float64, rows, cols int, hx, hy float64) []float64 {
out := make([]float64, rows*cols)
for r := range rows {
for c := range cols {
s := 0.0
for _, e := range mirroredStencilRows(cols, c) {
s += e.v * u[r*cols+e.j] / (hx * hx)
}
for _, e := range mirroredStencilRows(rows, r) {
s += e.v * u[e.j*cols+c] / (hy * hy)
}
out[r*cols+c] = s
}
}
return out
}
// mirroredStencilMatrix builds the explicit operator matrix of the
// (rows·cols)-point grid: column j is the operator applied to a unit
// input at j, so row i holds the coefficients of equation i.
func mirroredStencilMatrix(rows, cols int, hx, hy float64) [][]float64 {
n := rows * cols
m := make([][]float64, n)
for i := range n {
m[i] = make([]float64, n)
}
for j := range n {
u := make([]float64, n)
u[j] = 1
col := mirroredStencilApply(u, rows, cols, hx, hy)
for i := range n {
m[i][j] = col[i]
}
}
return m
}
// neumannTrapz returns the trapezoidal-weighted sum of a row-major
// grid and the measure's total weight: the boundary rows and columns
// count half. That weight vector is the mirrored operator's left null
// vector, the measure its compatibility condition is stated in.
func neumannTrapz(u []float64, rows, cols int) (sum, weight float64) {
for r := range rows {
wr := 1.0
if r == 0 || r == rows-1 {
wr = 0.5
}
for c := range cols {
wc := 1.0
if c == 0 || c == cols-1 {
wc = 0.5
}
sum += wr * wc * u[r*cols+c]
weight += wr * wc
}
}
return sum, weight
}
// neumannTrapzSum returns the trapezoidal-weighted sum of a row-major
// grid, the gate's measure.
func neumannTrapzSum(u []float64, rows, cols int) float64 {
sum, _ := neumannTrapz(u, rows, cols)
return sum
}
// neumannTrapzMean returns the trapezoidal-weighted mean, the sum over
// its total weight.
func neumannTrapzMean(u []float64, rows, cols int) float64 {
sum, weight := neumannTrapz(u, rows, cols)
return sum / weight
}
// neumannDenseSolve solves the singular mirrored system M u = f by
// replacing the last equation with the zero-trapezoidal-mean
// constraint, which makes it nonsingular (the trapezoidal weight is
// the left null vector, so it cannot lie in the row space), then
// eliminating with partial pivoting. Test scaffolding: it panics on a
// singular matrix rather than returning a wrong answer.
func neumannDenseSolve(m [][]float64, f []float64, rows, cols int) []float64 {
n := len(f)
a := make([][]float64, n)
for i := range n {
a[i] = append(append([]float64(nil), m[i]...), f[i])
}
for j := range n {
r, c := j/cols, j%cols
wr, wc := 1.0, 1.0
if r == 0 || r == rows-1 {
wr = 0.5
}
if c == 0 || c == cols-1 {
wc = 0.5
}
a[n-1][j] = wr * wc
}
a[n-1][n] = 0
for col := range n {
piv := col
for r := col + 1; r < n; r++ {
if math.Abs(a[r][col]) > math.Abs(a[piv][col]) {
piv = r
}
}
a[col], a[piv] = a[piv], a[col]
p := a[col][col]
if p == 0 {
panic("neumannDenseSolve: singular matrix")
}
for r := col + 1; r < n; r++ {
fac := a[r][col] / p
for c := col; c <= n; c++ {
a[r][c] -= fac * a[col][c]
}
}
}
u := make([]float64, n)
for i := n - 1; i >= 0; i-- {
s := a[i][n]
for j := i + 1; j < n; j++ {
s -= a[i][j] * u[j]
}
u[i] = s / a[i][i]
}
return u
}
// TestSolvePoissonNeumannMirroredStencil holds the solve against the
// explicit ghost-mirror matrix: the operator is built entry by entry
// for the small grids, inverted through a dense elimination with the
// zero-trapezoidal-mean constraint, and the library's solution must
// agree with it, satisfy M u = f and carry no trapezoidal mean.
func TestSolvePoissonNeumannMirroredStencil(t *testing.T) {
for _, g := range []struct {
rows, cols int
lx, ly float64
}{
{3, 3, 1, 1},
{4, 4, 1, 1},
{5, 3, 1.7, 0.9},
{9, 9, 1, 1},
} {
hx, hy := g.lx/float64(g.cols-1), g.ly/float64(g.rows-1)
// A compatible source: a cosine mode minus its trapezoidal
// mean, so the constant mode of the transform vanishes.
f := make([]float64, g.rows*g.cols)
for r := range g.rows {
for c := range g.cols {
f[r*g.cols+c] = math.Cos(2*math.Pi*float64(c)/float64(g.cols-1)) *
math.Cos(3*math.Pi*float64(r)/float64(g.rows-1))
}
}
mu := neumannTrapzMean(f, g.rows, g.cols)
for i := range f {
f[i] -= mu
}
want := neumannDenseSolve(mirroredStencilMatrix(g.rows, g.cols, hx, hy), f, g.rows, g.cols)
got, err := SolvePoissonNeumann(mustFloats(t, f, g.rows, g.cols), g.lx, g.ly)
if err != nil {
t.Fatalf("%dx%d: SolvePoissonNeumann: %v", g.rows, g.cols, err)
}
vals := make([]float64, got.Len())
for i := range vals {
vals[i] = got.FloatAt(i)
}
assertFinite(t, fmt.Sprintf("%dx%d solve", g.rows, g.cols), vals)
worst := 0.0
for i := range f {
worst = max(worst, math.Abs(vals[i]-want[i]))
}
// The dense solve carries its own round-off; the agreement is
// at the 1e-16 level on these grids, so the bound below keeps
// orders of margin and still pins an operator error.
if worst > 1e-12 {
t.Fatalf("%dx%d: solve differs from the dense inverse by %.3g", g.rows, g.cols, worst)
}
resid := mirroredStencilApply(vals, g.rows, g.cols, hx, hy)
rus := 0.0
for i := range f {
rus = max(rus, math.Abs(resid[i]-f[i]))
}
if rus > 1e-12 {
t.Fatalf("%dx%d: residual |M u - f| = %.3g", g.rows, g.cols, rus)
}
if mean := neumannTrapzMean(vals, g.rows, g.cols); math.Abs(mean) > 1e-14 {
t.Fatalf("%dx%d: trapezoidal mean of the solution is %.3g, want zero", g.rows, g.cols, mean)
}
t.Logf("%dx%d lx=%g ly=%g: max |solve - dense inverse| = %.3g", g.rows, g.cols, g.lx, g.ly, worst)
}
}
// TestSolvePoissonNeumannDiscreteEigenfunction pins the two halves of
// the construction: the mirrored stencil really has the cosine
// eigenfunctions with the eigenvalues 2/h²(1-cos(πk/(N-1))) per axis,
// and the solve inverts the stencil on an eigenfunction to transform
// round-off, which is the exactness the spectral solve promises.
func TestSolvePoissonNeumannDiscreteEigenfunction(t *testing.T) {
for _, g := range []struct {
rows, cols, kx, ky int
}{
{9, 9, 2, 3},
{17, 17, 4, 5},
{12, 8, 3, 5},
} {
hx, hy := 1/float64(g.cols-1), 1/float64(g.rows-1)
mode := make([]float64, g.rows*g.cols)
for r := range g.rows {
for c := range g.cols {
mode[r*g.cols+c] = math.Cos(math.Pi*float64(g.kx)*float64(c)/float64(g.cols-1)) *
math.Cos(math.Pi*float64(g.ky)*float64(r)/float64(g.rows-1))
}
}
lambda := 2/(hx*hx)*(1-math.Cos(math.Pi*float64(g.kx)/float64(g.cols-1))) +
2/(hy*hy)*(1-math.Cos(math.Pi*float64(g.ky)/float64(g.rows-1)))
applied := mirroredStencilApply(mode, g.rows, g.cols, hx, hy)
for i := range mode {
if e := math.Abs(applied[i] - lambda*mode[i]); e > 1e-12*lambda {
t.Fatalf("%dx%d k=(%d,%d): M v - lambda v = %.3g at %d, the cosine mode is not an eigenfunction",
g.rows, g.cols, g.kx, g.ky, e, i)
}
}
got, err := SolvePoissonNeumann(mustFloats(t, applied, g.rows, g.cols), 1, 1)
if err != nil {
t.Fatalf("%dx%d: SolvePoissonNeumann: %v", g.rows, g.cols, err)
}
mu := neumannTrapzMean(mode, g.rows, g.cols)
gotVals := make([]float64, got.Len())
for i := range gotVals {
gotVals[i] = got.FloatAt(i)
}
assertFinite(t, fmt.Sprintf("%dx%d solve", g.rows, g.cols), gotVals)
worst := 0.0
for i := range mode {
worst = max(worst, math.Abs(gotVals[i]-(mode[i]-mu)))
}
// The acceptance level is the transform's round-off, about
// 1e-14; the measured worst across these grids is 6.1e-15.
if worst > 1e-13 {
t.Fatalf("%dx%d k=(%d,%d): eigenfunction recovered to %.3g only", g.rows, g.cols, g.kx, g.ky, worst)
}
t.Logf("%dx%d k=(%d,%d): eigenfunction error %.3g", g.rows, g.cols, g.kx, g.ky, worst)
}
}
// TestSolvePoissonNeumannSecondOrder runs the manufactured solution
// u = cos(πx)cos(πy), f = 2π²u (which the trapezoidal measure already
// sees as zero-mean) on three grids: every sample must be finite, the
// recentred error must sit inside the stencil's O(h²) budget, the
// error must fall by about four per grid doubling, and the result must
// carry no trapezoidal mean. The shipped test of the same construction
// compares NaN samples as "not larger", so this one asserts finiteness
// first.
func TestSolvePoissonNeumannSecondOrder(t *testing.T) {
worstOf := map[int]float64{}
for _, n := range []int{9, 17, 33} {
h := 1 / float64(n-1)
flatF := make([]float64, n*n)
exact := make([]float64, n*n)
for r := range n {
for c := range n {
x := float64(c) * h
y := float64(r) * h
exact[r*n+c] = math.Cos(math.Pi*x) * math.Cos(math.Pi*y)
flatF[r*n+c] = 2 * math.Pi * math.Pi * exact[r*n+c]
}
}
u, err := SolvePoissonNeumann(mustFloats(t, flatF, n, n), 1, 1)
if err != nil {
t.Fatalf("grid %d: SolvePoissonNeumann: %v", n, err)
}
vals := make([]float64, u.Len())
for i := range vals {
vals[i] = u.FloatAt(i)
}
assertFinite(t, fmt.Sprintf("grid %d", n), vals)
if mean := neumannTrapzMean(vals, n, n); math.Abs(mean) > 1e-14 {
t.Fatalf("grid %d: trapezoidal mean of the solution is %.3g, want zero", n, mean)
}
// Recentring: the solve fixes the constant mode, the exact
// solution's own trapezoidal mean is zero, so this is the
// contract's comparison and not a fudge.
got := neumannTrapzMean(vals, n, n)
worst := 0.0
for i := range exact {
worst = max(worst, math.Abs(vals[i]-got-exact[i]))
}
if worst > 3*h*h {
t.Fatalf("grid %d: worst error %.3g above the O(h²) budget %.3g", n, worst, 3*h*h)
}
worstOf[n] = worst
}
// O(h²) means the error falls by about four per doubling; the
// measured ratios sit near 4, so anything under 3 fails the claim.
if r := worstOf[9] / worstOf[17]; r < 3 {
t.Fatalf("error fell by only %.3f from 9 to 17 points, not the O(h²) factor", r)
}
if r := worstOf[17] / worstOf[33]; r < 3 {
t.Fatalf("error fell by only %.3f from 17 to 33 points, not the O(h²) factor", r)
}
t.Logf("worst errors (h² = %.3g, %.3g, %.3g): %.3g, %.3g, %.3g",
1/64.0/64.0, 1/16.0/16.0, 1/32.0/32.0, worstOf[9], worstOf[17], worstOf[33])
}
// TestSolvePoissonNeumannTrapzRefusal checks the compatibility gate on
// its own measure: a source whose plain mean is zero to rounding but
// whose trapezoidal-weighted sum is not has no mirrored-stencil
// solution, and the solve must refuse it by name rather than divide
// the constant mode by its zero eigenvalue. The corners-only source is
// the smallest such case: the boundary samples carry half the
// trapezoidal weight, so removing the plain mean leaves a large
// trapezoidal component.
func TestSolvePoissonNeumannTrapzRefusal(t *testing.T) {
const n = 9
flat := make([]float64, n*n)
for _, i := range []int{0, n - 1, (n - 1) * n, n*n - 1} {
flat[i] = 1
}
plain := 0.0
for _, v := range flat {
plain += v
}
plain /= float64(n * n)
for i := range flat {
flat[i] -= plain
}
// The construction is only meaningful if the plain mean really
// vanished: otherwise the old gate would have caught it.
residual := 0.0
for _, v := range flat {
residual += v
}
if math.Abs(residual) > 1e-14 {
t.Fatalf("test construction: residual plain sum %g", residual)
}
if sum := neumannTrapzSum(flat, n, n); math.Abs(sum) < 1 {
t.Fatalf("test construction: trapezoidal sum %.3g is too small to exercise the gate", sum)
}
f := mustFloats(t, flat, n, n)
out, err := SolvePoissonNeumann(f, 1, 1)
if err == nil {
t.Fatalf("zero-plain-mean but trapezoidally incompatible source accepted, solution = %v of %d samples",
out.FloatAt(0), out.Len())
}
if !strings.Contains(err.Error(), "SolvePoissonNeumann") || !strings.Contains(err.Error(), "trapezoidal") {
t.Fatalf("refusal %q does not name the function and the measure", err)
}
// The all-ones source has no solution on either measure, and the
// shipped refusal test's shape guards stay intact.
if _, err := SolvePoissonNeumann(mustFloats(t, []float64{1, 1, 1, 1, 1, 1, 1, 1, 1}, 3, 3), 1, 1); err == nil {
t.Fatal("nonzero-mean source accepted")
}
}
// The view contract of the new entry points: a rebased view's payload
// is longer than its element count and starts past offset zero, so a
// kernel that reaches for the raw payload instead of the elements
// reads the wrong window of the backing array. Each test compares the
// view answer against the same values in a fresh array.
func TestEntryPointsOnView(t *testing.T) {
ramp := make([]float64, 20)
for i := range ramp {
ramp[i] = float64(i)
}
back, err := core.FromFloats(ramp, 20)
if err != nil {
t.Fatal(err)
}
view, err := core.Slice(back, 0, 3, 19)
if err != nil {
t.Fatal(err)
}
fresh, err := core.FromFloats(append([]float64{}, ramp[3:19]...), 16)
if err != nil {
t.Fatal(err)
}
median, err := MedianFilter(view, 3)
if err != nil {
t.Fatalf("MedianFilter: %v", err)
}
wantMedian, err := MedianFilter(fresh, 3)
if err != nil {
t.Fatalf("MedianFilter: %v", err)
}
for i := range 16 {
if median.FloatAt(i) != wantMedian.FloatAt(i) {
t.Fatalf("MedianFilter(view)[%d] = %v, want %v", i, median.FloatAt(i), wantMedian.FloatAt(i))
}
}
rank, err := RankFilter(view, 5, 0)
if err != nil {
t.Fatalf("RankFilter: %v", err)
}
wantRank, err := RankFilter(fresh, 5, 0)
if err != nil {
t.Fatalf("RankFilter: %v", err)
}
for i := range 16 {
if rank.FloatAt(i) != wantRank.FloatAt(i) {
t.Fatalf("RankFilter(view)[%d] = %v, want %v", i, rank.FloatAt(i), wantRank.FloatAt(i))
}
}
b, a, err := ButterworthLowPass(2, 2.0, 0.4)
if err != nil {
t.Fatal(err)
}
filtered, err := Filtfilt(b, a, view)
if err != nil {
t.Fatalf("Filtfilt: %v", err)
}
wantFiltered, err := Filtfilt(b, a, fresh)
if err != nil {
t.Fatalf("Filtfilt: %v", err)
}
for i := range 16 {
if math.Float64bits(filtered.FloatAt(i)) != math.Float64bits(wantFiltered.FloatAt(i)) {
t.Fatalf("Filtfilt(view)[%d] = %v, want %v", i, filtered.FloatAt(i), wantFiltered.FloatAt(i))
}
}
coef, err := DaubechiesDWT(view, DB2, 1, DWTPeriodic)
if err != nil {
t.Fatalf("DaubechiesDWT: %v", err)
}
wantCoef, err := DaubechiesDWT(fresh, DB2, 1, DWTPeriodic)
if err != nil {
t.Fatalf("DaubechiesDWT: %v", err)
}
for i := range coef.Len() {
if math.Float64bits(coef.FloatAt(i)) != math.Float64bits(wantCoef.FloatAt(i)) {
t.Fatalf("DaubechiesDWT(view)[%d] = %v, want %v", i, coef.FloatAt(i), wantCoef.FloatAt(i))
}
}
}
// TestWindowKaiserRefusesNonFiniteBeta pins the refusal of a beta the
// Bessel ratio cannot carry: +Inf used to answer an all-NaN window.
func TestWindowKaiserRefusesNonFiniteBeta(t *testing.T) {
for _, beta := range []float64{math.Inf(1), math.Inf(-1), math.NaN()} {
if _, err := WindowKaiser(8, beta, false); err == nil {
t.Fatalf("WindowKaiser beta %g: expected an error, got none", beta)
}
}
}