270 lines
8.0 KiB
Go
270 lines
8.0 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
|
|
// SPDX-License-Identifier: MIT
|
|||
|
|
|
|||
|
|
package integrate
|
|||
|
|
|
|||
|
|
import "sourcedock.dev/petrbalvin/tensor/internal/base"
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"math"
|
|||
|
|
"sync"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Numerical quadrature: the definite integral of a function over an
|
|||
|
|
// interval. The library integrates ODEs but until now not plain
|
|||
|
|
// integrals, which every data-reduction pipeline needs.
|
|||
|
|
//
|
|||
|
|
// The scheme is adaptive Gauss-Legendre with an honest internal
|
|||
|
|
// error estimate: every subinterval is evaluated by a 21-point rule
|
|||
|
|
// and a 10-point rule over the same interval, the gap between the two
|
|||
|
|
// is that subinterval's error, and the subinterval with the largest
|
|||
|
|
// error is bisected until the summed error meets the tolerance. The
|
|||
|
|
// 21 and 10 point nodes come from Newton's method on the Legendre
|
|||
|
|
// recurrence, so the whole construction is derived in the library
|
|||
|
|
// rather than imported as a table. Infinite bounds map onto (0, 1) by
|
|||
|
|
// rational substitution before the rule runs; an integrand over an
|
|||
|
|
// infinite interval has to decay to zero for this to converge.
|
|||
|
|
|
|||
|
|
// QuadratureOptions tunes Integrate. RelTol ≤ 0 means 1e-10, AbsTol
|
|||
|
|
// ≤ 0 means 1e-12, MaxIntervals ≤ 0 means 256.
|
|||
|
|
type QuadratureOptions struct {
|
|||
|
|
RelTol float64
|
|||
|
|
AbsTol float64
|
|||
|
|
MaxIntervals int
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// gaussLegendreEntry holds the cached nodes and weights of the
|
|||
|
|
// n-point Gauss-Legendre rule over [-1, 1].
|
|||
|
|
type gaussLegendreEntry struct {
|
|||
|
|
nodes []float64
|
|||
|
|
weights []float64
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var (
|
|||
|
|
gaussLegendreCacheMu sync.Mutex
|
|||
|
|
gaussLegendreCache = map[int]gaussLegendreEntry{}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// GaussLegendreNodes returns the nodes and weights of the n-point
|
|||
|
|
// Gauss-Legendre rule over [-1, 1], exact for polynomials up to
|
|||
|
|
// degree 2n−1. Nodes come out ascending. n must be between 1 and 128.
|
|||
|
|
// The nodes are the roots of the n-th Legendre polynomial, found by
|
|||
|
|
// Newton's method on the three-term recurrence, which reaches
|
|||
|
|
// rounding-level accuracy in a handful of iterations per node. The
|
|||
|
|
// returned slices are a shared cache: they must be treated as
|
|||
|
|
// read-only, because a write would poison every later quadrature run
|
|||
|
|
// on the same node count.
|
|||
|
|
func GaussLegendreNodes(n int) (nodes, weights []float64, err error) {
|
|||
|
|
if n < 1 || n > 128 {
|
|||
|
|
return nil, nil, base.Errf("GaussLegendreNodes: n must be between 1 and 128, got %d", n)
|
|||
|
|
}
|
|||
|
|
gaussLegendreCacheMu.Lock()
|
|||
|
|
entry, ok := gaussLegendreCache[n]
|
|||
|
|
gaussLegendreCacheMu.Unlock()
|
|||
|
|
if ok {
|
|||
|
|
return entry.nodes, entry.weights, nil
|
|||
|
|
}
|
|||
|
|
nodes = make([]float64, n)
|
|||
|
|
weights = make([]float64, n)
|
|||
|
|
for i := range n {
|
|||
|
|
// The trigonometric starting guess separates the roots well
|
|||
|
|
// enough that Newton never jumps to a neighbour.
|
|||
|
|
x := -math.Cos(math.Pi * (float64(i) + 0.75) / (float64(n) + 0.5))
|
|||
|
|
var p, dp float64
|
|||
|
|
for range 100 {
|
|||
|
|
p, dp = legendrePair(n, x)
|
|||
|
|
dx := p / dp
|
|||
|
|
x -= dx
|
|||
|
|
if math.Abs(dx) <= 1e-16*(1+math.Abs(x)) {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if math.Abs(p) > 1e-10 {
|
|||
|
|
return nil, nil, base.Errf("GaussLegendreNodes: Newton failed to converge for n=%d", n)
|
|||
|
|
}
|
|||
|
|
nodes[i] = x
|
|||
|
|
weights[i] = 2 / ((1 - x*x) * dp * dp)
|
|||
|
|
}
|
|||
|
|
entry = gaussLegendreEntry{nodes: nodes, weights: weights}
|
|||
|
|
gaussLegendreCacheMu.Lock()
|
|||
|
|
gaussLegendreCache[n] = entry
|
|||
|
|
gaussLegendreCacheMu.Unlock()
|
|||
|
|
return nodes, weights, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// legendrePair evaluates P_n(x) and P_n'(x) by the three-term
|
|||
|
|
// recurrence and its derivative identity.
|
|||
|
|
func legendrePair(n int, x float64) (p, dp float64) {
|
|||
|
|
p = 1
|
|||
|
|
if n == 0 {
|
|||
|
|
return 1, 0
|
|||
|
|
}
|
|||
|
|
pm := 0.0
|
|||
|
|
for k := 1; k <= n; k++ {
|
|||
|
|
pm, p = p, ((2*float64(k)-1)*x*p-float64(k-1)*pm)/float64(k)
|
|||
|
|
}
|
|||
|
|
dp = float64(n) * (x*p - pm) / (x*x - 1)
|
|||
|
|
return p, dp
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// IntegrateFunction returns the definite integral of f over [a, b] with an
|
|||
|
|
// estimate of the absolute error. Infinite bounds are accepted: a = −∞
|
|||
|
|
// or b = +∞ (or both) integrate over the whole tail under a rational
|
|||
|
|
// substitution, which asks the integrand to decay to zero. A reversed
|
|||
|
|
// interval (a > b) integrates in the negative direction. A tolerance
|
|||
|
|
// that cannot be met within MaxIntervals subintervals is an error,
|
|||
|
|
// never a silent approximation.
|
|||
|
|
//
|
|||
|
|
// Every sampled scheme has one blind spot: a feature entirely inside
|
|||
|
|
// the gaps of the first rule's nodes, say a peak far narrower than
|
|||
|
|
// (b−a)/n, produces small values everywhere it samples and is missed
|
|||
|
|
// with a small error estimate. Split known sharp features into their
|
|||
|
|
// own IntegrateFunction calls.
|
|||
|
|
func IntegrateFunction(f func(x float64) (float64, error), a, b float64, opts QuadratureOptions) (float64, float64, error) {
|
|||
|
|
if opts.RelTol <= 0 {
|
|||
|
|
opts.RelTol = 1e-10
|
|||
|
|
}
|
|||
|
|
if opts.AbsTol <= 0 {
|
|||
|
|
opts.AbsTol = 1e-12
|
|||
|
|
}
|
|||
|
|
if opts.MaxIntervals <= 0 {
|
|||
|
|
opts.MaxIntervals = 256
|
|||
|
|
}
|
|||
|
|
if math.IsNaN(a) || math.IsNaN(b) {
|
|||
|
|
return 0, 0, base.Errf("Integrate: bounds must not be NaN")
|
|||
|
|
}
|
|||
|
|
sign := 1.0
|
|||
|
|
if b < a {
|
|||
|
|
a, b = b, a
|
|||
|
|
sign = -1
|
|||
|
|
}
|
|||
|
|
if a == b {
|
|||
|
|
return 0, 0, nil
|
|||
|
|
}
|
|||
|
|
// Map every bound combination onto a plain finite interval with a
|
|||
|
|
// wrapped integrand carrying the substitution's Jacobian.
|
|||
|
|
lo, hi := 0.0, 1.0
|
|||
|
|
g := f
|
|||
|
|
switch {
|
|||
|
|
case a == math.Inf(-1) && b == math.Inf(1):
|
|||
|
|
g = func(t float64) (float64, error) {
|
|||
|
|
u := t - 0.5
|
|||
|
|
den := 0.25 - u*u
|
|||
|
|
if den <= 0 {
|
|||
|
|
return 0, nil
|
|||
|
|
}
|
|||
|
|
x := 2 * u / den
|
|||
|
|
fx, ferr := f(x)
|
|||
|
|
if ferr != nil {
|
|||
|
|
return 0, ferr
|
|||
|
|
}
|
|||
|
|
return fx * (0.5 + 2*u*u) / (den * den), nil
|
|||
|
|
}
|
|||
|
|
case a == math.Inf(-1):
|
|||
|
|
g = func(t float64) (float64, error) {
|
|||
|
|
x := b - t/(1-t)
|
|||
|
|
fx, ferr := f(x)
|
|||
|
|
if ferr != nil {
|
|||
|
|
return 0, ferr
|
|||
|
|
}
|
|||
|
|
return fx / (1 - t) / (1 - t), nil
|
|||
|
|
}
|
|||
|
|
case b == math.Inf(1):
|
|||
|
|
g = func(t float64) (float64, error) {
|
|||
|
|
x := a + t/(1-t)
|
|||
|
|
fx, ferr := f(x)
|
|||
|
|
if ferr != nil {
|
|||
|
|
return 0, ferr
|
|||
|
|
}
|
|||
|
|
return fx / (1 - t) / (1 - t), nil
|
|||
|
|
}
|
|||
|
|
default:
|
|||
|
|
lo, hi = a, b
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nodes10, w10, err := GaussLegendreNodes(10)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, 0, err
|
|||
|
|
}
|
|||
|
|
nodes21, w21, err := GaussLegendreNodes(21)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, 0, err
|
|||
|
|
}
|
|||
|
|
rule := func(g func(float64) (float64, error), l, r float64, xs, ws []float64) (float64, error) {
|
|||
|
|
mid, half := (l+r)/2, (r-l)/2
|
|||
|
|
total := 0.0
|
|||
|
|
for i := range xs {
|
|||
|
|
fx, ferr := g(mid + half*xs[i])
|
|||
|
|
if ferr != nil {
|
|||
|
|
return 0, ferr
|
|||
|
|
}
|
|||
|
|
total += ws[i] * fx
|
|||
|
|
}
|
|||
|
|
return total * half, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Each leaf carries its 21-point value and its error, the gap to
|
|||
|
|
// the 10-point rule over the same interval.
|
|||
|
|
type leaf struct {
|
|||
|
|
l, r, value, err float64
|
|||
|
|
}
|
|||
|
|
measure := func(l, r float64) (leaf, error) {
|
|||
|
|
v21, err := rule(g, l, r, nodes21, w21)
|
|||
|
|
if err != nil {
|
|||
|
|
return leaf{}, err
|
|||
|
|
}
|
|||
|
|
v10, err := rule(g, l, r, nodes10, w10)
|
|||
|
|
if err != nil {
|
|||
|
|
return leaf{}, err
|
|||
|
|
}
|
|||
|
|
// An infinite integrand value would poison errSum with NaN and
|
|||
|
|
// slip through the loop condition (NaN comparisons are false),
|
|||
|
|
// publishing a bogus integral, so both non-finite kinds are
|
|||
|
|
// refused exactly as IntegrateND refuses them.
|
|||
|
|
if math.IsNaN(v21) || math.IsNaN(v10) || math.IsInf(v21, 0) || math.IsInf(v10, 0) {
|
|||
|
|
return leaf{}, base.Errf("the integrand returned a non-finite value on [%g, %g]", l, r)
|
|||
|
|
}
|
|||
|
|
return leaf{l: l, r: r, value: v21, err: math.Abs(v21 - v10)}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
first, err := measure(lo, hi)
|
|||
|
|
if err != nil {
|
|||
|
|
return 0, 0, base.Errf("Integrate: %w", err)
|
|||
|
|
}
|
|||
|
|
leaves := []leaf{first}
|
|||
|
|
budget := func() (total, errSum float64) {
|
|||
|
|
for _, s := range leaves {
|
|||
|
|
total += s.value
|
|||
|
|
errSum += s.err
|
|||
|
|
}
|
|||
|
|
return total, errSum
|
|||
|
|
}
|
|||
|
|
value, errSum := budget()
|
|||
|
|
for errSum > math.Max(opts.AbsTol, opts.RelTol*math.Abs(value)) {
|
|||
|
|
if len(leaves) >= opts.MaxIntervals {
|
|||
|
|
return 0, 0, base.Errf("Integrate: error estimate %g exceeds the tolerance within %d subintervals",
|
|||
|
|
errSum, opts.MaxIntervals)
|
|||
|
|
}
|
|||
|
|
// Bisect the leaf that contributes the most error.
|
|||
|
|
worst := 0
|
|||
|
|
for i, s := range leaves {
|
|||
|
|
if s.err > leaves[worst].err {
|
|||
|
|
worst = i
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
w := leaves[worst]
|
|||
|
|
left, lerr := measure(w.l, (w.l+w.r)/2)
|
|||
|
|
if lerr != nil {
|
|||
|
|
return 0, 0, base.Errf("Integrate: %w", lerr)
|
|||
|
|
}
|
|||
|
|
right, rerr := measure((w.l+w.r)/2, w.r)
|
|||
|
|
if rerr != nil {
|
|||
|
|
return 0, 0, base.Errf("Integrate: %w", rerr)
|
|||
|
|
}
|
|||
|
|
leaves[worst] = left
|
|||
|
|
leaves = append(leaves, right)
|
|||
|
|
value, errSum = budget()
|
|||
|
|
}
|
|||
|
|
return sign * value, errSum, nil
|
|||
|
|
}
|