Files
tensor/signal/periodogram_test.go
T

313 lines
10 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"
)
// TestLombScargleFindsPeak recovers the known 0.1-cycle-per-sample
// period from unevenly sampled data: the grid point nearest the true
// frequency must carry the largest power by a clear margin.
func TestLombScargleFindsPeak(t *testing.T) {
const trueFreq = 0.1
n := 200
times := make([]float64, n)
values := make([]float64, n)
for i := range n {
// Deterministic uneven sampling: every third tick skipped.
times[i] = float64(i) * 1.3
values[i] = math.Sin(2*math.Pi*trueFreq*times[i]) + 0.3*math.Cos(2*math.Pi*0.31*times[i])
}
freqs, power, err := LombScargle(mustFloats(t, times, n), mustFloats(t, values, n),
0.02, 0.45, 400)
if err != nil {
t.Fatalf("LombScargle: %v", err)
}
best, bestF := 0, 0.0
for i := range power.Len() {
if power.FloatAt(i) > float64(best) {
best = i
bestF = freqs.FloatAt(i)
}
}
if math.Abs(bestF-trueFreq) > 0.45/400*3 {
t.Fatalf("peak at %.5f, want within three bins of %.5f", bestF, trueFreq)
}
// The second tone must also stand out on the grid.
power2 := 0.0
for i := range power.Len() {
if math.Abs(freqs.FloatAt(i)-0.31) < 0.01 && power.FloatAt(i) > power2 {
power2 = power.FloatAt(i)
}
}
if power2 <= 0 {
t.Fatal("the secondary tone left no peak")
}
}
// TestLombScargleScale pins the classical normalisation: a pure
// unit-amplitude sinusoid sampled evenly at exactly its period peaks
// at n/(4·var) ≈ n/2 for unit-amplitude data of unit variance… checked
// against the directly evaluated defining sum instead of a hand rule.
func TestLombScargleScale(t *testing.T) {
n := 60
times := make([]float64, n)
values := make([]float64, n)
for i := range n {
times[i] = float64(i)
values[i] = math.Sin(2 * math.Pi * float64(i) / 12)
}
_, power, err := LombScargle(mustFloats(t, times, n), mustFloats(t, values, n),
1.0/12.0, 1.0/12.0, 1)
if err != nil {
t.Fatalf("LombScargle: %v", err)
}
// Direct reference: the two orthogonal sums at the exact tone.
mean := 0.0
for i := range n {
mean += values[i]
}
mean /= float64(n)
variance := 0.0
for i := range n {
d := values[i] - mean
variance += d * d
}
variance /= float64(n - 1)
omega := 2 * math.Pi / 12
sc, ss := 0.0, 0.0
for i := range n {
sc += math.Cos(omega * float64(i))
ss += math.Sin(omega * float64(i))
}
tau := 0.5 * math.Atan2(ss, sc) / omega
sumCos, sumSin, sumCosSq, sumSinSq := 0.0, 0.0, 0.0, 0.0
for i := range n {
arg := omega * (float64(i) - tau)
c, s := math.Cos(arg), math.Sin(arg)
sumCos += (values[i] - mean) * c
sumSin += (values[i] - mean) * s
sumCosSq += c * c
sumSinSq += s * s
}
want := (sumCos*sumCos/sumCosSq + sumSin*sumSin/sumSinSq) / (2 * variance)
if got := power.FloatAt(0); math.IsNaN(got) || math.IsInf(got, 0) {
t.Fatalf("power = %v, want a finite value comparable to %.12g", got, want)
}
if math.Abs(power.FloatAt(0)-want) > 1e-9 {
t.Fatalf("power = %.12g, want the direct sum %.12g", power.FloatAt(0), want)
}
}
// TestLombScargleErrors pins the validation contract.
func TestLombScargleErrors(t *testing.T) {
times := mustFloats(t, []float64{0, 1, 2, 3}, 4)
values := mustFloats(t, []float64{1, 2, 1, 2}, 4)
pair := mustFloats(t, []float64{0, 1}, 2)
if _, _, err := LombScargle(pair, mustFloats(t, []float64{1, 2}, 2), 0.1, 1, 5); err == nil {
t.Fatal("expected an error for a two-point time base")
}
short := mustFloats(t, []float64{0, 1, 2}, 3)
flat := mustFloats(t, []float64{1, 1, 1}, 3)
if _, _, err := LombScargle(short, flat, 0.1, 1, 5); err == nil {
t.Fatal("expected an error for zero variance")
}
if _, _, err := LombScargle(times, mustFloats(t, []float64{1, 2}, 2), 0.1, 1, 5); err == nil {
t.Fatal("expected an error for a length mismatch")
}
if _, _, err := LombScargle(times, values, 0, 1, 5); err == nil {
t.Fatal("expected an error for a non-positive minFreq")
}
if _, _, err := LombScargle(times, values, 1, 0.5, 5); err == nil {
t.Fatal("expected an error for an inverted range")
}
if _, _, err := LombScargle(times, values, 0.1, 1, 0); err == nil {
t.Fatal("expected an error for zero frequency points")
}
}
// TestWelchPSDWhiteNoise checks the level: filtered deterministic
// samples with unit variance must estimate a flat band near one.
func TestWelchPSDWhiteNoise(t *testing.T) {
n := 4096
x := make([]float64, n)
g := core.NewGenerator(7)
draws, err := core.Normal(g, n, 0, 1)
if err != nil {
t.Fatalf("Normal: %v", err)
}
for i := range n {
x[i] = draws.FloatAt(i)
}
_, psd, err := WelchPSD(mustFloats(t, x, n), 1000, 256, 128, "hann")
if err != nil {
t.Fatalf("WelchPSD: %v", err)
}
mean := 0.0
for i := range psd.Len() {
mean += psd.FloatAt(i) / float64(psd.Len())
}
// A flat unit-variance band integrates to σ² over fs/2, so the
// level sits at 2σ²/fs in PSD units of x²/Hz.
if r := mean / (2 / 1000.0); math.Abs(r-1) > 0.15 {
t.Fatalf("mean PSD = %.6f, want 2σ²/fs = %.6f (ratio %.3f)", mean, 2/1000.0, r)
}
}
// TestWelchPSDSinusoid pins the peak location and the Parseval
// balance: the PSD integrated over frequency returns the signal
// variance.
func TestWelchPSDSinusoid(t *testing.T) {
const fs = 128.0
const tone = 16.0
n := 2048
x := make([]float64, n)
for i := range n {
x[i] = math.Sin(2 * math.Pi * tone * float64(i) / fs)
}
freqs, psd, err := WelchPSD(mustFloats(t, x, n), fs, 256, 128, "hann")
if err != nil {
t.Fatalf("WelchPSD: %v", err)
}
peak, best := 0, 0.0
for i := range psd.Len() {
if psd.FloatAt(i) > best {
best = psd.FloatAt(i)
peak = i
}
}
if math.Abs(freqs.FloatAt(peak)-tone) > fs/256 {
t.Fatalf("PSD peak at %.4f Hz, want %.1f", freqs.FloatAt(peak), tone)
}
// Parseval: sum(psd)·df ≈ variance for a windowed estimate on a
// signal with negligible edge leakage.
total, df := 0.0, fs/256
for i := range psd.Len() {
total += psd.FloatAt(i)
}
total *= df
sq := 0.0
for i := range n {
sq += x[i] * x[i]
}
variance := sq / float64(n)
if math.Abs(total-variance)/variance > 0.1 {
t.Fatalf("Parseval: ∫PSD = %.4f, variance = %.4f (rel %.3f)", total, variance,
math.Abs(total-variance)/variance)
}
}
// TestWelchPSDErrors pins the validation contract.
func TestWelchPSDErrors(t *testing.T) {
x := mustFloats(t, make([]float64, 64), 64)
if _, _, err := WelchPSD(x, 100, 128, 0, "hann"); err == nil {
t.Fatal("expected an error for a segment longer than the signal")
}
if _, _, err := WelchPSD(x, 100, 32, 32, "hann"); err == nil {
t.Fatal("expected an error for a full overlap")
}
if _, _, err := WelchPSD(x, 100, 32, -1, "hann"); err == nil {
t.Fatal("expected an error for a negative overlap")
}
if _, _, err := WelchPSD(x, 0, 32, 16, "hann"); err == nil {
t.Fatal("expected an error for a zero sampling rate")
}
if _, _, err := WelchPSD(x, 100, 32, 16, "kaiser"); err == nil {
t.Fatal("expected an error for an unknown window")
}
rank2, _ := core.FromFloats([]float64{1, 2, 3, 4}, 2, 2)
if _, _, err := WelchPSD(rank2, 100, 2, 1, "hann"); err == nil {
t.Fatal("expected an error for a rank-2 signal")
}
}
// TestWelchPSDSegmentCount pins the segment arithmetic: a signal of
// n samples with non-overlapping segments fills exactly
// (n−overlap)/(segment−overlap) segments, the last one included. The
// third of the three segments here carries the only energy, so a
// dropped final segment would leave the Nyquist bin empty, and the
// average over three segments puts |X|²=64 at exactly 64/(3·fs·wPower).
func TestWelchPSDSegmentCount(t *testing.T) {
n := 24
x := make([]float64, n) // segments 1 and 2 are silent
for i := 16; i < n; i++ {
x[i] = 1 // the alternating ±1 pattern shifted to +1: 16+i even
if (i-16)%2 == 1 {
x[i] = -1
}
}
freqs, psd, err := WelchPSD(mustFloats(t, x, n), 1, 8, 0, "box")
if err != nil {
t.Fatalf("WelchPSD: %v", err)
}
if psd.Len() != 5 {
t.Fatalf("bins = %d, want 5", psd.Len())
}
for k := range 5 {
want := 0.0
if k == 4 {
want = 64.0 / (3 * 1 * 8) // one segment of three carries |X[4]|² = 64
}
if math.Abs(psd.FloatAt(k)-want) > 1e-9 {
t.Fatalf("psd[%d] = %.9g, want %.9g", k, psd.FloatAt(k), want)
}
if k == 4 && math.Abs(freqs.FloatAt(k)-0.5) > 1e-9 {
t.Fatalf("bin 4 sits at %.6f Hz, want the 0.5 Nyquist", freqs.FloatAt(k))
}
}
// A signal exactly one segment long is a legal single-segment
// periodogram, not an error.
solo, psd1, err := WelchPSD(mustFloats(t, x[16:], 8), 1, 8, 0, "box")
if err != nil {
t.Fatalf("WelchPSD single segment: %v", err)
}
if solo.Len() != 5 || psd1.Len() != 5 {
t.Fatalf("single-segment shapes = %d/%d, want 5/5", solo.Len(), psd1.Len())
}
if math.Abs(psd1.FloatAt(4)-8) > 1e-9 {
t.Fatalf("single-segment psd[4] = %.9g, want 64/(1·1·8) = 8", psd1.FloatAt(4))
}
}
// TestLombScargleSingleFrequency pins the nFreq == 1 contract: the
// grid holds the single frequency minFreq and a finite power (the
// old grid formula divided 0/0 and produced NaN).
func TestLombScargleSingleFrequency(t *testing.T) {
n := 40
times := make([]float64, n)
values := make([]float64, n)
for i := range n {
times[i] = 1.3 * float64(i)
values[i] = math.Sin(2 * math.Pi * 0.1 * times[i])
}
freqs, power, err := LombScargle(mustFloats(t, times, n), mustFloats(t, values, n), 0.1, 0.7, 1)
if err != nil {
t.Fatalf("LombScargle: %v", err)
}
if freqs.Len() != 1 || power.Len() != 1 {
t.Fatalf("shapes %d/%d, want 1/1", freqs.Len(), power.Len())
}
if freqs.FloatAt(0) != 0.1 {
t.Fatalf("single frequency = %g, want minFreq 0.1", freqs.FloatAt(0))
}
if p := power.FloatAt(0); math.IsNaN(p) || math.IsInf(p, 0) || p <= 0 {
t.Fatalf("single-frequency power = %g, want a finite positive value", p)
}
}
// TestLombScargleConstantTimesErrors pins the degenerate time base: a
// constant time base carries no phase information and used to drive
// the sine fit to 0/0 NaN.
func TestLombScargleConstantTimesErrors(t *testing.T) {
times := mustFloats(t, []float64{2, 2, 2, 2, 2}, 5)
values := mustFloats(t, []float64{1, -1, 1, -1, 1}, 5)
if _, _, err := LombScargle(times, values, 0.1, 1, 5); err == nil {
t.Fatal("expected an error for an all-equal time base")
}
}