Files
tensor/integrate/filon_accuracy_test.go
T

331 lines
11 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// 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)
}
}
}