feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user