Files
tensor/signal/nufft.go
T
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

125 lines
4.6 KiB
Go
Raw 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
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
}