Files
tensor/integrate/filon.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

235 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package integrate
import (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/base"
)
// Oscillatory quadrature: the integral of a smooth amplitude against a
// sine or cosine of a high frequency, the shape every spectral
// reduction produces and one a plain adaptive rule pays for double: it
// must resolve the carrier, not the amplitude, so the evaluation count
// grows with the frequency and the per-panel rules start aliasing.
//
// The scheme is Filon-type. The interval splits into equal panels, the
// amplitude f is interpolated on each panel by a polynomial through
// Gauss-Legendre nodes, and the product of that polynomial with the
// oscillatory kernel is carried out exactly through per-panel weights.
// The error therefore tracks the smoothness of f alone and falls like
// the panel width to the interpolation order, no matter how large the
// frequency grows, while the plain adaptive rule must spend roughly
// twenty evaluations per carrier wavelength to see it at all.
// FilonOptions tunes IntegrateFilon. Nodes ≤ 0 means 16, the
// polynomial degree of the amplitude interpolant per panel is Nodes−1.
// Panels ≤ 0 means automatic: the count that keeps each panel at most
// about Nodes half-wavelengths of the carrier, the range where the
// moment construction below is exact to the rounding floor.
type FilonOptions struct {
Panels int
Nodes int
}
// filonAlphaCap bounds the forced-panel moment phase: a panel may
// carry at most this many half-wavelengths of the carrier before the
// auxiliary rule that builds the weights would have to grow without
// bound. The automatic panel count never reaches it.
const filonAlphaCap = 4096.0
// IntegrateFilon returns the two definite integrals
//
// cosIntegral = ∫ f(x)·cos(kx) dx, sinIntegral = ∫ f(x)·sin(kx) dx
//
// over [a, b], the real and imaginary parts of ∫ f(x)·e^{ikx} dx. A
// reversed interval integrates in the negative direction and k = 0
// degenerates to the plain integral of f with a zero sine part. The
// construction is exact whenever f is a polynomial of degree below
// Nodes, so on smooth amplitudes the answer sits at the rounding floor
// even for frequencies whose carrier a sampled rule cannot see.
//
// Errors: NaN or infinite bounds, an infinite frequency, a NaN
// frequency, Nodes outside [2, 32], a forced Panels whose panels would
// carry more than filonAlphaCap half-wavelengths of the carrier, a span
// that overflows the float64 range, a frequency whose span product
// leaves no representable panel count, and an f that fails or returns
// a non-finite value.
func IntegrateFilon(f func(x float64) (float64, error), a, b, k float64, opts FilonOptions) (float64, float64, error) {
if opts.Nodes <= 0 {
opts.Nodes = 16
}
if opts.Nodes < 2 || opts.Nodes > 32 {
return 0, 0, base.Errf("IntegrateFilon: Nodes must be between 2 and 32, got %d", opts.Nodes)
}
if math.IsNaN(a) || math.IsNaN(b) || math.IsNaN(k) {
return 0, 0, base.Errf("IntegrateFilon: bounds and frequency must not be NaN")
}
if math.IsInf(a, 0) || math.IsInf(b, 0) {
return 0, 0, base.Errf("IntegrateFilon: bounds must be finite, got [%g, %g]", a, b)
}
if math.IsInf(k, 0) {
return 0, 0, base.Errf("IntegrateFilon: the frequency must be finite, got %g", k)
}
sign := 1.0
if b < a {
a, b = b, a
sign = -1
}
if a == b {
return 0, 0, nil
}
// Two finite bounds can still sit so far apart that their span
// overflows: the panel width would be infinite and the carrier's
// phase at the panel centre 0·Inf or k·Inf, a quiet NaN pair.
if span := b - a; math.IsInf(span, 0) {
return 0, 0, base.Errf("IntegrateFilon: the span from %g to %g overflows, leaving no representable panel width", a, b)
}
if opts.Panels > 0 {
if alpha := math.Abs(k) * (b - a) / (2 * float64(opts.Panels)); alpha > filonAlphaCap {
return 0, 0, base.Errf("IntegrateFilon: %d panels leave %g half-wavelengths of the carrier per panel, above the %g the weights can be built within; raise Panels or leave them automatic",
opts.Panels, alpha, filonAlphaCap)
}
}
panels := opts.Panels
if panels <= 0 {
panels = 1
if k != 0 {
// A panel of h carries |k|h/2 half-wavelengths; the cap at
// Nodes keeps the moment construction in its exact range
// and the interpolation error far under the floor. The
// estimate can also leave the int range while still
// finite, and the conversion of such a ceiling is
// implementation-dependent garbage: on saturation it asks
// for an unending loop, elsewhere it wraps negative and
// the empty loop reports a quiet zero. Refuse anything
// the platform's int cannot represent.
est := math.Abs(k) * (b - a) / (2 * float64(opts.Nodes))
if est >= math.MaxInt {
return 0, 0, base.Errf("IntegrateFilon: the frequency %g over the span %g leaves no representable panel count", k, b-a)
}
panels = int(math.Ceil(est))
}
}
h := (b - a) / float64(panels)
alpha := k * h / 2
nodes, _, err := GaussLegendreNodes(opts.Nodes)
if err != nil {
return 0, 0, err
}
wCos, wSin, err := filonWeights(nodes, alpha)
if err != nil {
return 0, 0, err
}
// One sweep over the panels: sample the amplitude at the nodes,
// contract it with the weights into the panel's two amplitudes C
// and S, and rotate them into place by the carrier's phase at the
// panel centre.
var cosTotal, sinTotal float64
for p := range panels {
centre := a + (float64(p)+0.5)*h
half := h / 2
var c, s float64
for i := range nodes {
fx, ferr := f(centre + half*nodes[i])
if ferr != nil {
return 0, 0, base.Errf("IntegrateFilon: %w", ferr)
}
if math.IsNaN(fx) || math.IsInf(fx, 0) {
return 0, 0, base.Errf("IntegrateFilon: the amplitude returned the non-finite value %g on panel %d", fx, p)
}
c += wCos[i] * fx
s += wSin[i] * fx
}
phase := k * centre
cosP, sinP := math.Cos(phase), math.Sin(phase)
cosTotal += cosP*c - sinP*s
sinTotal += sinP*c + cosP*s
}
return sign * cosTotal * h / 2, sign * sinTotal * h / 2, nil
}
// filonWeights returns, for the Gauss-Legendre nodes of [-1, 1], the
// Filon weights: the exact integrals of each Lagrange basis polynomial
// against cos(αy) and sin(αy). With these the panel integral of the
// interpolating polynomial times the carrier is one dot product per
// part, and every trace of the carrier's phase lives in the weights,
// built once, never per panel.
//
// The basis moments come from a composite 32-point Gauss-Legendre rule
// whose subinterval count follows α, so the auxiliary rule resolves
// the carrier the amplitude is multiplied by; the automatic panel cap
// keeps that cost at one subinterval and the rule at the rounding
// floor.
func filonWeights(nodes []float64, alpha float64) (wCos, wSin []float64, err error) {
m := len(nodes)
// Barycentric weights of the interpolation nodes.
bw := make([]float64, m)
for i := range m {
p := 1.0
for j := range m {
if j != i {
p *= nodes[i] - nodes[j]
}
}
if p == 0 {
return nil, nil, base.Errf("IntegrateFilon: repeated interpolation nodes")
}
bw[i] = 1 / p
}
// The auxiliary rule: 32-point Gauss-Legendre over enough equal
// subintervals of [-1, 1] that each carries at most 16
// half-wavelengths of e^{iαy}.
subs := 1
if a := math.Abs(alpha); a > 16 {
subs = int(math.Ceil(a / 16))
}
auxNodes, auxWeights, err := GaussLegendreNodes(32)
if err != nil {
return nil, nil, err
}
wCos = make([]float64, m)
wSin = make([]float64, m)
span := 2.0 / float64(subs)
for s := range subs {
lo := -1 + float64(s)*span
for t := range auxNodes {
// The aux nodes live on [-1, 1]; map them into the
// subinterval [lo, lo+span] with the half-span the affine
// change of variables carries.
y := lo + span*0.5*(auxNodes[t]+1)
// Barycentric evaluation of every basis polynomial at y,
// with the exact hit a node coincidence asks for.
den := 0.0
exact := -1
for i := range m {
d := y - nodes[i]
if d == 0 {
exact = i
break
}
den += bw[i] / d
}
cy, sy := math.Cos(alpha*y), math.Sin(alpha*y)
w := span * 0.5 * auxWeights[t]
for i := range m {
var li float64
if exact >= 0 {
if i == exact {
li = 1
}
} else {
li = bw[i] / (y - nodes[i]) / den
}
wCos[i] += w * li * cy
wSin[i] += w * li * sy
}
}
}
return wCos, wSin, nil
}