// 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" ) // Spectral solution of the Poisson equation on a rectangle with // homogeneous boundary data. The sine basis vanishes on the boundary // and diagonalises −Δ for Dirichlet data; the cosine basis has zero // slope there and does the same for Neumann. Both solves run one // separable transform pair per axis and one division per mode, and // both refuse the inputs the mathematics refuses: nonzero-mean // sources for Neumann, exactly as the periodic solve does. // poissonRect validates the shared rectangle arguments and returns // the grid shape and spacings. func poissonRect(name string, f *core.Array, lx, ly float64) (rows, cols int, hx, hy float64, err error) { if f.NDim() != 2 { return 0, 0, 0, 0, base.Errf("%s: f must be rank 2, got shape %s", name, base.ShapeText(f.Shape())) } if f.Dtype() != core.Float { return 0, 0, 0, 0, base.Errf("%s: f must be a float64 array, got %s", name, f.Dtype()) } rows, cols = f.Shape()[0], f.Shape()[1] if rows < 3 || cols < 3 { return 0, 0, 0, 0, base.Errf("%s: the grid must be at least 3×3 to hold interior points, got %d×%d", name, rows, cols) } if lx <= 0 || ly <= 0 { return 0, 0, 0, 0, base.Errf("%s: the side lengths must be positive, got %g and %g", name, lx, ly) } // A non-finite source would drive the compatibility tolerances NaN // and slip through their gates, so it is refused up front. A dense // float64 payload is scanned slice-wise: the elements are the ones // FloatAt returns. vals := poissonFloats(f) for _, v := range vals { if math.IsInf(v, 0) || math.IsNaN(v) { return 0, 0, 0, 0, base.Errf("%s: f holds the non-finite value %g", name, v) } } hx = lx / float64(cols-1) hy = ly / float64(rows-1) return rows, cols, hx, hy, nil } // poissonFloats streams the grid's elements in element order: the raw // payload when the array is dense, a materialised copy through FloatAt // when it is a strided view, whose payload order is not the element // order. Treat an aliased result as read-only. func poissonFloats(f *core.Array) []float64 { if !f.Strided() { return f.RawFloats() } out := make([]float64, f.Len()) for i := range out { out[i] = f.FloatAt(i) } return out } // poissonTrapzSum returns the trapezoidal-weighted sum of f over the // whole grid: the boundary rows and columns count half, the weight the // mirrored stencil's own left null vector carries. It is the constant // mode of the cosine transform pair, the measure the Neumann solve is // compatible in. func poissonTrapzSum(f *core.Array) float64 { rows, cols := f.Shape()[0], f.Shape()[1] vals := poissonFloats(f) sum := 0.0 for r := range rows { wr := 1.0 if r == 0 || r == rows-1 { wr = 0.5 } for c := range cols { wc := 1.0 if c == 0 || c == cols-1 { wc = 0.5 } sum += wr * wc * vals[r*cols+c] } } return sum } // SolvePoissonDirichlet solves −Δu = f on the rectangle with u held // at zero on the whole boundary. f is sampled on the (rows × cols) // grid including the boundary rows and columns, whose entries play no // role in the solve; u comes back on the same grid with a zero // boundary. Row r of f samples y = r·hy, column c samples x = c·hx, // with hx = lx/(cols−1) and hy = ly/(rows−1). The sine transform // pair along each axis diagonalises the interior second-difference // operator, so the error is the stencil's O(h²), decaying with the // grid. Non-float dtypes, grids under 3×3 and non-positive side // lengths are errors. func SolvePoissonDirichlet(f *core.Array, lx, ly float64) (*core.Array, error) { const name = "SolvePoissonDirichlet" rows, cols, hx, hy, err := poissonRect(name, f, lx, ly) if err != nil { return nil, err } interiorR, interiorC := rows-2, cols-2 // The interior right-hand side, transformed along both axes with // the orthonormal DST-I, whose basis vanishes on the boundary. spectrum, err := transformBlock(f, 1, 1, interiorR, interiorC, true) if err != nil { return nil, base.Errf("%s: %w", name, err) } // Divide by the stencil eigenvalues: two per axis from the cosine // identity on the second difference. Each eigenvalue depends on one // index alone, so the per-axis values are built once instead of once // per mode; the expressions are the ones the inner loop evaluated. eigX := make([]float64, interiorC+1) for kx := 1; kx <= interiorC; kx++ { eigX[kx] = 2 / (hx * hx) * (1 - math.Cos(math.Pi*float64(kx)/float64(interiorC+1))) } eigY := make([]float64, interiorR+1) for ky := 1; ky <= interiorR; ky++ { eigY[ky] = 2 / (hy * hy) * (1 - math.Cos(math.Pi*float64(ky)/float64(interiorR+1))) } for ky := 1; ky <= interiorR; ky++ { ly := eigY[ky] row := (ky - 1) * interiorC for kx := 1; kx <= interiorC; kx++ { spectrum[row+kx-1] /= eigX[kx] + ly } } interior, err := inverseBlock(spectrum, interiorR, interiorC, true) if err != nil { return nil, base.Errf("%s: %w", name, err) } out := core.New(core.Float, rows, cols) into := out.RawFloats() for r := range interiorR { for c := range interiorC { into[(r+1)*cols+(c+1)] = interior[r*interiorC+c] } } return out, nil } // SolvePoissonNeumann solves −Δu = f with zero normal derivative on // the whole boundary: u comes back on the full grid, boundary points // included, every sample an unknown. Conventions mirror // SolvePoissonDirichlet, with the cosine transform pair along each // axis. The stencil is the ghost-mirror one, the neighbour outside the // boundary standing in for zero slope, so a boundary row couples its // mirrored neighbour with 2/h² where an interior row uses 1/h² (the // boundary control volume is half an interior one). Its cosine modes // cos(πkx x/lx)·cos(πky y/ly), kx = 0…cols−1 and ky = 0…rows−1, carry // the eigenvalues 2/hx²·(1−cos(πkx/(cols−1))) + // 2/hy²·(1−cos(πky/(rows−1))) per axis, and the error against the // closed form is the stencil's O(h²). // // The operator annihilates the constants, so a solution exists only // for sources whose trapezoidal-weighted sum vanishes; that sum is the // operator's own compatibility condition (the trapezoidal measure is // its left null vector), not the plain mean, and a nonzero one is an // error rather than a quietly shifted problem. The free constant of u // is fixed by setting the constant mode to zero, so the result has // zero trapezoidal mean. Non-float dtypes, grids under 3×3 and // non-positive side lengths are errors. func SolvePoissonNeumann(f *core.Array, lx, ly float64) (*core.Array, error) { const name = "SolvePoissonNeumann" rows, cols, hx, hy, err := poissonRect(name, f, lx, ly) if err != nil { return nil, err } // The compatibility gate: the constant mode of the transformed // source is the trapezoidal-weighted sum, so anything above the // accumulated round-off of that sum breaks solvability. The // tolerance follows the periodic solve's constant-mode gate, and it // is relative to the source: the sum of a trapezoidal grid scales // with the sample magnitude and the element count, so an absolute // floor would let a tiny source through with a residual far above // its own size (a 1e-9 source used to be accepted with a 1.8e-5 // relative residual). sum := poissonTrapzSum(f) tol := 64 * base.EpsF * float64(rows*cols) * maxAbsF(f) if math.Abs(sum) > tol { return nil, base.Errf("%s: f has the nonzero trapezoidal mean %g, the Neumann problem has no solution", name, sum/float64((rows-1)*(cols-1))) } // The pipeline. The mirrored operator M (rows [2,−2] on the // boundary, [−1,2,−1] inside, over h²) is diagonalised by the // UNNORMALISED DCT-I, whose boundary input weight of 1/2 is the // trapezoidal measure; the library carries the orthonormal DCT-I // (boundary weight 1/sqrt2), and W = diag(1/2,1,…,1,1/2) turns one // into the other. So the forward trip scales by W^(1/2) before the // DCT-I, divides by the eigenvalues, and the return trip scales by // W^(-1/2) after the second DCT-I, which is its own inverse. weighted := core.New(core.Float, rows, cols) src := poissonFloats(f) dst := weighted.RawFloats() for r := range rows { sr := 1.0 if r == 0 || r == rows-1 { sr = math.Sqrt2 / 2 } for c := range cols { sc := 1.0 if c == 0 || c == cols-1 { sc = math.Sqrt2 / 2 } dst[r*cols+c] = src[r*cols+c] * sr * sc } } spectrum, err := transformBlock(weighted, 0, 0, rows, cols, false) if err != nil { return nil, base.Errf("%s: %w", name, err) } // The x eigenvalues depend on kx alone, so the row is built once and // read by every ky; the expression is the one the inner loop // evaluated. The y eigenvalue was already hoisted to the outer loop. eigX := make([]float64, cols) for kx := range cols { eigX[kx] = 2 / (hx * hx) * (1 - math.Cos(math.Pi*float64(kx)/float64(cols-1))) } for ky := range rows { ly := 2 / (hy * hy) * (1 - math.Cos(math.Pi*float64(ky)/float64(rows-1))) row := ky * cols for kx := range cols { i := row + kx if kx == 0 && ky == 0 { // The constant mode is the operator's null space: it // stays zero, which is what fixes the solution's free // constant, instead of dividing by its zero eigenvalue. spectrum[i] = 0 continue } spectrum[i] /= eigX[kx] + ly } } interior, err := inverseBlock(spectrum, rows, cols, false) if err != nil { return nil, base.Errf("%s: %w", name, err) } // The return trip: undo the W^(1/2) scaling, and strip the // trapezoidal mean, the measure the solve works in and the one the // operator leaves free. out := core.New(core.Float, rows, cols) into := out.RawFloats() sum, weight := 0.0, 0.0 for r := range rows { sr, wr := 1.0, 1.0 if r == 0 || r == rows-1 { sr, wr = math.Sqrt2, 0.5 } for c := range cols { sc, wc := 1.0, 1.0 if c == 0 || c == cols-1 { sc, wc = math.Sqrt2, 0.5 } v := interior[r*cols+c] * sr * sc into[r*cols+c] = v sum += wr * wc * v weight += wr * wc } } mean := sum / weight for i := range into { into[i] -= mean } return out, nil } // maxAbsF returns the largest absolute sample of a float array. func maxAbsF(f *core.Array) float64 { largest := 0.0 for _, v := range poissonFloats(f) { largest = max(largest, math.Abs(v)) } return largest } // lineTransformPlan carries the chirp-z pieces one transform length // needs for the orthonormal DST-I (sine) or DCT-I (cosine) the Poisson // solves apply along a line, plus the buffers the per-line work reuses. // // Both transforms are the real or imaginary part of a sum of complex // exponentials over the half-frequency grid the trigonometric identity // (j+1)(k+1) = ((j+1)² + (k+1)² − (j−k)²)/2 exposes: a per-sample // phase, a per-bin rotation and an even chirp kernel, so the sum over // the bins is a circular convolution of length the smallest power of // two ≥ 2n−1. The padded full-length route each line used to take ran // a convolution of roughly twice that length to reach the same bins. type lineTransformPlan struct { n int m int sine bool // pre weights the samples, post rotates the bins, kern is the // kernel's forward transform on the convolution grid. pre []complex128 post []complex128 kern []complex128 norm float64 // buf carries one line's spectrum through the convolution; the plan // is used from one goroutine and the solves are single-threaded, so // the buffer is fully rewritten on every apply. buf []complex128 } // newLineTransformPlan builds the plan for n points. The chirp angles // are reduced modulo the kernel's period in exact integer arithmetic // before the transcendental call, so no angle grows with n. func newLineTransformPlan(n int, sine bool) *lineTransformPlan { p := &lineTransformPlan{n: n, sine: sine} m := 1 for m < 2*n-1 { m <<= 1 } p.m = m p.pre = make([]complex128, n) p.post = make([]complex128, n) p.kern = make([]complex128, m) p.buf = make([]complex128, m) if sine { // DST-I: out[k] = norm·Im[ post_k · Σ_j x_j·pre_j·e^{−iθ(j−k)²/2} ] // with θ = π/(n+1), pre_j = e^{iθ(j + j²/2)}, // post_k = e^{iθ(k+1 + k²/2)}. period := 4 * (n + 1) theta := math.Pi / float64(n+1) for j := range n { t := (2*j + j*j) % period p.pre[j] = polar(1, theta*float64(t)/2) } for k := range n { t := (2*(k+1) + k*k) % period p.post[k] = polar(1, theta*float64(t)/2) } for l := range n { t := (l * l) % period w := polar(1, -theta*float64(t)/2) p.kern[l] = w if l != 0 { p.kern[m-l] = w } } p.norm = math.Sqrt(2 / float64(n+1)) } else { // DCT-I: out[k] = norm·Re[ w_k·e^{iθk²/2} · // Σ_j x_j·w_j·e^{iθj²/2}·e^{−iθ(j−k)²/2} ] with θ = π/(n−1) // and w the half weight on the endpoints. period := 4 * (n - 1) theta := math.Pi / float64(n-1) for j := range n { t := (j * j) % period w := 1.0 if j == 0 || j == n-1 { w = math.Sqrt2 / 2 } p.pre[j] = complex(w, 0) * polar(1, theta*float64(t)/2) } for k := range n { t := (k * k) % period w := 1.0 if k == 0 || k == n-1 { w = math.Sqrt2 / 2 } p.post[k] = complex(w, 0) * polar(1, theta*float64(t)/2) } for l := range n { t := (l * l) % period w := polar(1, -theta*float64(t)/2) p.kern[l] = w if l != 0 { p.kern[m-l] = w } } p.norm = math.Sqrt(2 / float64(n-1)) } // The kernel's spectrum depends only on the plan; it is the same // forward transform every line multiplies against. transform(p.kern, -1) return p } // polar returns the unit complex number of the given angle. // math.Sincos returns exactly the pair math.Cos and math.Sin produce // (verified bit-for-bit), so the value is unchanged. func polar(magnitude, angle float64) complex128 { s, c := math.Sincos(angle) return complex(magnitude*c, magnitude*s) } // apply transforms src into dst; both have length p.n and must not // alias. The convolution runs on the plan's reused spectrum buffer, // which every call overwrites completely before it is read. func (p *lineTransformPlan) apply(dst, src []float64) { n, m := p.n, p.m buf := p.buf if p.sine && n == 1 { dst[0] = src[0] return } for j := range n { buf[j] = complex(src[j], 0) * p.pre[j] } clear(buf[n:m]) transform(buf, -1) for i := range m { buf[i] *= p.kern[i] } transform(buf, +1) scale := complex(1/float64(m), 0) if p.sine { for k := range n { v := buf[k] * scale * p.post[k] dst[k] = p.norm * imag(v) } return } for k := range n { v := buf[k] * scale * p.post[k] dst[k] = p.norm * real(v) } } // transformBlock applies the named self-inverse orthonormal transform // (DST-I when sine, DCT-I when not) to every row and then every column // of the rows×cols block of f whose top-left corner sits at (row0, // col0), returning the flat spectrum. One plan per axis length serves // every line of that axis. func transformBlock(f *core.Array, row0, col0, rows, cols int, sine bool) ([]float64, error) { srcVals := poissonFloats(f) srcCols := f.Shape()[1] work := make([]float64, rows*cols) longest := max(rows, cols) line := make([]float64, longest) out := make([]float64, longest) // poissonFloats materialises a strided source in element order; a // raw payload read would keep the physical order instead. rowPlan := newLineTransformPlan(cols, sine) for r := range rows { for c := range cols { line[c] = srcVals[(r+row0)*srcCols+(c+col0)] } rowPlan.apply(out[:cols], line[:cols]) copy(work[r*cols:(r+1)*cols], out[:cols]) } colPlan := newLineTransformPlan(rows, sine) for c := range cols { for r := range rows { line[r] = work[r*cols+c] } colPlan.apply(out[:rows], line[:rows]) for r := range rows { work[r*cols+c] = out[r] } } return work, nil } // inverseBlock applies the same self-inverse transform to a flat // spectrum, the return trip of transformBlock. func inverseBlock(spectrum []float64, rows, cols int, sine bool) ([]float64, error) { arr, err := core.FromFloats(spectrum, rows, cols) if err != nil { return nil, err } return transformBlock(arr, 0, 0, rows, cols, sine) }