Files
tensor/integrate/filon_accuracy_test.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

331 lines
11 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"
"math/big"
"slices"
"testing"
)
// IntegrateFilon against an external exact reference: the antiderivative
//
// ∫ p(x)·e^{ikx} dx = e^{ikx}·Σ_{j≥0} (−1)^j p^{(j)}(x)/(ik)^{j+1},
//
// summed in math/big at a working size far past the float64 grid, with
// π from Machin's formula and the endpoint phases reduced mod 2π before
// the Taylor run. The reference holds for every frequency tried here,
// so a phase defect of the scheme itself shows against it.
const filonRefPrec = 512
func fb(x float64) *big.Float {
return new(big.Float).SetPrec(filonRefPrec).SetFloat64(x)
}
func fbInt(n int64) *big.Float {
return new(big.Float).SetPrec(filonRefPrec).SetInt64(n)
}
func fbPi() *big.Float {
// π = 16·atan(1/5) − 4·atan(1/239).
atan := func(t *big.Float) *big.Float {
power := new(big.Float).SetPrec(filonRefPrec).Set(t)
sum := fb(0)
for k := int64(1); ; k += 2 {
term := new(big.Float).SetPrec(filonRefPrec).Quo(power, fbInt(k))
if (k/2)%2 == 1 {
term.Neg(term)
}
sum.Add(sum, term)
power.Mul(power, t)
power.Mul(power, t)
if term.MantExp(nil) < -int(filonRefPrec)-10 {
break
}
}
return sum
}
// 1/5 must reach atan as the exact quotient: the float64 literal
// 0.2 carries a 1e-17 argument error that Machin's formula
// amplifies sixteenfold into π itself.
fifth := new(big.Float).SetPrec(filonRefPrec).Quo(fb(1), fbInt(5))
two39 := new(big.Float).SetPrec(filonRefPrec).Quo(fb(1), fbInt(239))
sixteen := new(big.Float).SetPrec(filonRefPrec).Mul(fbInt(16), atan(fifth))
four := new(big.Float).SetPrec(filonRefPrec).Mul(fbInt(4), atan(two39))
return sixteen.Sub(sixteen, four)
}
var (
filonTwoPi = new(big.Float).SetPrec(filonRefPrec).Mul(fb(2), fbPi())
filonPi = fbPi()
)
// filonBigSinCos returns sin(x), cos(x) for the exact big.Float argument,
// kept in extended precision: the endpoint products below multiply them
// by antiderivative terms far larger than the integral itself, so a
// float64 detour here would show up in the reference's own answer.
func filonBigSinCos(x *big.Float) (s, c *big.Float) {
n := new(big.Float).SetPrec(filonRefPrec).Quo(x, filonTwoPi)
ni, _ := n.Int(nil)
r := new(big.Float).SetPrec(filonRefPrec).Mul(new(big.Float).SetInt(ni), filonTwoPi)
r.Sub(x, r)
// The remainder sits within (−2π, 2π); one step puts it in (−π, π].
halfPi := new(big.Float).SetPrec(filonRefPrec).Quo(filonPi, fb(2))
if r.Cmp(halfPi) > 0 {
r.Sub(r, filonTwoPi)
} else if r.Cmp(new(big.Float).SetPrec(filonRefPrec).Neg(halfPi)) < 0 {
r.Add(r, filonTwoPi)
}
// Taylor runs about the reduced argument; the zero remainder is the
// exact answer both series converge to.
if r.Sign() == 0 {
return fb(0), fb(1)
}
r2 := new(big.Float).SetPrec(filonRefPrec).Mul(r, r)
ts, tc := new(big.Float).SetPrec(filonRefPrec).Set(r), fb(1)
sumS, sumC := new(big.Float).SetPrec(filonRefPrec).Set(r), fb(1)
for j := int64(1); ; j++ {
ts.Mul(ts, r2)
ts.Quo(ts, fbInt((2*j)*(2*j+1)))
ts.Neg(ts)
sumS.Add(sumS, ts)
tc.Mul(tc, r2)
tc.Quo(tc, fbInt((2*j-1)*(2*j)))
tc.Neg(tc)
sumC.Add(sumC, tc)
if ts.Sign() == 0 || ts.MantExp(nil) < -int(filonRefPrec)-10 {
break
}
}
return sumS, sumC
}
// poly is a real polynomial, coefficients ascending.
type poly []float64
func (p poly) evalBig(x *big.Float) *big.Float {
acc := fb(0)
for _, v := range slices.Backward(p) {
acc.Mul(acc, x)
acc.Add(acc, fb(v))
}
return acc
}
// formalDeriv differentiates the coefficient list.
func (p poly) formalDeriv() poly {
if len(p) <= 1 {
return poly{0}
}
d := make(poly, len(p)-1)
for i := 1; i < len(p); i++ {
d[i-1] = float64(i) * p[i]
}
return d
}
// filonPolyRef evaluates ∫ₐ^b p(x)·cos(kx) dx and the sine part against
// the antiderivative above, in extended precision.
func filonPolyRef(p poly, a, b, k float64) (c, s float64) {
endpoint := func(x float64) (re, im *big.Float) {
// Q(x) = Σ (−1)^j p^{(j)}(x)/(ik)^{j+1}, split by the cycle of
// i^{−(j+1)}: −i, −1, i, 1.
qre, qim := fb(0), fb(0)
sign := fb(1)
kpow := fb(1) // k^(j+1), built by repeated multiplication
px := fb(x)
pp := p
value := pp.evalBig(px)
for j := range p {
kpow.Mul(kpow, fb(k))
scale := new(big.Float).SetPrec(filonRefPrec).Quo(sign, kpow)
switch (j + 1) % 4 {
case 1: // −i
qim.Sub(qim, scale.Mul(scale, value))
case 2: // −1
qre.Sub(qre, scale.Mul(scale, value))
case 3: // i
qim.Add(qim, scale.Mul(scale, value))
default: // 1
qre.Add(qre, scale.Mul(scale, value))
}
sign.Neg(sign)
// p^{(j+1)} for the next term.
pp = pp.formalDeriv()
value = pp.evalBig(px)
}
sb, cb := filonBigSinCos(fb(k * x))
ere := new(big.Float).SetPrec(filonRefPrec).Mul(cb, qre)
ere.Sub(ere, new(big.Float).SetPrec(filonRefPrec).Mul(sb, qim))
eim := new(big.Float).SetPrec(filonRefPrec).Mul(sb, qre)
eim.Add(eim, new(big.Float).SetPrec(filonRefPrec).Mul(cb, qim))
return ere, eim
}
reB, imB := endpoint(b)
reA, imA := endpoint(a)
cv, _ := reB.Sub(reB, reA).Float64()
sv, _ := imB.Sub(imB, imA).Float64()
return cv, sv
}
func filonRelErr(got, want float64) float64 {
if want == 0 {
return math.Abs(got)
}
return math.Abs(got-want) / math.Abs(want)
}
// ampScale bounds |p| over [a, b] by the sum of the coefficients'
// magnitudes lifted to the interval's ends, the scale the absolute
// tolerance is measured against: at high frequency the integral itself
// can cancel to nearly nothing and a relative metric would chase noise.
func ampScale(p poly, a, b float64) float64 {
mag := math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
s := 0.0
pow := 1.0
for _, c := range p {
s += math.Abs(c) * pow
pow *= mag
}
return math.Abs(b-a) * s
}
// TestIntegrateFilonPolynomialMoments holds the scheme against the exact
// antiderivative across amplitudes of every degree the default node
// count interpolates exactly, intervals with both orientations' worth of
// geometry, and frequencies from the settled to the far oscillatory.
func TestIntegrateFilonPolynomialMoments(t *testing.T) {
amplitudes := map[string]poly{
"1": {1},
"2 − 3x + x²": {2, -3, 1},
"1 + 0.5x³": {1, 0, 0, 0.5},
"x − 2x⁴ + 4x⁷": {0, 1, 0, 0, -2, 0, 0, 4},
// Degree 15, the exact interpolation degree of the default 16
// nodes.
"degree 15": {1, -1, 0.5, 0.25, -0.125, 0.0625, 0.5, -0.5, 0.25, -0.25, 0.125, -0.125, 0.0625, -0.0625, 0.5, -0.25},
}
worst := 0.0
for _, k := range []float64{1, 10, 100, 1000, 10000} {
for label, p := range amplitudes {
for _, c := range [][2]float64{{0, 1}, {2, 7}, {-1, 3}} {
a, b := c[0], c[1]
gotC, gotS, err := IntegrateFilon(plainPoly(p), a, b, k, FilonOptions{})
if err != nil {
t.Fatalf("%s on [%g,%g] k=%g: %v", label, a, b, k, err)
}
wantC, wantS := filonPolyRef(p, a, b, k)
scale := ampScale(p, a, b)
dC := math.Abs(gotC-wantC) / scale
dS := math.Abs(gotS-wantS) / scale
worst = math.Max(worst, math.Max(dC, dS))
if dC > 1e-13 {
t.Fatalf("%s on [%g,%g] k=%g: cos part %.17g against exact %.17g (scaled %.3g)",
label, a, b, k, gotC, wantC, dC)
}
if dS > 1e-13 {
t.Fatalf("%s on [%g,%g] k=%g: sin part %.17g against exact %.17g (scaled %.3g)",
label, a, b, k, gotS, wantS, dS)
}
}
}
}
t.Logf("worst scaled moment error across the sweep: %.3e", worst)
}
// plainPoly wraps a polynomial as the amplitude IntegrateFilon samples.
func plainPoly(p poly) func(float64) (float64, error) {
return func(x float64) (float64, error) {
acc := 0.0
for _, v := range slices.Backward(p) {
acc = acc*x + v
}
return acc, nil
}
}
// TestIntegrateFilonLargeFrequency pins the phase at frequencies where a
// float64 antiderivative would stop being a reference: the extended
// precision one keeps counting. The bound is absolute against the
// amplitude scale, because the integral itself shrinks like 1/k.
func TestIntegrateFilonLargeFrequency(t *testing.T) {
amplitudes := map[string]poly{
"2 − 3x + x²": {2, -3, 1},
"1 + 0.5x³": {1, 0, 0, 0.5},
"x − 2x⁴ + 4x⁷": {0, 1, 0, 0, -2, 0, 0, 4},
}
worst := 0.0
for _, k := range []float64{1e5, 1e6} {
for label, p := range amplitudes {
gotC, gotS, err := IntegrateFilon(plainPoly(p), 0, 1, k, FilonOptions{})
if err != nil {
t.Fatalf("%s at k=%g: %v", label, k, err)
}
wantC, wantS := filonPolyRef(p, 0, 1, k)
scale := ampScale(p, 0, 1)
dC, dS := math.Abs(gotC-wantC)/scale, math.Abs(gotS-wantS)/scale
t.Logf("k=%g %s: cos %.3e, sin %.3e (scaled absolute)", k, label, dC, dS)
worst = math.Max(worst, math.Max(dC, dS))
}
}
if worst > 1e-11 {
t.Fatalf("the worst large-frequency scaled error %.3e is past the phase budget", worst)
}
}
// TestIntegrateFilonBeatsPlainQuad measures the scheme's reason to exist:
// at equal evaluation budgets the plain adaptive rule must resolve the
// carrier while Filon tracks the amplitude, and the gap has to be worth
// the second entry point.
func TestIntegrateFilonBeatsPlainQuad(t *testing.T) {
p := poly{0, 1, 0, 0, -2, 0, 0, 4}
for _, k := range []float64{1000, 10000} {
wantC, _ := filonPolyRef(p, 0, 1, k)
// Filon's budget: automatic panels times the default node count.
panels := int(math.Ceil(k / (2 * 16)))
budget := panels * 16
// The plain rule pays 31 evaluations per subinterval (a 21-point
// rule and a 10-point rule on every leaf).
leaves := budget / 31
counts := 0
amp := func(x float64) (float64, error) {
counts++
v, err := plainPoly(p)(x)
if err != nil {
return 0, err
}
return v * math.Cos(k*x), nil
}
got, _, err := IntegrateFunction(amp, 0, 1, QuadratureOptions{MaxIntervals: leaves})
quadErr := math.Inf(1)
if err != nil {
t.Logf("k=%g: the plain rule failed within %d leaves (%d evaluations): %v", k, leaves, counts, err)
} else {
quadErr = filonRelErr(got, wantC)
t.Logf("k=%g: plain quad %.3e (%d evaluations), Filon on the same budget below", k, quadErr, counts)
}
fCounts := 0
f := func(x float64) (float64, error) {
fCounts++
return plainPoly(p)(x)
}
gotC, _, err := IntegrateFilon(f, 0, 1, k, FilonOptions{})
if err != nil {
t.Fatal(err)
}
fErr := filonRelErr(gotC, wantC)
t.Logf("k=%g: Filon %.3e from %d evaluations", k, fErr, fCounts)
if fCounts > budget {
t.Fatalf("Filon spent %d evaluations past its own budget %d", fCounts, budget)
}
if fErr >= quadErr {
t.Fatalf("k=%g: Filon's error %.3e fails to beat the plain rule's %.3e on the same budget", k, fErr, quadErr)
}
if fErr*100 > quadErr {
t.Fatalf("k=%g: Filon's error %.3e is within two orders of the plain rule's %.3e", k, fErr, quadErr)
}
}
}