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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|