802 lines
26 KiB
Go
802 lines
26 KiB
Go
// 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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|