203 lines
7.0 KiB
Go
203 lines
7.0 KiB
Go
// 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"
|
||
"sourcedock.dev/petrbalvin/tensor/internal/engine"
|
||
)
|
||
|
||
import (
|
||
"math"
|
||
"sync"
|
||
)
|
||
|
||
// Periodograms. The Lomb-Scargle periodogram answers "at which
|
||
// frequency does unevenly sampled data oscillate" without the
|
||
// interpolation a resampled FFT would need: each trial frequency gets
|
||
// its own least-squares fit of a sine and cosine through the actual
|
||
// observation times, with the phase reference τ chosen so the two
|
||
// fitted components are exactly orthogonal at that frequency.
|
||
|
||
// lombParallelMinN is the observation count above which a single
|
||
// frequency's two sine/cosine passes are worth a worker's spawn cost.
|
||
// Below it the frequency grid walk stays on the calling goroutine.
|
||
const lombParallelMinN = 1 << 9
|
||
|
||
// LombScargle computes the normalised Lomb-Scargle periodogram of the
|
||
// observations values taken at times, over the nFreq frequencies
|
||
// evenly spaced from minFreq to maxFreq inclusive (a single frequency
|
||
// when nFreq is 1) and returns the frequency grid and the power at
|
||
// each frequency. The power carries the classical
|
||
// normalisation: a pure sinusoid of amplitude A at a frequency on the
|
||
// grid peaks near A²·n/(4·var(values)), so the scale is comparable
|
||
// across data sets. An empty or two-point time base, a length
|
||
// mismatch, an all-equal time base, a non-positive variance, or a
|
||
// frequency range that does not satisfy 0 < minFreq ≤ maxFreq is an
|
||
// error.
|
||
func LombScargle(times, values *core.Array, minFreq, maxFreq float64, nFreq int) (freqs, power *core.Array, err error) {
|
||
const name = "LombScargle"
|
||
if times.NDim() != 1 || values.NDim() != 1 {
|
||
return nil, nil, base.Errf("%s: times and values must be vectors", name)
|
||
}
|
||
if times.Dtype() == core.Complex || values.Dtype() == core.Complex {
|
||
return nil, nil, base.Errf("%s: complex arrays are not supported", name)
|
||
}
|
||
n := values.Len()
|
||
if n < 3 {
|
||
return nil, nil, base.Errf("%s: at least three observations are needed, got %d", name, n)
|
||
}
|
||
if times.Len() != n {
|
||
return nil, nil, base.Errf("%s: times has %d entries for %d values", name, times.Len(), n)
|
||
}
|
||
if nFreq < 1 {
|
||
return nil, nil, base.Errf("%s: nFreq must be at least 1, got %d", name, nFreq)
|
||
}
|
||
// The gate is NaN-rejecting and Inf-rejecting at once: +Inf passes
|
||
// a bare > 0, and Inf endpoints turn every interpolated frequency
|
||
// into NaN with no error.
|
||
if !(minFreq > 0) || math.IsInf(minFreq, 0) || math.IsInf(maxFreq, 0) || maxFreq < minFreq {
|
||
return nil, nil, base.Errf("%s: the frequency range must satisfy 0 < minFreq ≤ maxFreq over finite frequencies, got [%g, %g]",
|
||
name, minFreq, maxFreq)
|
||
}
|
||
t := make([]float64, n)
|
||
x := make([]float64, n)
|
||
mean := 0.0
|
||
// A non-finite time or value would drive the variance NaN, slip
|
||
// past its gate and publish NaN powers with no error, so both
|
||
// arrays are refused up front (the guard SolvePoissonPeriodic
|
||
// applies to its source).
|
||
for i := range n {
|
||
t[i] = times.FloatAt(i)
|
||
if math.IsNaN(t[i]) || math.IsInf(t[i], 0) {
|
||
return nil, nil, base.Errf("%s: times holds the non-finite value %g at %d", name, t[i], i)
|
||
}
|
||
x[i] = values.FloatAt(i)
|
||
if math.IsNaN(x[i]) || math.IsInf(x[i], 0) {
|
||
return nil, nil, base.Errf("%s: values holds the non-finite value %g at %d", name, x[i], i)
|
||
}
|
||
mean += x[i]
|
||
}
|
||
mean /= float64(n)
|
||
// A constant time base carries no phase information: every trial
|
||
// frequency drives the sine fit to 0/0.
|
||
if allEqual(t) {
|
||
return nil, nil, base.Errf("%s: the times must not all be equal", name)
|
||
}
|
||
variance := 0.0
|
||
for i := range n {
|
||
x[i] -= mean
|
||
variance += x[i] * x[i]
|
||
}
|
||
variance /= float64(n - 1)
|
||
if variance <= 0 {
|
||
return nil, nil, base.Errf("%s: the values have zero variance", name)
|
||
}
|
||
tCenter := t[n/2]
|
||
// The offsets from the phase centre feed every trig argument of
|
||
// every frequency: (t[i]−tCenter) is recomputed twice per
|
||
// observation per frequency, so it is evaluated once here and
|
||
// reused. The stored value is the subtraction result itself, so
|
||
// every argument keeps the exact bits it had.
|
||
dt := make([]float64, n)
|
||
for i := range n {
|
||
dt[i] = t[i] - tCenter
|
||
}
|
||
freqsArr := core.New(core.Float, nFreq)
|
||
powerArr := core.New(core.Float, nFreq)
|
||
freqRow := freqsArr.RawFloats()
|
||
powerRow := powerArr.RawFloats()
|
||
// A frequency whose sine or cosine sum vanishes cannot be fitted
|
||
// on this time base. The serial walk reported the lowest such
|
||
// frequency; the split keeps that contract by remembering the
|
||
// smallest offending index and erroring after the join.
|
||
var (
|
||
badMu sync.Mutex
|
||
bad = -1
|
||
)
|
||
unresolvable := func(f int) {
|
||
badMu.Lock()
|
||
defer badMu.Unlock()
|
||
if bad < 0 || f < bad {
|
||
bad = f
|
||
}
|
||
}
|
||
// fitAt runs the whole per-frequency pipeline: the grid frequency,
|
||
// the orthogonalising phase reference τ and both least-squares
|
||
// fits. Every read is from the shared time and value slices, every
|
||
// write lands in this frequency's own slot of the two outputs, and
|
||
// the per-frequency arithmetic sequence is the serial one
|
||
// unchanged, so the split cannot move an addend.
|
||
fitAt := func(f int) {
|
||
freq := minFreq
|
||
if nFreq > 1 {
|
||
freq = minFreq + (maxFreq-minFreq)*float64(f)/float64(nFreq-1)
|
||
}
|
||
freqRow[f] = freq
|
||
omega := 2 * math.Pi * freq
|
||
// The phase reference τ keeps the sine and cosine fits
|
||
// orthogonal at this frequency. Both passes need the sine and
|
||
// the cosine of the same argument; math.Sincos shares the range
|
||
// reduction between the two and returns exactly the pair
|
||
// math.Sin and math.Cos produce (verified bit-for-bit), so the
|
||
// sums are unchanged while the trig work halves.
|
||
sumSin2, sumCos2 := 0.0, 0.0
|
||
for i := range n {
|
||
arg := omega * dt[i]
|
||
s, c := math.Sincos(2 * arg)
|
||
sumSin2 += s
|
||
sumCos2 += c
|
||
}
|
||
tau := 0.5 * math.Atan2(sumSin2, sumCos2) / omega
|
||
sumCos, sumSin, sumCosSq, sumSinSq := 0.0, 0.0, 0.0, 0.0
|
||
for i := range n {
|
||
arg := omega * (dt[i] - tau)
|
||
c, s := math.Sincos(arg)
|
||
sumCos += x[i] * c
|
||
sumSin += x[i] * s
|
||
sumCosSq += c * c
|
||
sumSinSq += s * s
|
||
}
|
||
if sumSinSq == 0 || sumCosSq == 0 {
|
||
unresolvable(f)
|
||
return
|
||
}
|
||
power := (sumCos*sumCos)/sumCosSq + (sumSin*sumSin)/sumSinSq
|
||
powerRow[f] = power / (2 * variance)
|
||
}
|
||
if n >= lombParallelMinN {
|
||
// The frequencies split across workers: disjoint output slots,
|
||
// per-frequency normalisations computed inside the worker that
|
||
// owns the frequency.
|
||
engine.Parallel(nFreq, func(fs, fe int) {
|
||
for f := fs; f < fe; f++ {
|
||
fitAt(f)
|
||
}
|
||
})
|
||
} else {
|
||
for f := range nFreq {
|
||
fitAt(f)
|
||
}
|
||
}
|
||
if bad >= 0 {
|
||
freq := minFreq
|
||
if nFreq > 1 {
|
||
freq = minFreq + (maxFreq-minFreq)*float64(bad)/float64(nFreq-1)
|
||
}
|
||
return nil, nil, base.Errf("%s: the time base cannot resolve the frequency %g", name, freq)
|
||
}
|
||
return freqsArr, powerArr, nil
|
||
}
|
||
|
||
// allEqual reports whether every slice entry matches the first.
|
||
func allEqual(v []float64) bool {
|
||
for _, x := range v[1:] {
|
||
if x != v[0] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|