Files
tensor/signal/nufft.go
T

125 lines
4.6 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/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
import "math"
// The type-1 nonuniform fast Fourier transform. Interferometric
// imaging and irregularly sampled spectroscopy ask for the spectrum
// of data whose samples sit at arbitrary coordinates: f_k =
// Σ_j c_j·e^{2πi·k·x_j} over a uniform output grid, with the x_j
// spread anywhere in [−1/2, 1/2). The direct sum costs O(n·m); the
// gridding route costs O(n + m·log m) by scattering the samples onto
// an oversampled grid through a localised Gaussian kernel, running
// one FFT, and undoing the kernel's own Fourier footprint by pointwise
// division. The Gaussian is the kernel of choice because its Fourier
// image is Gaussian too, so the deconvolution is as local as the
// spreading.
// NUFFTType1 computes f_k = Σ_j c_j·e^{2πi·k·x_j} for k = 0 … n−1,
// where x holds the nonuniform sample coordinates in [−1/2, 1/2) and
// c the complex values. The answer carries the gridding error of the
// Gaussian kernel, a few digits short of the direct sum at the
// default kernel width, in exchange for the FFT's speed. Coordinates
// outside [−1/2, 1/2), mismatched lengths, or a non-positive output
// size is an error.
func NUFFTType1(x, c *core.Array, n int) (*core.Array, error) {
const name = "NUFFTType1"
if x.NDim() != 1 || c.NDim() != 1 {
return nil, base.Errf("%s: coordinates and values must be vectors", name)
}
if x.Len() != c.Len() {
return nil, base.Errf("%s: %d coordinates for %d values", name, x.Len(), c.Len())
}
if x.Dtype() == core.Complex {
return nil, base.Errf("%s: the coordinates must be real, got %s", name, x.Dtype())
}
if n <= 0 {
return nil, base.Errf("%s: the output size must be positive, got %d", name, n)
}
count := x.Len()
// Oversampling factor and kernel geometry: the wider the kernel
// relative to the grid, the smaller the aliasing and truncation
// error, at linear cost in the spreading step.
upsampled := 8
for upsampled < 4*n {
upsampled *= 2
}
const sigma = 2.0
const radius = 10
grid := make([]complex128, upsampled)
coords := make([]float64, count)
for j := range count {
coords[j] = x.FloatAt(j)
// NaN defeats both range comparisons below, so it is refused
// by name: it would poison the whole grid and the answer
// would come back all-NaN with no error.
if math.IsNaN(coords[j]) {
return nil, base.Errf("%s: coordinate %d is NaN", name, j)
}
if coords[j] < -0.5 || coords[j] >= 0.5 {
return nil, base.Errf("%s: coordinate %d = %g lies outside [−1/2, 1/2)", name, j, coords[j])
}
}
// Spread: each sample lands on the nearest grid point and bleeds
// into its radius neighbours through the kernel. The sample's value is
// read once, not once per tap.
for j := range count {
g := coords[j] * float64(upsampled)
near := math.Floor(g + 0.5)
frac := g - near
origin := int(near)
cj := c.ComplexAt(j)
for o := -radius; o <= radius; o++ {
idx := ((origin+o)%upsampled + upsampled) % upsampled
t := float64(o) - frac
grid[idx] += cj * complex(math.Exp(-t*t/(2*sigma*sigma)), 0)
}
}
// One inverse FFT of the oversampled grid, scaled by 1/m exactly as
// the IFFT entry point applies it: a division, not a multiplication
// by the reciprocal, so the rounding matches.
transform(grid, +1)
m := complex(float64(upsampled), 0)
for k := range upsampled {
grid[k] /= m
}
// Undo the kernel: the discrete Fourier image of the sampled
// Gaussian at frequency k, computed by the same short sum. The
// Gaussian envelope does not depend on k, so it is built once.
gaussian := make([]float64, 2*radius+1)
for o := -radius; o <= radius; o++ {
gaussian[o+radius] = math.Exp(-float64(o*o) / (2 * sigma * sigma))
}
out, oerr := core.Zeros(core.Complex, []int{n}...)
if oerr != nil {
return nil, oerr
}
spectrum := out.RawComplexes()
for k := range n {
ku := k
if ku > upsampled/2 {
ku = upsampled - ku
}
image := complex(0, 0)
// The same taps in the same order; the row index is carried by
// the range so the kernel weight needs no index add. The
// rotation comes straight from math.Sincos, whose pair is the
// one CmplxPolar multiplies through (verified bit-for-bit), so
// each tap halves its trig work.
for oi := range gaussian {
o := oi - radius
s, c := math.Sincos(-2 * math.Pi * float64(o) * float64(ku) / float64(upsampled))
image += complex(gaussian[oi]*c, gaussian[oi]*s)
}
spectrum[k] = grid[k] / image * complex(float64(upsampled), 0)
}
return out, nil
}