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

313 lines
10 KiB
Go
Raw Permalink 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/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")
}
}