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

92 lines
2.8 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package signal
import (
"math"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// hilbertTone builds an integer number of periods of a cosine, where
// the periodic Fourier definition of the analytic signal is exact.
func hilbertTone(t *testing.T, cycles, n int) *core.Array {
t.Helper()
vals := make([]float64, n)
for i := range n {
vals[i] = math.Cos(2 * math.Pi * float64(cycles) * float64(i) / float64(n))
}
a, err := core.FromFloats(vals, n)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
return a
}
// TestAnalyticSignalTone checks the defining property on a pure tone:
// the analytic signal of cos is exp(iωt), so the real part is the
// input, the imaginary part the quadrature sine and the modulus one.
func TestAnalyticSignalTone(t *testing.T) {
const cycles, n = 13, 256
z, err := AnalyticSignal(hilbertTone(t, cycles, n))
if err != nil {
t.Fatalf("AnalyticSignal: %v", err)
}
bins := z.RawComplexes()[:z.Len()]
for i, c := range bins {
want := 2 * math.Pi * float64(cycles) * float64(i) / float64(n)
if math.Abs(real(c)-math.Cos(want)) > 1e-9 {
t.Fatalf("bin %d real part %.12g, want the input cosine", i, real(c))
}
if math.Abs(imag(c)-math.Sin(want)) > 1e-9 {
t.Fatalf("bin %d imaginary part %.12g, want the quadrature sine", i, imag(c))
}
if e := math.Hypot(real(c), imag(c)); math.Abs(e-1) > 1e-9 {
t.Fatalf("bin %d modulus %.12g, want 1", i, e)
}
}
}
// TestEnvelopeAmplitudeModulation checks the envelope on an AM
// carrier: the modulus of the analytic signal must recover the
// modulating wave, the quantity an AM receiver is after.
func TestEnvelopeAmplitudeModulation(t *testing.T) {
const n = 512
vals := make([]float64, n)
for i := range n {
mod := 1 + 0.5*math.Cos(2*math.Pi*4*float64(i)/float64(n))
carrier := math.Cos(2 * math.Pi * 40 * float64(i) / float64(n))
vals[i] = mod * carrier
}
a, err := core.FromFloats(vals, n)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
env, err := Envelope(a)
if err != nil {
t.Fatalf("Envelope: %v", err)
}
vals = env.RawFloats()[:env.Len()]
for i, got := range vals {
want := 1 + 0.5*math.Cos(2*math.Pi*4*float64(i)/float64(n))
if math.Abs(got-want) > 1e-9 {
t.Fatalf("sample %d envelope %.12g, want %.12g", i, got, want)
}
}
}
// TestAnalyticSignalRefusals checks the shape and emptiness guards.
func TestAnalyticSignalRefusals(t *testing.T) {
if _, err := AnalyticSignal(core.New(core.Float, 3, 2)); err == nil {
t.Fatal("matrix accepted")
}
if _, err := AnalyticSignal(core.New(core.Float, 0)); err == nil {
t.Fatal("zero-length series accepted")
}
if _, err := Envelope(core.New(core.Float, 3, 3, 3)); err == nil {
t.Fatal("3-D array accepted")
}
}