feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command deconv recovers a sharp image from a blurred, noisy
// observation by gradient descent through the Fourier transform: the
// convolution runs as a spectral product, the loss differentiates
// through FFT2 and IFFT2, and Tikhonov regularisation keeps the noise
// from winning. Astronomical PSF deconvolution in a page of gradient,
// the workload the spectral autograd exists for.
//
// Usage: go run ./examples/deconv
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
)
func main() {
const (
n = 32
sigma = 6.0
)
// The truth: one off-centre Gaussian source.
truth := make([]float64, n*n)
for r := range n {
for c := range n {
dx := float64(c) - 20.0
dy := float64(r) - 12.0
truth[r*n+c] = math.Exp(-(dx*dx + dy*dy) / (2 * 2.5 * 2.5))
}
}
// The PSF: a wider Gaussian, the blur to undo.
psf := make([]float64, n*n)
for r := range n {
for c := range n {
dx := float64(c) - n/2
dy := float64(r) - n/2
psf[r*n+c] = math.Exp(-(dx*dx + dy*dy) / (2 * sigma * sigma))
}
}
obsArr, err := tensor.FromFloats(truth, n, n)
if err != nil {
log.Fatal(err)
}
psfArr, err := tensor.FromFloats(psf, n, n)
if err != nil {
log.Fatal(err)
}
// The blur runs as a spectral product; the constants ride the same
// graph nodes without requiring grad.
kT := tensor.FromArray(psfArr, false)
kF, err := kT.FFT2()
if err != nil {
log.Fatal(err)
}
obsT := tensor.FromArray(obsArr, false)
spec, err := obsT.FFT2()
if err != nil {
log.Fatal(err)
}
prod, err := spec.Mul(kF)
if err != nil {
log.Fatal(err)
}
blurred, err := prod.IFFT2()
if err != nil {
log.Fatal(err)
}
noise := tensor.NewGenerator(42)
noisy := make([]float64, n*n)
for i := range noisy {
noisy[i] = real(blurred.Data().ComplexAt(i)) + 0.01*noise.NormalUnit()
}
obsC, err := tensor.FromFloats(noisy, n, n)
if err != nil {
log.Fatal(err)
}
obsComplex, err := tensor.Astype(obsC, tensor.Complex)
if err != nil {
log.Fatal(err)
}
obsTt := tensor.FromArray(obsComplex, false)
// The spectral loss is a stiff quadratic (curvature ~ |K|² per
// frequency), exactly the landscape plain gradient descent crawls
// on and Newton-CG eats: the CG solve rides the Hessian-vector
// product through the same FFT chain, two backward passes per
// iteration, no dense Hessian ever formed.
xArr, err := tensor.FromFloats(make([]float64, n*n), n, n)
if err != nil {
log.Fatal(err)
}
objective := func(x *tensor.Tensor) (*tensor.Tensor, error) {
spec, err := x.FFT2()
if err != nil {
return nil, err
}
prod, err := spec.Mul(kF)
if err != nil {
return nil, err
}
model, err := prod.IFFT2()
if err != nil {
return nil, err
}
resid, err := model.Sub(obsTt)
if err != nil {
return nil, err
}
dataTerm, err := resid.Abs2()
if err != nil {
return nil, err
}
regTerm, err := x.Abs2()
if err != nil {
return nil, err
}
reg, err := regTerm.Scale(1e-3)
if err != nil {
return nil, err
}
both, err := dataTerm.Add(reg)
if err != nil {
return nil, err
}
return both.Sum()
}
solution, loss, err := tensor.MinimiseNewtonCG(objective, xArr,
tensor.NewtonCGOptions{Tolerance: 1e-8, MaxIterations: 80})
if err != nil {
log.Fatal(err)
}
xArr = solution
fmt.Printf("newton-cg converged, loss = %.6e\n", loss)
peakOf := func(a *tensor.Array) (int, int, float64) {
best := math.Inf(-1)
br, bc := 0, 0
for r := range n {
for c := range n {
if v := a.FloatAt(r*n + c); v > best {
best, br, bc = v, r, c
}
}
}
return br, bc, best
}
tr, tc, tv := peakOf(obsArr)
br, bc, bv := peakOf(obsC)
rr, rc, rv := peakOf(xArr)
fmt.Printf("truth peak at (%d, %d), height %.3f\n", tr, tc, tv)
fmt.Printf("blurred observation peak at (%d, %d), height %.3f\n", br, bc, bv)
fmt.Printf("recovered peak at (%d, %d), height %.3f (final loss %.3e)\n", rr, rc, rv, loss)
if rr != tr || rc != tc {
log.Fatal("the recovery did not localise the source")
}
if math.Abs(rv-tv) > 0.25*tv {
log.Fatal("the recovery did not restore the source height")
}
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command fft demonstrates the Fourier transform: it synthesises a
// signal from two sinusoids, transforms it, and prints the dominant
// frequency components.
//
// Usage: go run ./examples/fft
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
sig "sourcedock.dev/petrbalvin/tensor/signal"
)
func main() {
// Two sinusoids: 5 Hz and 13 Hz, sampled at 100 Hz for 2 seconds.
const (
fs = 100.0
seconds = 2.0
)
n := int(fs * seconds)
vals := make([]float64, n)
for i := range n {
t := float64(i) / fs
vals[i] = math.Sin(2*math.Pi*5*t) + 0.5*math.Sin(2*math.Pi*13*t)
}
signal, err := tensor.FromFloats(vals, n)
if err != nil {
log.Fatal(err)
}
spec, err := sig.FFT(signal)
if err != nil {
log.Fatal(err)
}
freqs := sig.FFTFreq(n, 1/fs)
// Find the two strongest bins (excluding DC).
var peaks [2]struct {
freq float64
mag float64
}
for i := 1; i < n/2; i++ {
m, _ := tensor.ComplexAt(spec, i)
mag := math.Hypot(real(m), imag(m))
for p := range peaks {
if mag > peaks[p].mag {
peaks[p].freq, _ = tensor.FloatAt(freqs, i)
peaks[p].mag = mag
break
}
}
}
for _, p := range peaks {
fmt.Printf("peak at %.1f Hz (magnitude %.1f)\n", p.freq, p.mag)
}
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command fits builds a synthetic star field, saves it as a FITS
// primary image, reads it back and recovers the brightest star's
// position by a centre-of-mass centroid, the first step of any
// aperture photometry pipeline.
//
// Usage: go run ./examples/fits
package main
import (
"fmt"
"log"
"math"
"os"
"path/filepath"
"sourcedock.dev/petrbalvin/tensor"
)
func main() {
const (
size = 128
sigma = 2.0 // pixels, the seeing disk
)
// Three stars of different brightness on a flat sky background.
type star struct {
x, y, flux float64
}
stars := []star{
{40.5, 60.5, 900},
{80.5, 30.5, 300},
{95.5, 95.5, 120},
}
field := make([]float64, size*size)
for i := range size {
for j := range size {
v := 100.0 // sky
for _, s := range stars {
d2 := (float64(i)-s.y)*(float64(i)-s.y) + (float64(j)-s.x)*(float64(j)-s.x)
v += s.flux * math.Exp(-d2/(2*sigma*sigma))
}
field[i*size+j] = v
}
}
img, err := tensor.FromFloats(field, size, size)
if err != nil {
log.Fatal(err)
}
path := filepath.Join(os.TempDir(), "tensor-example-stars.fits")
defer os.Remove(path)
headers := map[string]string{
"OBJECT": "synthetic field",
"EXPTIME": "30",
"FILTER": "V",
}
if err := tensor.SaveFITS(path, img, headers); err != nil {
log.Fatal(err)
}
back, hdr, err := tensor.LoadFITS(path)
if err != nil {
log.Fatal(err)
}
fmt.Printf("wrote and read %s\n", filepath.Base(path))
for _, k := range []string{"OBJECT", "EXPTIME", "FILTER"} {
fmt.Printf(" %s = %s\n", k, hdr[k])
}
if back.Shape()[0] != size || back.Shape()[1] != size {
log.Fatalf("round trip changed the shape: %v", back.Shape())
}
// Locate the brightest pixel, then centroid a 9x9 window around
// it with the sky level subtracted.
best, bestVal := 0, -1.0
for i := range size * size {
if v := back.FloatAt(i); v > bestVal {
best, bestVal = i, v
}
}
by, bx := best/size, best%size
sum, sx, sy := 0.0, 0.0, 0.0
for i := by - 4; i <= by+4; i++ {
for j := bx - 4; j <= bx+4; j++ {
w := back.FloatAt(i*size+j) - 100
if w < 0 {
w = 0
}
sum += w
sx += w * float64(j)
sy += w * float64(i)
}
}
fmt.Printf("\nbrightest star: peak at (x=%d, y=%d), %.0f counts\n", bx, by, bestVal)
fmt.Printf("centroid of the 9x9 window: (x=%.2f, y=%.2f)\n", sx/sum, sy/sum)
fmt.Println("true position: (x=40.50, y=60.50)")
}
+242
View File
@@ -0,0 +1,242 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command helmholtz solves the discretised Helmholtz equation, the
// backbone of frequency-domain electromagnetics, in both of its
// solver shapes. The time-harmonic wave equation
//
// -∇²ψ - k²ψ = f
//
// on a 2-D grid gives a complex symmetric (non-Hermitian) sparse
// system, which the BiCGSTAB solver handles. Adding a small imaginary
// part to k², the way a lossy medium does, makes the operator
// Hermitian positive-definite and the conjugate gradient solver
// applies. Both solutions are verified against the dense solve, and
// the Hermitian operator's resonant modes come from the sparse
// eigensolver.
//
// Usage: go run ./examples/helmholtz
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
)
const grid = 24 // interior points per side
// laplacianCOO assembles the 5-point discrete -∇² on the interior of
// a grid*grid domain with Dirichlet walls, one entry per stencil
// point. The value at (i,j) is k2 times the identity there.
func helmholtzCOO(k2 complex128) (*tensor.SparseCOO, int) {
n := grid * grid
var idx []int64
var val []complex128
at := func(i, j int) int { return i*grid + j }
for i := range grid {
for j := range grid {
p := at(i, j)
// 4/h² on the diagonal with h = 1 in grid units, minus k².
idx = append(idx, int64(p), int64(p))
val = append(val, 4-k2)
if i > 0 {
idx = append(idx, int64(p), int64(at(i-1, j)))
val = append(val, -1)
}
if i < grid-1 {
idx = append(idx, int64(p), int64(at(i+1, j)))
val = append(val, -1)
}
if j > 0 {
idx = append(idx, int64(p), int64(at(i, j-1)))
val = append(val, -1)
}
if j < grid-1 {
idx = append(idx, int64(p), int64(at(i, j+1)))
val = append(val, -1)
}
}
}
indices, err := tensor.FromInts(idx, len(val), 2)
if err != nil {
log.Fatal(err)
}
values, err := tensor.FromComplexes(val, len(val))
if err != nil {
log.Fatal(err)
}
coo, err := tensor.NewSparseCOO(indices, values, []int{n, n})
if err != nil {
log.Fatal(err)
}
return coo, n
}
// landauCOO assembles the Hamiltonian of a charged particle on the
// same grid threading a perpendicular magnetic field, the Peierls
// substitution: every hop carries the phase the vector potential
// gives it, forward and conjugate backward, so the operator stays
// Hermitian. A positive mass term m² makes it positive-definite.
func landauCOO(m2, flux float64) *tensor.SparseCOO {
n := grid * grid
var idx []int64
var val []complex128
at := func(i, j int) int { return i*grid + j }
phase := func(i int) float64 { return 2 * math.Pi * flux * float64(i) }
for i := range grid {
for j := range grid {
p := at(i, j)
idx = append(idx, int64(p), int64(p))
val = append(val, complex(4+m2, 0))
if i > 0 {
idx = append(idx, int64(p), int64(at(i-1, j)))
val = append(val, -1+0i)
}
if i < grid-1 {
idx = append(idx, int64(p), int64(at(i+1, j)))
val = append(val, -1+0i)
}
if j > 0 {
idx = append(idx, int64(p), int64(at(i, j-1)))
val = append(val, -complex(math.Cos(phase(i)), math.Sin(phase(i))))
}
if j < grid-1 {
idx = append(idx, int64(p), int64(at(i, j+1)))
val = append(val, -complex(math.Cos(phase(i)), -math.Sin(phase(i))))
}
}
}
indices, err := tensor.FromInts(idx, len(val), 2)
if err != nil {
log.Fatal(err)
}
values, err := tensor.FromComplexes(val, len(val))
if err != nil {
log.Fatal(err)
}
coo, err := tensor.NewSparseCOO(indices, values, []int{n, n})
if err != nil {
log.Fatal(err)
}
return coo
}
// source is a point drive at the grid centre, the field of a small
// antenna.
func source(n int) *tensor.Array {
rhs := make([]complex128, n)
rhs[(grid/2)*grid+grid/2] = 1 + 0i
b, err := tensor.FromComplexes(rhs, n)
if err != nil {
log.Fatal(err)
}
return b
}
// residual returns ||b - A·x||₂ by reassembling A densely, the ground
// truth the sparse solver is checked against.
func residual(a *tensor.SparseCOO, x, b *tensor.Array, n int) float64 {
dense, err := a.Dense()
if err != nil {
log.Fatal(err)
}
ax, err := tensor.MatMul2D(dense, x)
if err != nil {
log.Fatal(err)
}
worst := 0.0
for i := range n {
av, err := tensor.ComplexAt(ax, i)
if err != nil {
log.Fatal(err)
}
bv, err := tensor.ComplexAt(b, i)
if err != nil {
log.Fatal(err)
}
if d := math.Hypot(real(av-bv), imag(av-bv)); d > worst {
worst = d
}
}
return worst
}
func main() {
const n = grid * grid
// A propagating mode: k = 2.5 in grid units, safely away from the
// discrete resonances at k² = 2-2cos(p*pi/(grid+1)).
k := 2.5 + 0i
a, _ := helmholtzCOO(k * k)
b := source(n)
x, err := tensor.SpSolveComplexBiCGSTAB(a, b, 1e-12, 2000)
if err != nil {
log.Fatal(err)
}
fmt.Println("lossless Helmholtz system, -nabla^2 - k^2, k = 2.5")
fmt.Printf(" unknowns: %d, stored nonzeros: %d\n", n, len(a.Values.RawComplexes()))
fmt.Printf(" BiCGSTAB residual ||b - A x|| = %.3g\n", residual(a, x, b, n))
// The genuinely Hermitian complex problem: a charged particle on
// the same grid in a perpendicular magnetic field. The Peierls
// phases make every hop complex, the forward and backward hop
// conjugates of each other, so the operator is Hermitian, and the
// mass term keeps it positive-definite: exactly the shape the
// conjugate gradient solver wants.
h := landauCOO(1.0, 1.0/25)
xh, err := tensor.SpSolveComplexCG(h, b, 1e-12, 2000)
if err != nil {
log.Fatal(err)
}
fmt.Println("\nLandau Hamiltonian on the grid, mass^2 = 1, flux 1/25 (Hermitian positive-definite)")
fmt.Printf(" CG residual ||b - A x|| = %.3g\n", residual(h, xh, b, n))
// Resonant modes of the lossless cavity: the largest eigenvalues
// of the discrete negative Laplacian are the highest-Q modes.
lap, _ := helmholtzCOO(0)
vals, vecs, err := tensor.SpEigenComplex(lap, 3, tensor.NewGenerator(4))
if err != nil {
log.Fatal(err)
}
fmt.Println("\ncavity modes: largest eigenvalues of -nabla^2")
for j := range 3 {
lam, err := tensor.FloatAt(vals, j)
if err != nil {
log.Fatal(err)
}
// Verify each Ritz pair: ||A v - lambda v|| must be small.
vcol, err := tensor.Slice(vecs, 1, j, j+1)
if err != nil {
log.Fatal(err)
}
av, err := tensor.MatMul2D(mustDense(lap), vcol)
if err != nil {
log.Fatal(err)
}
worst := 0.0
for i := range n {
a1, _ := tensor.ComplexAt(av, i)
v1, _ := tensor.ComplexAt(vcol, i)
if d := math.Hypot(real(a1-complex(lam, 0)*v1), imag(a1-complex(lam, 0)*v1)); d > worst {
worst = d
}
}
fmt.Printf(" lambda = %8.4f, residual %.3g\n", lam, worst)
}
// The analytic eigenvalues of the grid Laplacian are
// 4-2cos(p*pi/(grid+1))-2cos(q*pi/(grid+1)); the largest is p = q =
// grid, where both cosines approach -1 and the value nears 8.
fmt.Println(" (analytic maximum: 4 - 4cos(24pi/25) = 7.9685)")
}
func mustDense(a *tensor.SparseCOO) *tensor.Array {
d, err := a.Dense()
if err != nil {
log.Fatal(err)
}
return d
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command hmc samples a correlated two-dimensional Gaussian by
// Hamiltonian Monte Carlo on the differentiable log density, and
// checks the chain against the distribution's known moments: mean
// zero, unit variances, correlation 0.9. The sampler never sees an
// analytic gradient, only the autograd's.
//
// Usage: go run ./examples/hmc
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
)
func main() {
const rho = 0.9
// Precision matrix of the correlated Gaussian (up to scale, which
// the unnormalised density does not need).
prec, err := tensor.FromFloats([]float64{1, -rho, -rho, 1}, 2, 2)
if err != nil {
log.Fatal(err)
}
aT := tensor.FromArray(prec, false)
logDensity := func(q *tensor.Tensor) (*tensor.Tensor, error) {
// log p(q) ∝ −½ qᵀAq: one matrix-vector product on the graph,
// then the inner product with q itself.
r, err := aT.MatMul(q)
if err != nil {
return nil, err
}
quad, err := r.Mul(q)
if err != nil {
return nil, err
}
s, err := quad.Sum()
if err != nil {
return nil, err
}
return s.Scale(-0.5)
}
q0, err := tensor.FromFloats([]float64{0.5, -0.5}, 2)
if err != nil {
log.Fatal(err)
}
samples, err := tensor.SampleHMC(logDensity, q0, tensor.HMCOptions{
Step: 0.15,
Steps: 12,
BurnIn: 500,
Thin: 5,
Samples: 20000,
Seed: 7,
})
if err != nil {
log.Fatal(err)
}
n := samples.Shape()[0]
mean0, mean1 := 0.0, 0.0
for i := range n {
mean0 += samples.FloatAt(i * 2)
mean1 += samples.FloatAt(i*2 + 1)
}
mean0 /= float64(n)
mean1 /= float64(n)
var0, var1, cov := 0.0, 0.0, 0.0
for i := range n {
d0 := samples.FloatAt(i*2) - mean0
d1 := samples.FloatAt(i*2+1) - mean1
var0 += d0 * d0
var1 += d1 * d1
cov += d0 * d1
}
var0 /= float64(n - 1)
var1 /= float64(n - 1)
cov /= float64(n - 1)
corr := cov / math.Sqrt(var0*var1)
fmt.Printf("samples %d\n", n)
fmt.Printf("mean (%.3f, %.3f), want (0, 0)\n", mean0, mean1)
// The covariance is A⁻¹: unit-over-(1−ρ²) variances around the
// correlation rho.
wantVar := 1 / (1 - rho*rho)
fmt.Printf("variance (%.3f, %.3f), want (%.3f, %.3f)\n", var0, var1, wantVar, wantVar)
fmt.Printf("correlation %.3f, want %.3f\n", corr, rho)
if math.Abs(mean0) > 0.05 || math.Abs(mean1) > 0.05 {
log.Fatal("the sample mean drifted")
}
if math.Abs(corr-rho) > 0.02 {
log.Fatalf("the sample correlation %.3f missed %.3f", corr, rho)
}
if math.Abs(var0-wantVar) > 0.15*wantVar || math.Abs(var1-wantVar) > 0.15*wantVar {
log.Fatalf("the sample variances (%.3f, %.3f) missed %.3f", var0, var1, wantVar)
}
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command netcdf writes a synthetic climate field to a NetCDF classic
// file, reads it back, and computes the zonal statistics a climate
// workflow starts from. The point is the round trip: dimensions,
// attributes and values survive the file exactly.
//
// Usage: go run ./examples/netcdf
package main
import (
"fmt"
"log"
"math"
"os"
"path/filepath"
"sourcedock.dev/petrbalvin/tensor"
)
func main() {
const (
nLat = 36 // 5-degree grid
nLon = 72
)
// A warm anomaly centred on 45 N, 15 E over a zonal gradient, the
// shape of a heat island in a coarse climate model.
field := make([]float64, nLat*nLon)
for i := range nLat {
lat := -90 + 5*(float64(i)+0.5)
for j := range nLon {
lon := -180 + 5*(float64(j)+0.5)
base := 30*math.Cos(lat*math.Pi/180) - 5
dLat := (lat - 45) / 15
dLon := math.Sin((lon - 15) * math.Pi / 180)
anomaly := 8 * math.Exp(-(dLat*dLat + dLon*dLon))
field[i*nLon+j] = base + anomaly
}
}
temp, err := tensor.FromFloats(field, nLat, nLon)
if err != nil {
log.Fatal(err)
}
path := filepath.Join(os.TempDir(), "tensor-example-climate.nc")
defer os.Remove(path)
dims := []tensor.NetCDFDim{
{Name: "lat", Length: nLat},
{Name: "lon", Length: nLon},
}
vars := []tensor.NetCDFVar{{
Name: "temperature",
Dims: []string{"lat", "lon"},
Values: temp,
Attrs: map[string]string{
"units": "degC",
"long_name": "synthetic air temperature",
"anomaly_lon": "15",
},
}}
attrs := map[string]string{
"title": "tensor NetCDF example",
"source": "synthetic Gaussian anomaly",
}
if err := tensor.SaveNetCDF(path, dims, vars, attrs); err != nil {
log.Fatal(err)
}
fmt.Printf("wrote %s: %d x %d grid, %d variables\n\n", path, nLat, nLon, len(vars))
gotDims, gotVars, gotAttrs, err := tensor.LoadNetCDF(path)
if err != nil {
log.Fatal(err)
}
fmt.Printf("dimensions: ")
for _, d := range gotDims {
fmt.Printf("%s(%d) ", d.Name, d.Length)
}
fmt.Printf("\nglobal attributes: %v\n\n", gotAttrs)
back := gotVars[0].Values
if back.Shape()[0] != nLat || back.Shape()[1] != nLon {
log.Fatalf("round trip changed the shape: %v", back.Shape())
}
maxDiff := 0.0
for i := range nLat * nLon {
d := math.Abs(back.FloatAt(i) - field[i])
if d > maxDiff {
maxDiff = d
}
}
fmt.Printf("largest round-trip difference: %g (exact for float64)\n\n", maxDiff)
// Zonal means: the latitude profile of the field, the first thing
// a climate diagnostic asks for.
fmt.Println("latitude zonal mean temperature")
for i := 0; i < nLat; i += 6 {
row, err := tensor.Slice(back, 0, i, i+1)
if err != nil {
log.Fatal(err)
}
mean, err := tensor.Mean(row)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%6.1f° %10.3f degC\n", -90+5*(float64(i)+0.5), mean)
}
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command ode-fit fits the parameters of a damped oscillator to
// endpoint measurements by the adjoint method: AdjointODE hands back
// dL/dθ for every parameter at the cost of one extra solve, and plain
// gradient descent walks the parameters to the truth. This is the
// data-assimilation loop no other Go library expresses.
//
// Usage: go run ./examples/ode-fit
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
)
// coreDynamics is y' = (v, −c·v − ω²·y) over plain arrays, the shape
// IntegrateODE wants; it generates the data.
func coreDynamics(c, omega float64) func(float64, *tensor.Array) (*tensor.Array, error) {
return func(t float64, y *tensor.Array) (*tensor.Array, error) {
return tensor.FromFloats([]float64{
y.FloatAt(1),
-c*y.FloatAt(1) - omega*omega*y.FloatAt(0),
}, 2)
}
}
// graphDynamics is the same equation with (c, ω) as differentiable
// leaves, the shape AdjointODE wants.
func graphDynamics(c, omega *tensor.Tensor) func(float64, *tensor.Tensor) (*tensor.Tensor, error) {
return func(t float64, y *tensor.Tensor) (*tensor.Tensor, error) {
yv, err := y.Slice(0, 0, 1)
if err != nil {
return nil, err
}
vv, err := y.Slice(0, 1, 2)
if err != nil {
return nil, err
}
w2, err := omega.Pow(2)
if err != nil {
return nil, err
}
damping, err := c.Mul(vv)
if err != nil {
return nil, err
}
restoring, err := w2.Mul(yv)
if err != nil {
return nil, err
}
acc, err := damping.Add(restoring)
if err != nil {
return nil, err
}
neg, err := acc.Scale(-1)
if err != nil {
return nil, err
}
return vv.Concat(neg, 0)
}
}
func main() {
const (
trueC = 0.8
trueOmega = 3.0
)
y0, err := tensor.FromFloats([]float64{1, 0}, 2)
if err != nil {
log.Fatal(err)
}
// Measurements of y(t) at three times from the true system.
times := []float64{0.4, 0.8, 1.2, 1.6, 2.0, 2.4}
data := make([]float64, len(times))
trueF := coreDynamics(trueC, trueOmega)
for i, T := range times {
end, err := tensor.IntegrateODE(trueF, 0, T, y0, tensor.ODEOptions{})
if err != nil {
log.Fatal(err)
}
data[i] = end.FloatAt(0)
}
// The fit: gradient descent on L = Σ (y(Tᵢ; θ) − dataᵢ)² with the
// gradient from one adjoint pass per data point.
cVal, wVal := 0.3, 1.8
const rate = 0.04
for iter := 1; iter <= 400; iter++ {
gc, gw := 0.0, 0.0
loss := 0.0
forwardF := coreDynamics(cVal, wVal)
cArr, _ := tensor.FromFloats([]float64{cVal}, 1)
wArr, _ := tensor.FromFloats([]float64{wVal}, 1)
c := tensor.FromArray(cArr, true)
omega := tensor.FromArray(wArr, true)
adjF := graphDynamics(c, omega)
for i, T := range times {
end, err := tensor.IntegrateODE(forwardF, 0, T, y0, tensor.ODEOptions{})
if err != nil {
log.Fatal(err)
}
res := end.FloatAt(0) - data[i]
loss += res * res
// dL/dy(T) = 2·res on the position component only; the
// velocity component carries no loss.
seed, err := tensor.FromFloats([]float64{2 * res, 0}, 2)
if err != nil {
log.Fatal(err)
}
_, paramGrads, err := tensor.AdjointODE(adjF, []*tensor.Tensor{c, omega},
0, T, y0, seed, tensor.ODEOptions{})
if err != nil {
log.Fatal(err)
}
gc += paramGrads[0].FloatAt(0)
gw += paramGrads[1].FloatAt(0)
}
if iter%100 == 0 {
fmt.Printf("iter %3d c = %.4f omega = %.4f loss = %.3e\n", iter, cVal, wVal, loss)
}
cVal -= rate * gc
wVal -= rate * gw
}
fmt.Printf("fitted c = %.4f (true %.4f), omega = %.4f (true %.4f)\n",
cVal, trueC, wVal, trueOmega)
if math.Abs(cVal-trueC) > 0.05 || math.Abs(wVal-trueOmega) > 0.05 {
log.Fatal("the fit did not converge to the truth")
}
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command pde solves the two canonical one-dimensional partial
// differential equations: the heat equation by Crank-Nicolson and the
// wave equation by velocity Verlet. Both start from the same Gaussian
// pulse on a rod, and the diagnostics show diffusion flattening the
// pulse while the wave keeps its shape and travels.
//
// Usage: go run ./examples/pde
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
)
func main() {
const (
n = 201
dx = 0.01 // metres, a 2 m rod
k = 1e-3 // thermal diffusivity, m^2/s
c = 0.5 // wave speed, m/s
)
pulse := make([]float64, n)
for i := range n {
x := float64(i)*dx - 1.0
pulse[i] = math.Exp(-(x * x) / 0.01)
}
u0, err := tensor.FromFloats(pulse, n)
if err != nil {
log.Fatal(err)
}
fmt.Println("heat equation (Crank-Nicolson, ends held at zero):")
fmt.Println(" time peak mean (interior)")
for _, tf := range []float64{0, 0.5, 2, 5} {
var row *tensor.Array
if tf == 0 {
row = u0 // the initial pulse itself
} else {
u, err := tensor.IntegrateHeat1D(u0, k, dx, tf, tf/400, 5, 0, 0)
if err != nil {
log.Fatal(err)
}
// The last sample row holds the final state. The ends are
// Dirichlet zeros, so heat drains out of the rod once the
// pulse reaches them; by the last time printed it has not,
// which is why the interior mean barely moves.
row, err = tensor.Slice(u, 0, 4, 5)
if err != nil {
log.Fatal(err)
}
}
mx, err := tensor.Max(row)
if err != nil {
log.Fatal(err)
}
mean, err := tensor.Mean(row)
if err != nil {
log.Fatal(err)
}
fmt.Printf(" %.2f s %7.4f %7.4f\n", tf, mx.Float(), mean)
}
fmt.Println()
fmt.Println("wave equation (velocity Verlet, fixed ends):")
fmt.Println(" time peak position of the peak")
rest, err := tensor.Zeros(tensor.Float, n)
if err != nil {
log.Fatal(err)
}
for _, tf := range []float64{0, 0.5, 1.0, 1.5} {
var row *tensor.Array
if tf == 0 {
row = u0
} else {
u, err := tensor.IntegrateWave1D(u0, rest, c, dx, tf, tf/600, 5)
if err != nil {
log.Fatal(err)
}
row, err = tensor.Slice(u, 0, 4, 5)
if err != nil {
log.Fatal(err)
}
}
mx, err := tensor.Max(row)
if err != nil {
log.Fatal(err)
}
peak, peakVal := 0, -1.0
for i := range n {
if v := row.FloatAt(i); v > peakVal {
peak, peakVal = i, v
}
}
fmt.Printf(" %.2f s %6.4f x = %.2f m\n",
tf, mx.Float(), float64(peak)*dx-1.0)
}
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command pendulum computes the exact period of a simple pendulum at
// large amplitude through the complete elliptic integral of the first
// kind, and shows how far the small-angle formula drifts once the
// release angle stops being small. The period is
//
// T = 4·sqrt(L/g)·K(sin²(θ₀/2)),
//
// where K is EllipticK with the m = k² parameter convention.
//
// Usage: go run ./examples/pendulum
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
)
// kComplete evaluates EllipticK at a single parameter.
func kComplete(m float64) float64 {
arr, err := tensor.FromFloats([]float64{m}, 1)
if err != nil {
log.Fatal(err)
}
k, err := tensor.EllipticK(arr)
if err != nil {
log.Fatal(err)
}
v, _ := tensor.FloatAt(k, 0)
return v
}
func main() {
const (
length = 1.0 // metres
grav = 9.80665
)
small := 2 * math.Pi * math.Sqrt(length/grav)
fmt.Println("release angle exact period small-angle period drift")
for _, deg := range []float64{5, 15, 30, 45, 60, 90, 120, 170} {
theta := deg * math.Pi / 180
m := math.Sin(theta/2) * math.Sin(theta/2)
period := 4 * math.Sqrt(length/grav) * kComplete(m)
drift := (period/small - 1) * 100
fmt.Printf("%10.0f° %12.6f s %14.6f s %+6.2f %%\n",
deg, period, small, drift)
}
// The inverse problem: which release angle doubles the small-angle
// period? Bisection on the angle, the period being monotone in it.
target := 2 * small
lo, hi := 0.0, math.Pi
angle := 0.0
for range 80 {
mid := (lo + hi) / 2
m := math.Sin(mid/2) * math.Sin(mid/2)
if 4*math.Sqrt(length/grav)*kComplete(m) < target {
lo = mid
} else {
hi = mid
}
angle = mid
}
fmt.Printf("\na release angle of %.2f° doubles the period (%.4f s)\n",
angle*180/math.Pi, target)
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command qmc compares quasi-random integration against plain Monte
// Carlo on the same two-dimensional integral. Sobol points are a
// digital lattice: every block of 2^m points stratifies each
// coordinate exactly, so the error decays far faster than the
// 1/sqrt(n) of random sampling, and Halton sits in between.
//
// Usage: go run ./examples/qmc
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
)
// f is the integrand: smooth, with its curvature spread over the unit
// square. The exact value is (1-e^-1)*sqrt(pi)/2*erf(1), the product
// of the x integral and the error function integral over y.
func f(x, y float64) float64 { return math.Exp(-x - y*y) }
const exact = 0.4720828881800443 // (1-e^-1)*sqrt(pi)/2*erf(1)
// estimate integrates f over [0,1]^2 from an (n,2) point set.
func estimate(pts *tensor.Array, n int) float64 {
s := 0.0
for i := range n {
x, err := tensor.FloatAt(pts, i, 0)
if err != nil {
log.Fatal(err)
}
y, err := tensor.FloatAt(pts, i, 1)
if err != nil {
log.Fatal(err)
}
s += f(x, y)
}
return s / float64(n)
}
func main() {
fmt.Printf("integral of exp(-x - y^2) over the unit square, exact %.10f\n\n", exact)
fmt.Println(" points Monte Carlo Halton Sobol")
for _, n := range []int{64, 256, 1024, 4096, 16384} {
// Monte Carlo: uniform draws from the seeded generator.
g := tensor.NewGenerator(int64(n))
mc, err := tensor.Floats(g, 2*n)
if err != nil {
log.Fatal(err)
}
mcPts, err := tensor.Reshape(mc, n, 2)
if err != nil {
log.Fatal(err)
}
// Halton and Sobol from the first point on; Sobol skips its
// origin point exactly as Halton does.
hal, err := tensor.HaltonPoints(n, 2, 0)
if err != nil {
log.Fatal(err)
}
sob, err := tensor.SobolPoints(n, 2, 0)
if err != nil {
log.Fatal(err)
}
eMC := math.Abs(estimate(mcPts, n) - exact)
eHal := math.Abs(estimate(hal, n) - exact)
eSob := math.Abs(estimate(sob, n) - exact)
fmt.Printf(" %6d %.3e %.3e %.3e\n", n, eMC, eHal, eSob)
}
fmt.Println()
fmt.Println("the quasi-random errors collapse with n; the Monte Carlo")
fmt.Println("error only shrinks as 1/sqrt(n) and stays noisy on top")
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command regression fits a linear trend to a noisy time series,
// reports the full inference table (coefficients, standard errors,
// t-statistics, p-values, R²) and checks that the residuals are
// actually uncorrelated, which is the assumption the t-tests rest on.
//
// Usage: go run ./examples/regression
package main
import (
"fmt"
"log"
"sourcedock.dev/petrbalvin/tensor"
"sourcedock.dev/petrbalvin/tensor/signal"
"sourcedock.dev/petrbalvin/tensor/stats"
)
func main() {
const n = 400
// A trend of 0.05 per sample on a level of 2, with AR(1) noise
// (rho = 0.3), drawn from the reproducible generator.
g := tensor.NewGenerator(7)
white, err := tensor.Normal(g, n, 0, 1)
if err != nil {
log.Fatal(err)
}
y := make([]float64, n)
ar := 0.0
for i := range n {
w, _ := tensor.FloatAt(white, i)
ar = 0.3*ar + w
y[i] = 2 + 0.05*float64(i) + 0.4*ar
}
yArr, err := tensor.FromFloats(y, n)
if err != nil {
log.Fatal(err)
}
// The design carries its own intercept column, the convention of
// the classic linear model.
design := make([]float64, 2*n)
for i := range n {
design[2*i] = 1
design[2*i+1] = float64(i)
}
xArr, err := tensor.FromFloats(design, n, 2)
if err != nil {
log.Fatal(err)
}
fit, err := stats.LinearRegression(xArr, yArr)
if err != nil {
log.Fatal(err)
}
fmt.Println("ordinary least squares fit, y = intercept + slope * t")
fmt.Println("term estimate std error t-stat p-value")
fmt.Printf("intercept %9.4f %9.4f %7.3f %.3g\n",
fit.Coefficients[0], fit.StandardErrors[0], fit.TStatistics[0], fit.PValues[0])
fmt.Printf("slope %9.4f %9.4f %7.3f %.3g\n",
fit.Coefficients[1], fit.StandardErrors[1], fit.TStatistics[1], fit.PValues[1])
fmt.Printf("\nR² = %.4f, adjusted R² = %.4f, residual variance = %.4f\n",
fit.RSquared, fit.AdjustedRSquared, fit.ResidualVariance)
fmt.Println("(the generating values were intercept 2, slope 0.05)")
// The t-tests assume uncorrelated residuals. Pull them out and
// check the autocorrelation at the first few lags; with rho = 0.3
// in the noise, lag 1 must show clear correlation, which is the
// honest caveat for the standard errors above.
resid := make([]float64, n)
for i := range n {
pred := fit.Coefficients[0] + fit.Coefficients[1]*float64(i)
resid[i] = y[i] - pred
}
rArr, err := tensor.FromFloats(resid, n)
if err != nil {
log.Fatal(err)
}
ac, err := signal.Autocorrelate(rArr, 5)
if err != nil {
log.Fatal(err)
}
// The transform returns lags 0..5; lag 0 is 1 by definition, the
// AR(1) memory shows from lag 1 on.
fmt.Print("\nresidual autocorrelation:")
for lag := 1; lag <= 5; lag++ {
v, _ := tensor.FloatAt(ac, lag)
fmt.Printf(" lag %d: %+.3f", lag, v)
}
fmt.Println()
// A two-sample test on the first and last halves: with a trend of
// 0.05 over 200 samples the means must differ decisively.
first, err := tensor.Slice(yArr, 0, 0, n/2)
if err != nil {
log.Fatal(err)
}
last, err := tensor.Slice(yArr, 0, n/2, n)
if err != nil {
log.Fatal(err)
}
t, df, p, err := stats.WelchTTest(first, last)
if err != nil {
log.Fatal(err)
}
meanOf := func(a *tensor.Array) float64 {
m, err := tensor.Mean(a)
if err != nil {
log.Fatal(err)
}
return m
}
fmt.Printf("\nWelch t-test, first half vs second half:\n")
fmt.Printf(" means %.3f vs %.3f, t = %.2f, df = %.1f, p = %.3g\n",
meanOf(first), meanOf(last), t, df, p)
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command spectral estimates the frequency content of a signal two
// ways: Welch's averaged periodogram on evenly sampled data, and the
// Lomb-Scargle periodogram on the same signal observed at irregular
// times, where an FFT cannot run at all. Both must find the two
// buried sinusoids.
//
// Usage: go run ./examples/spectral
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
)
func main() {
const (
fs = 100.0
seconds = 4.0
f1 = 5.0
f2 = 13.0
)
n := int(fs * seconds)
gen := tensor.NewGenerator(11)
// The signal: two sinusoids plus noise.
t := make([]float64, n)
x := make([]float64, n)
for i := range n {
t[i] = float64(i) / fs
x[i] = math.Sin(2*math.Pi*f1*t[i]) + 0.6*math.Sin(2*math.Pi*f2*t[i]) + 0.4*gen.NormalUnit()
}
xArr, err := tensor.FromFloats(x, n)
if err != nil {
log.Fatal(err)
}
// Welch: average periodograms over Hann-windowed segments, the
// variance-suppressed estimate an FFT alone cannot give.
freqs, psd, err := tensor.WelchPSD(xArr, fs, 256, 128, "hann")
if err != nil {
log.Fatal(err)
}
wf1, wf2 := twoPeaks(peakFrequencies(freqs, psd, 2))
fmt.Printf("welch peaks at %.2f Hz and %.2f Hz (want %.1f and %.1f)\n", wf1, wf2, f1, f2)
// Lomb-Scargle: keep every second sample at jittered times, the
// uneven regime the DFT does not define. The mean rate stays at
// 50 Hz, comfortably above both sources' Nyquist needs, while the
// jitter is what makes the ordinary FFT inapplicable.
times := make([]float64, 0, n/2)
values := make([]float64, 0, n/2)
for i := 0; i < n; i += 2 {
jitter := 0.6 * gen.Unit() / fs
times = append(times, t[i]+jitter)
values = append(values, x[i])
}
tArr, err := tensor.FromFloats(times, len(times))
if err != nil {
log.Fatal(err)
}
vArr, err := tensor.FromFloats(values, len(values))
if err != nil {
log.Fatal(err)
}
lsFreqs, power, err := tensor.LombScargle(tArr, vArr, 1.0, 30.0, 3000)
if err != nil {
log.Fatal(err)
}
lf1, lf2 := twoPeaks(peakFrequencies(lsFreqs, power, 2))
fmt.Printf("lomb-scargle peaks at %.2f Hz and %.2f Hz (want %.1f and %.1f)\n", lf1, lf2, f1, f2)
for _, got := range []float64{wf1, wf2, lf1, lf2} {
if math.Abs(got-f1) > 0.3 && math.Abs(got-f2) > 0.3 {
log.Fatalf("a peak landed at %.2f Hz, away from both sources", got)
}
}
}
// peakFrequencies returns the abscissae of the count largest local
// maxima of a periodogram, descending by height and kept at least
// 1.5 Hz apart so a sidelobe of a tall peak cannot shadow a real one.
func peakFrequencies(freqs, power *tensor.Array, count int) []float64 {
n := freqs.Len()
// Three-point boxcar smooth: the periodogram's noise is white, a
// genuine peak is not.
smooth := make([]float64, n)
for i := range n {
lo := max(i-1, 0)
hi := min(i+1, n-1)
s := 0.0
for j := lo; j <= hi; j++ {
s += power.FloatAt(j)
}
smooth[i] = s / float64(hi-lo+1)
}
type peak struct {
f, h float64
}
var peaks []peak
for i := 1; i < n-1; i++ {
if smooth[i] > smooth[i-1] && smooth[i] >= smooth[i+1] {
peaks = append(peaks, peak{freqs.FloatAt(i), smooth[i]})
}
}
for i := 1; i < len(peaks); i++ {
for j := i; j > 0 && peaks[j-1].h < peaks[j].h; j-- {
peaks[j-1], peaks[j] = peaks[j], peaks[j-1]
}
}
out := make([]float64, 0, count)
for _, p := range peaks {
if len(out) == count {
break
}
far := true
for _, f := range out {
if math.Abs(p.f-f) < 1.5 {
far = false
break
}
}
if far {
out = append(out, p.f)
}
}
return out
}
// twoPeaks unpacks the two-element result of peakFrequencies.
func twoPeaks(fs []float64) (float64, float64) { return fs[0], fs[1] }
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Command wavelets demonstrates the discrete wavelet transform on a
// denoising task and the continuous transform on a time-frequency
// task: a clean signal is buried in noise, the detail coefficients are
// soft-thresholded and the signal rebuilt, then a two-tone signal with
// an abrupt frequency change is mapped by the CWT so the change is
// visible in time, not just in frequency.
//
// Usage: go run ./examples/wavelets
package main
import (
"fmt"
"log"
"math"
"sourcedock.dev/petrbalvin/tensor"
"sourcedock.dev/petrbalvin/tensor/signal"
)
func main() {
const n = 1024
// A clean decaying sinusoid, buried in noise drawn from the
// reproducible generator so the run is exactly repeatable.
g := tensor.NewGenerator(2026)
noise, err := tensor.Normal(g, n, 0, 0.25)
if err != nil {
log.Fatal(err)
}
clean := make([]float64, n)
dirty := make([]float64, n)
for i := range n {
x := float64(i) / n
clean[i] = math.Sin(2*math.Pi*3*x) * math.Exp(-3*x)
nv, _ := tensor.FloatAt(noise, i)
dirty[i] = clean[i] + nv
}
dirtyArr, err := tensor.FromFloats(dirty, n)
if err != nil {
log.Fatal(err)
}
// Decompose, soft-threshold the detail coefficients, rebuild. The
// threshold sits at twice the noise standard deviation, the level
// where a noise-only coefficient almost never survives.
const levels = 5
coef, err := signal.DWT(dirtyArr, levels)
if err != nil {
log.Fatal(err)
}
approx := n >> levels
const threshold = 2 * 0.25
raw := coef.RawFloats()
for i := approx; i < len(raw); i++ {
v := raw[i]
switch {
case v > threshold:
raw[i] = v - threshold
case v < -threshold:
raw[i] = v + threshold
default:
raw[i] = 0
}
}
denoised, err := signal.IDWT(coef, levels)
if err != nil {
log.Fatal(err)
}
mse := func(a []float64) float64 {
s := 0.0
for i := range n {
d := a[i] - clean[i]
s += d * d
}
return s / float64(n)
}
fmt.Println("mean squared error against the clean signal:")
fmt.Printf(" noisy %.6f\n", mse(dirty))
fmt.Printf(" denoised %.6f\n", mse(denoised.RawFloats()[:n]))
fmt.Println()
// The continuous transform: 512 samples of a signal whose tone
// jumps from 8 to 32 cycles over the whole run, halfway through.
// A Morlet scale a responds at omega0/(2*pi*a) cycles per sample,
// which is omega0*N/(2*pi*a) cycles per record of N = 512 samples,
// so with omega0 = 5 the two tones live near a = 51 and a = 13;
// the scalogram ridge must jump between them.
const m = 512
chirp := make([]float64, m)
for i := range m {
freq := 8.0
if i >= m/2 {
freq = 32.0
}
chirp[i] = math.Sin(2 * math.Pi * freq * float64(i) / m)
}
chirpArr, err := tensor.FromFloats(chirp, m)
if err != nil {
log.Fatal(err)
}
scales := []float64{4, 8, 13, 16, 26, 32, 51, 64}
scalogram, err := signal.CWT(chirpArr, signal.Morlet, scales, 1)
if err != nil {
log.Fatal(err)
}
fmt.Println("CWT ridge: the scale carrying the peak energy in each half")
// The wavelet of scale 64 spans about 256 samples, so the outer
// quarters of the run are edge territory; the ridge is read from
// the interior of each half only.
const margin = 128
for _, seg := range []struct {
label string
start, stop int
}{
{"first half ", margin, m/2 - margin/2},
{"second half", m/2 + margin/2, m - margin},
} {
best := 0
bestMag := -1.0
for si := range scales {
for i := seg.start; i < seg.stop; i++ {
// The scalogram is (len(scales), m), one complex row
// per scale; the ridge is the peak magnitude.
cv, err := tensor.ComplexAt(scalogram, si, i)
if err != nil {
log.Fatal(err)
}
if a := math.Hypot(real(cv), imag(cv)); a > bestMag {
best, bestMag = si, a
}
}
}
fmt.Printf(" %s: scale %.0f\n", seg.label, scales[best])
}
}