// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package signal import ( "math" "sourcedock.dev/petrbalvin/tensor/internal/base" "sourcedock.dev/petrbalvin/tensor/internal/core" ) // Correlation functions: the time-series staples on top of // the FFT. The autocorrelation and cross-correlation run as spectral // products (one transform pair whatever the lag count), the partial // autocorrelation follows by Durbin-Levinson recursion over the ACF, // the algorithm that defined the PACF in the first place. // Autocorrelate returns the sample autocorrelation of a rank-1 real // signal at lags 0..maxLag: the mean is removed first and every value // is normalised by the lag-zero sum (the biased estimator, the one // whose Durbin-Levinson and Bartlett-band theory is written for). // maxLag must lie in [0, n−1]; the result has maxLag+1 entries and // starts at 1, unless the signal is constant: its zero power makes // every lag 0, lag zero included. func Autocorrelate(x *core.Array, maxLag int) (*core.Array, error) { const name = "Autocorrelate" if x.NDim() != 1 { return nil, base.Errf("%s: needs a rank-1 signal, got shape %s", name, base.ShapeText(x.Shape())) } if x.Dtype() == core.Complex { return nil, base.Errf("%s: complex signals are not supported", name) } n := x.Len() if n == 0 { return nil, base.Errf("%s: an empty signal has no correlation", name) } if maxLag < 0 || maxLag >= n { return nil, base.Errf("%s: maxLag must lie in [0, %d], got %d", name, n-1, maxLag) } // Demean. The payload walk reads the same values FloatAt would // (widenFloats widens exactly), in the same order, so the mean // and every padded sample keep their bits. xF := x.RawFloats() if xF == nil || x.Strided() { xF = widenFloats(x) } // A non-finite sample would drive the normaliser NaN and publish // NaN correlations with no error, so it is refused up front (the // guard SolvePoissonPeriodic applies to its source). for i := range n { if v := xF[i]; math.IsNaN(v) || math.IsInf(v, 0) { return nil, base.Errf("%s: x holds the non-finite value %g at %d", name, v, i) } } mean := 0.0 for i := range n { mean += xF[i] } mean /= float64(n) // The correlation runs as one transform pair over the zero-padded // signal, in place on a private buffer: the values that reach the // butterflies and the arithmetic that follows are the FFT/IFFT // path's unchanged, minus the copies into and out of arrays that // nobody else ever read. padded := make([]complex128, 2*n) for i := range n { padded[i] = complex(xF[i]-mean, 0) } transform(padded, -1) // |F|²: multiply by the conjugate, then back. The zero-padded // transform makes the circular correlation linear. for i := range padded { padded[i] = padded[i] * conj128(padded[i]) } transform(padded, +1) // The inverse scaling, applied with IFFT's own operation order. m := float64(2 * n) for i := range padded { padded[i] /= complex(m, 0) } den := real(padded[0]) out := make([]float64, maxLag+1) for k := range maxLag + 1 { if den == 0 { out[k] = 0 continue } out[k] = real(padded[k]) / den } return floatsFromArrayMust(out, []int{maxLag + 1}), nil } // conj128 conjugates one complex number (the local spelling keeps the // dependency surface to the packages already imported). func conj128(z complex128) complex128 { return complex(real(z), -imag(z)) } // CrossCorrelate returns the raw cross-correlation of two equal-length // real signals at every lag: the result has 2n−1 entries, index i // carrying lag i−(n−1), value r(lag) = Σ_t x[t+lag]·y[t] over the // overlapping t. No mean removal, no normalisation: correlation as // the inner product of shifted copies, the definition convolution and // matched filtering share. func CrossCorrelate(x, y *core.Array) (*core.Array, error) { const name = "CrossCorrelate" if x.NDim() != 1 || y.NDim() != 1 { return nil, base.Errf("%s: needs two rank-1 signals, got %s and %s", name, base.ShapeText(x.Shape()), base.ShapeText(y.Shape())) } if x.Dtype() == core.Complex || y.Dtype() == core.Complex { return nil, base.Errf("%s: complex signals are not supported", name) } n := x.Len() if n == 0 || y.Len() != n { return nil, base.Errf("%s: the signals must be equal-length non-empty, got %d and %d", name, n, y.Len()) } // Zero-pad both signals through the raw payloads where possible; // the widened reads equal the FloatAt values bit for bit. xF := x.RawFloats() if xF == nil || x.Strided() { xF = widenFloats(x) } yF := y.RawFloats() if yF == nil || y.Strided() { yF = widenFloats(y) } // A non-finite sample would publish NaN correlations with no // error, so both signals are refused up front. for i := range n { if v := xF[i]; math.IsNaN(v) || math.IsInf(v, 0) { return nil, base.Errf("%s: x holds the non-finite value %g at %d", name, v, i) } if v := yF[i]; math.IsNaN(v) || math.IsInf(v, 0) { return nil, base.Errf("%s: y holds the non-finite value %g at %d", name, v, i) } } // The spectra multiply in place on private buffers: the values // reaching the transforms and the per-element products are the // FFT/mulSpectraConj/IFFT path's unchanged, minus the copies into // and out of arrays nobody else ever read. px := make([]complex128, 2*n) py := make([]complex128, 2*n) for i := range n { px[i] = complex(xF[i], 0) py[i] = complex(yF[i], 0) } transform(px, -1) transform(py, -1) // Fx·conj(Fy) transforms back to Σ_t x[t+lag]·y[t], the documented // orientation; the index mapping lands the positive lags in the // first half. // The product runs in mulSpectraConj, the array-based helper the // FFT path always used: the compiler's FMA fusion of the complex // multiply depends on the compiled shape of its surroundings, and // the helper's shape is the one whose product bits this result is // pinned to. The wrappers take the buffers without copying; the // product array is the one allocation of the old path this keeps. prod, perr := mulSpectraConj(complexFromArrayMust(px, []int{2 * n}), complexFromArrayMust(py, []int{2 * n})) if perr != nil { return nil, base.Errf("%s: %w", name, perr) } pc := prod.RawComplexes() transform(pc, +1) m := float64(2 * n) for i := range pc { pc[i] /= complex(m, 0) } // Lag 0 sits at index 0 of the transformed product, lag k at k and // negative lag −k at 2n−k; the output centres them on index n−1. out := make([]float64, 2*n-1) for k := range 2*n - 1 { lag := k - (n - 1) if lag >= 0 { out[k] = real(pc[lag]) } else { out[k] = real(pc[2*n+lag]) } } return floatsFromArrayMust(out, []int{2*n - 1}), nil } // mulSpectraConj multiplies the first spectrum by the conjugate of the // second, element-wise. Both spectra come from the FFT and stream // their complex payloads directly; the accessor loop stays as the // fallback for anything else. The function is kept byte-for-byte as // the FFT path always defined it: its compiled FMA pattern is what the // correlation results are pinned to. func mulSpectraConj(a, b *core.Array) (*core.Array, error) { as := a.RawComplexes() bs := b.RawComplexes() if as != nil && bs != nil && !a.Strided() && !b.Strided() { out := make([]complex128, len(as)) for i := range out { out[i] = as[i] * conj128(bs[i]) } return core.ComplexFromArray(out, a.Shape()...) } out := make([]complex128, a.Len()) for i := range out { out[i] = a.ComplexAt(i) * conj128(b.ComplexAt(i)) } return core.ComplexFromArray(out, a.Shape()...) } // PartialAutocorrelate returns the partial autocorrelation of a rank-1 // real signal at lags 1..maxLag, by the Durbin-Levinson recursion over // the biased autocorrelation: pacf[k] is the last coefficient of the // order-k AR fit, the "correlation with the intermediate lags // regressed out" that box-jenkins identification reads. maxLag must // lie in [1, n−2] for the recursion to stay meaningful. func PartialAutocorrelate(x *core.Array, maxLag int) (*core.Array, error) { const name = "PartialAutocorrelate" n := x.Len() if maxLag < 1 || maxLag > n-2 { return nil, base.Errf("%s: maxLag must lie in [1, %d], got %d", name, n-2, maxLag) } acf, err := Autocorrelate(x, maxLag) if err != nil { return nil, err } // r[k] = acf at lag k (1-based recursion indexing). r := make([]float64, maxLag+1) r[0] = 1 for k := range maxLag { r[k+1] = acf.FloatAt(k + 1) } phi := make([]float64, maxLag+1) // current order's coefficients phiPrev := make([]float64, maxLag+1) out := make([]float64, maxLag) for k := 1; k <= maxLag; k++ { num := r[k] den := 1.0 for j := 1; j < k; j++ { num -= phiPrev[j] * r[k-j] den -= phiPrev[j] * r[j] } if den == 0 { return nil, base.Errf("%s: the recursion broke down at lag %d", name, k) } phi[k] = num / den for j := 1; j < k; j++ { phi[j] = phiPrev[j] - phi[k]*phiPrev[k-j] } copy(phiPrev, phi) out[k-1] = phi[k] } return floatsFromArrayMust(out, []int{maxLag}), nil }