331 lines
11 KiB
Go
331 lines
11 KiB
Go
// 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)
|
||
}
|
||
}
|
||
}
|