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
|
|||
|
|
}
|