// Copyright (c) 2026 Petr Balvín (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) } } }