ogeom_math/integrate.rs
1//! Numerical integration.
2//!
3//! Gauss–Legendre quadrature, applied adaptively. A kernel integrates for arc
4//! length, for area and volume over a parametric patch, and for the moments
5//! that follow from those: all integrands that are smooth almost everywhere
6//! and awkward exactly where a feature is.
7//!
8//! # Why Gauss rather than Simpson
9//!
10//! An `n`-point Gauss rule is exact for polynomials of degree `2n - 1`, against
11//! Simpson's `3` for the same three evaluations. Since a B-spline *is* a
12//! piecewise polynomial and the speed of a curve along one is a square root of
13//! a polynomial, the integrands here are close enough to polynomial that the
14//! difference is large. Ten points integrate most spans to machine precision in
15//! one go.
16//!
17//! # Why adaptive on top
18//!
19//! A fixed rule cannot report its own error. A Gauss–Kronrod pair can: the
20//! fifteen-point Kronrod rule shares the seven-point Gauss rule's nodes, so
21//! one pass over an interval yields two estimates, and their difference is
22//! a fair measure of what the better one still misses. Where that is inside
23//! the budget the estimate has converged *there*; where it is not, only that
24//! part is subdivided. So a curve that is straight over most of its length
25//! and sharp in one place costs what the sharp place costs, not what the
26//! sharp place would cost applied everywhere. The same pair, in tensor
27//! form, integrates over a rectangle of parameters the same way.
28//!
29//! The recursion is bounded, and a result that hit the bound says so rather
30//! than being returned as though it converged.
31
32use ogeom_core::{OgeomResult, ogeom_bail};
33
34/// Nodes of the ten-point Gauss–Legendre rule on `[-1, 1]`, positive half.
35///
36/// The rule is symmetric, so the negative nodes are these negated and the
37/// weights are shared. Values are the standard ones (roots of the degree-ten
38/// Legendre polynomial), quoted to full `f64` precision.
39const NODES: [f64; 5] = [
40 0.148_874_338_981_631_21,
41 0.433_395_394_129_247_2,
42 0.679_409_568_299_024_4,
43 0.865_063_366_688_984_5,
44 0.973_906_528_517_171_7,
45];
46
47/// Weights matching [`NODES`].
48const WEIGHTS: [f64; 5] = [
49 0.295_524_224_714_752_87,
50 0.269_266_719_309_996_35,
51 0.219_086_362_515_982_04,
52 0.149_451_349_150_580_6,
53 0.066_671_344_308_688_14,
54];
55
56/// Nodes of the fifteen-point Kronrod rule on `[-1, 1]`, positive half,
57/// outermost first; every other one from the second is a node of the
58/// seven-point Gauss rule it extends.
59const KRONROD_NODES: [f64; 8] = [
60 0.991_455_371_120_812_6,
61 0.949_107_912_342_758_5,
62 0.864_864_423_359_769_1,
63 0.741_531_185_599_394_5,
64 0.586_087_235_467_691_1,
65 0.405_845_151_377_397_2,
66 0.207_784_955_007_898_48,
67 0.0,
68];
69
70/// Weights matching [`KRONROD_NODES`].
71const KRONROD_WEIGHTS: [f64; 8] = [
72 0.022_935_322_010_529_224,
73 0.063_092_092_629_978_56,
74 0.104_790_010_322_250_19,
75 0.140_653_259_715_525_92,
76 0.169_004_726_639_267_9,
77 0.190_350_578_064_785_42,
78 0.204_432_940_075_298_89,
79 0.209_482_141_084_727_82,
80];
81
82/// Weights of the seven-point Gauss rule, at the Kronrod nodes of odd
83/// index (the second, fourth, sixth and the centre).
84const GAUSS7_WEIGHTS: [f64; 4] = [
85 0.129_484_966_168_869_7,
86 0.279_705_391_489_276_64,
87 0.381_830_050_505_118_9,
88 0.417_959_183_673_469_4,
89];
90
91/// The most times [`integrate`] will subdivide one interval.
92///
93/// An integrand that has not converged by here has a singularity rather than a
94/// resolution problem, and the depth limit turns that into a reported failure
95/// instead of an exhausted stack.
96const MAX_DEPTH: u32 = 24;
97
98/// The most times [`integrate_2d`] will halve one cell, either way: a
99/// singular integrand is told about at the bottom rather than pursued.
100const MAX_DEPTH_2D: u32 = 24;
101
102/// Integrate `f` over `[a, b]` with the fixed ten-point rule.
103///
104/// Exact for polynomials up to degree nineteen. No error estimate; for that,
105/// use [`integrate`], which is this applied adaptively.
106///
107/// A reversed interval integrates to the negative, as it should: the rule
108/// carries the sign of `b - a` rather than quietly sorting its arguments.
109pub fn gauss_legendre<F: FnMut(f64) -> f64>(mut f: F, a: f64, b: f64) -> f64 {
110 let half = (b - a) * 0.5;
111 let middle = f64::midpoint(a, b);
112 let mut total = 0.0;
113 for (node, weight) in NODES.iter().zip(&WEIGHTS) {
114 let offset = half * node;
115 total += weight * (f(middle - offset) + f(middle + offset));
116 }
117 total * half
118}
119
120/// The ten-point Gauss-Legendre rule on `[a, b]`: each node with its weight.
121///
122/// For a caller that needs the samples themselves rather than one scalar
123/// integral, such as a sum of several integrands over the same nodes. The
124/// weights carry the sign of `b - a`, as [`gauss_legendre`]'s do.
125#[must_use]
126pub fn gauss_legendre_rule(a: f64, b: f64) -> [(f64, f64); 10] {
127 let half = (b - a) * 0.5;
128 let middle = f64::midpoint(a, b);
129 let mut rule = [(0.0, 0.0); 10];
130 for (i, (node, weight)) in NODES.iter().zip(&WEIGHTS).enumerate() {
131 let offset = half * node;
132 rule[2 * i] = (middle - offset, weight * half);
133 rule[2 * i + 1] = (middle + offset, weight * half);
134 }
135 rule
136}
137
138/// Integrate `f` over `[a, b]` with the seven-point Gauss and fifteen-point
139/// Kronrod pair: the Kronrod value, and the magnitude of its difference
140/// from the Gauss value as the estimate of what it still misses.
141///
142/// Fifteen evaluations, shared. The Kronrod rule is exact for polynomials
143/// up to degree twenty-two, the Gauss rule up to thirteen; where the two
144/// agree the integrand is polynomial enough that both are right, and the
145/// gap is a fair measure of the error where they are not. A reversed
146/// interval integrates to the negative, as [`gauss_legendre`] does.
147pub fn gauss_kronrod<F: FnMut(f64) -> f64>(mut f: F, a: f64, b: f64) -> (f64, f64) {
148 let half = (b - a) * 0.5;
149 let middle = f64::midpoint(a, b);
150 let mut kronrod = 0.0;
151 let mut gauss = 0.0;
152 for (i, (node, weight)) in KRONROD_NODES.iter().zip(&KRONROD_WEIGHTS).enumerate() {
153 let offset = half * node;
154 let pair = if *node == 0.0 {
155 f(middle)
156 } else {
157 f(middle - offset) + f(middle + offset)
158 };
159 kronrod += weight * pair;
160 if i % 2 == 1 {
161 gauss += GAUSS7_WEIGHTS[i / 2] * pair;
162 }
163 }
164 (kronrod * half, ((kronrod - gauss) * half).abs())
165}
166
167/// Integrate `f` over `[a, b]` to an absolute tolerance.
168///
169/// Subdivides where, and only where, the estimate has not settled, so a
170/// mostly-smooth integrand costs about what the smooth part costs.
171///
172/// # What it will not do
173///
174/// Each half is given half its parent's budget, so the budgets sum to the one
175/// asked for and the result is bounded by it. The cost is that an integrand
176/// with an *infinite derivative* at an endpoint (`sqrt(1 - x^2)` at `x = 1`,
177/// which is a circle's own equation) has a budget shrinking faster than its
178/// error does, and cannot be squeezed arbitrarily. In practice it manages
179/// about `1e-7` on that shape, and lands within `1e-14` when it does; asked for
180/// `1e-8` it reports that it could not rather than returning the number it
181/// reached.
182///
183/// This does not affect arc length, which is what the routine is mostly for:
184/// the speed along a curve is `|c'(u)|`, smooth and positive wherever the
185/// parameterization is regular. A singularity here means a genuinely singular
186/// parameterization, which is worth being told about.
187///
188/// # Errors
189///
190/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the interval is not finite;
191/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if some part of it did not
192/// converge within the depth limit. That is reported rather than returned as a
193/// number, because an integral that silently stopped improving is the shape of
194/// answer that gets trusted.
195pub fn integrate<F: FnMut(f64) -> f64>(
196 mut f: F,
197 a: f64,
198 b: f64,
199 tolerance: f64,
200) -> OgeomResult<f64> {
201 if !a.is_finite() || !b.is_finite() {
202 ogeom_bail!(Domain, "cannot integrate over [{a}, {b}]");
203 }
204 if !tolerance.is_finite() || tolerance <= 0.0 {
205 ogeom_bail!(Domain, "integration tolerance {tolerance} must be positive");
206 }
207 if a == b {
208 return Ok(0.0);
209 }
210 refine(&mut f, a, b, tolerance, 0)
211}
212
213/// One step of the adaptive halving.
214fn refine<F: FnMut(f64) -> f64>(
215 f: &mut F,
216 a: f64,
217 b: f64,
218 tolerance: f64,
219 depth: u32,
220) -> OgeomResult<f64> {
221 let (value, error) = gauss_kronrod(&mut *f, a, b);
222 if error <= tolerance {
223 return Ok(value);
224 }
225 // Nothing left here worth resolving. A Gauss rule does not converge in
226 // *relative* terms against a square-root singularity (the error stays a
227 // roughly fixed fraction of the contribution), so an interval containing
228 // one can fail the comparison above at every depth, while the quantity it
229 // is failing about shrinks to nothing. Once the total magnitude on this
230 // interval is inside the budget, no amount of refining it can move the
231 // answer by more than the budget, so refining it is not worth doing.
232 if value.abs() + error <= tolerance {
233 return Ok(value);
234 }
235 if depth >= MAX_DEPTH {
236 ogeom_bail!(
237 NotDone,
238 "the integral over [{a}, {b}] did not converge to {tolerance} \
239 within {MAX_DEPTH} subdivisions; the integrand has a singularity \
240 there rather than a resolution problem"
241 );
242 }
243 // Half the tolerance to each half, so the halves' errors sum to the whole's
244 // rather than each being allowed the whole budget.
245 let middle = f64::midpoint(a, b);
246 let half = tolerance * 0.5;
247 Ok(refine(f, a, middle, half, depth + 1)? + refine(f, middle, b, half, depth + 1)?)
248}
249
250/// Integrate `f(u, v)` over the rectangle `[a, b] x [c, d]` to an absolute
251/// tolerance: a patch's area, its moments, anything spread over a chart.
252///
253/// The Gauss–Kronrod pair in tensor form: one pass over a cell is fifteen
254/// by fifteen evaluations and yields the Kronrod estimate and, from the
255/// same values, the estimate with the Gauss rule in `u` and the one with
256/// it in `v`, each gap the error owed to that direction. A cell whose
257/// worse gap is inside its budget is done; one whose is not is halved
258/// *along the rougher direction*, each half given half the budget, so the
259/// cells' errors sum to the whole's and a ridge running across the chart
260/// (a crease in an integrand, a seam) costs a line of cells rather than a
261/// field of them.
262///
263/// # Errors
264///
265/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if a bound is not
266/// finite or the tolerance is not positive;
267/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if some cell did
268/// not converge within the depth limit.
269pub fn integrate_2d<F: FnMut(f64, f64) -> f64>(
270 mut f: F,
271 u: (f64, f64),
272 v: (f64, f64),
273 tolerance: f64,
274) -> OgeomResult<f64> {
275 let (a, b) = u;
276 let (c, d) = v;
277 if !a.is_finite() || !b.is_finite() || !c.is_finite() || !d.is_finite() {
278 ogeom_bail!(Domain, "cannot integrate over [{a}, {b}] x [{c}, {d}]");
279 }
280 if !tolerance.is_finite() || tolerance <= 0.0 {
281 ogeom_bail!(Domain, "integration tolerance {tolerance} must be positive");
282 }
283 if a == b || c == d {
284 return Ok(0.0);
285 }
286 refine_2d(&mut f, (a, b), (c, d), tolerance, 0)
287}
288
289/// The tensor Gauss–Kronrod pair over one cell: the Kronrod value, and the
290/// gaps to the estimates with the Gauss rule in `u` and in `v`.
291fn gauss_kronrod_2d<F: FnMut(f64, f64) -> f64>(
292 f: &mut F,
293 (a, b): (f64, f64),
294 (c, d): (f64, f64),
295) -> (f64, [f64; 2]) {
296 let (half_u, middle_u) = ((b - a) * 0.5, f64::midpoint(a, b));
297 let (half_v, middle_v) = ((d - c) * 0.5, f64::midpoint(c, d));
298 // Each node's two mirror images, or the centre once.
299 let stations = |half: f64, middle: f64, node: f64| -> [Option<f64>; 2] {
300 if node == 0.0 {
301 [Some(middle), None]
302 } else {
303 [Some(middle - half * node), Some(middle + half * node)]
304 }
305 };
306 let mut kronrod = 0.0;
307 let mut gauss_u = 0.0;
308 let mut gauss_v = 0.0;
309 for (i, (node_u, weight_u)) in KRONROD_NODES.iter().zip(&KRONROD_WEIGHTS).enumerate() {
310 for (j, (node_v, weight_v)) in KRONROD_NODES.iter().zip(&KRONROD_WEIGHTS).enumerate() {
311 let mut cell = 0.0;
312 for uu in stations(half_u, middle_u, *node_u).into_iter().flatten() {
313 for vv in stations(half_v, middle_v, *node_v).into_iter().flatten() {
314 cell += f(uu, vv);
315 }
316 }
317 kronrod += weight_u * weight_v * cell;
318 if i % 2 == 1 {
319 gauss_u += GAUSS7_WEIGHTS[i / 2] * weight_v * cell;
320 }
321 if j % 2 == 1 {
322 gauss_v += weight_u * GAUSS7_WEIGHTS[j / 2] * cell;
323 }
324 }
325 }
326 let scale = half_u * half_v;
327 (
328 kronrod * scale,
329 [
330 ((kronrod - gauss_u) * scale).abs(),
331 ((kronrod - gauss_v) * scale).abs(),
332 ],
333 )
334}
335
336/// One step of the adaptive quartering.
337fn refine_2d<F: FnMut(f64, f64) -> f64>(
338 f: &mut F,
339 (a, b): (f64, f64),
340 (c, d): (f64, f64),
341 tolerance: f64,
342 depth: u32,
343) -> OgeomResult<f64> {
344 let (value, [error_u, error_v]) = gauss_kronrod_2d(f, (a, b), (c, d));
345 let error = error_u.max(error_v);
346 if error <= tolerance || value.abs() + error <= tolerance {
347 return Ok(value);
348 }
349 if depth >= MAX_DEPTH_2D {
350 ogeom_bail!(
351 NotDone,
352 "the integral over [{a}, {b}] x [{c}, {d}] did not converge to \
353 {tolerance} within {MAX_DEPTH_2D} halvings; the integrand has a \
354 singularity there rather than a resolution problem"
355 );
356 }
357 let half = tolerance * 0.5;
358 if error_u >= error_v {
359 let mu = f64::midpoint(a, b);
360 Ok(refine_2d(f, (a, mu), (c, d), half, depth + 1)?
361 + refine_2d(f, (mu, b), (c, d), half, depth + 1)?)
362 } else {
363 let mv = f64::midpoint(c, d);
364 Ok(refine_2d(f, (a, b), (c, mv), half, depth + 1)?
365 + refine_2d(f, (a, b), (mv, d), half, depth + 1)?)
366 }
367}
368
369#[cfg(test)]
370#[allow(clippy::unwrap_used)]
371mod tests {
372 use super::*;
373 use approx::assert_relative_eq;
374 use core::f64::consts::PI;
375
376 #[test]
377 fn a_polynomial_within_the_rules_degree_is_exact_in_one_go() {
378 // Degree nineteen is what a ten-point rule integrates exactly, and
379 // "exactly" here should mean to rounding, not to a tolerance.
380 let f = |x: f64| x.powi(19) + 3.0 * x.powi(4) - 7.0 * x + 2.0;
381 let exact = 1.0 / 20.0 + 3.0 / 5.0 - 7.0 / 2.0 + 2.0;
382 assert_relative_eq!(gauss_legendre(f, 0.0, 1.0), exact, epsilon = 1e-14);
383 }
384
385 #[test]
386 fn transcendental_integrands_converge() {
387 assert_relative_eq!(
388 integrate(f64::sin, 0.0, PI, 1e-12).unwrap(),
389 2.0,
390 epsilon = 1e-12
391 );
392 assert_relative_eq!(
393 integrate(|x| 1.0 / x, 1.0, core::f64::consts::E, 1e-12).unwrap(),
394 1.0,
395 epsilon = 1e-12
396 );
397 }
398
399 #[test]
400 fn an_infinite_derivative_at_an_endpoint_is_handled_to_a_stated_limit() {
401 // The quarter circle. Its integrand's derivative blows up at x = 1, so
402 // the halved budget shrinks faster than the error there does and the
403 // method has a floor. Where it converges it is far better than asked;
404 // and where it does not, it says so instead of returning what it
405 // reached, which is the whole difference between a limit and a bug.
406 let quarter = |x: f64| (1.0 - x * x).max(0.0).sqrt();
407 let found = integrate(quarter, 0.0, 1.0, 1e-7).unwrap();
408 assert_relative_eq!(found, PI / 4.0, epsilon = 1e-12);
409 assert!(
410 integrate(quarter, 0.0, 1.0, 1e-8).is_err(),
411 "asked for more than the method can give, it should say so"
412 );
413 }
414
415 /// The pair's constants, checked by what they must do: the weights sum
416 /// to the interval, the Gauss rule is exact to degree thirteen and the
417 /// Kronrod rule to twenty-two, and the gap between them is zero where
418 /// both are exact.
419 #[test]
420 fn the_gauss_kronrod_pair_is_exact_to_its_degrees() {
421 let (weight_sum, _) = gauss_kronrod(|_| 1.0, -1.0, 1.0);
422 assert_relative_eq!(weight_sum, 2.0, epsilon = 1e-15);
423 let thirteen = |x: f64| x.powi(13) + 2.0 * x.powi(8) - x;
424 let (value, error) = gauss_kronrod(thirteen, 0.0, 1.0);
425 assert_relative_eq!(value, 1.0 / 14.0 + 2.0 / 9.0 - 0.5, epsilon = 1e-14);
426 assert!(error < 1e-14, "both rules exact, no gap: {error}");
427 let twenty_two = |x: f64| x.powi(22) - 3.0 * x.powi(17);
428 let (value, error) = gauss_kronrod(twenty_two, 0.0, 1.0);
429 assert_relative_eq!(value, 1.0 / 23.0 - 3.0 / 18.0, epsilon = 1e-14);
430 assert!(
431 error > 1e-6,
432 "the Gauss rule is not exact here, and the gap says so: {error}"
433 );
434 }
435
436 /// A rectangle of parameters: exact for a product of polynomials in one
437 /// pass, the area of a sphere from its own chart, and a ridge that
438 /// forces quartering on one side of the cell only.
439 #[test]
440 fn a_rectangle_integrates_to_a_stated_tolerance() {
441 let product =
442 integrate_2d(|u, v| u * u * v * v * v, (0.0, 1.0), (0.0, 1.0), 1e-12).unwrap();
443 assert_relative_eq!(product, 1.0 / 12.0, epsilon = 1e-13);
444 let sphere = integrate_2d(|_, v| v.sin(), (0.0, 2.0 * PI), (0.0, PI), 1e-10).unwrap();
445 assert_relative_eq!(sphere, 4.0 * PI, epsilon = 1e-9);
446 let ridge = integrate_2d(|u, v| (u - 0.3).abs() + v, (0.0, 1.0), (0.0, 1.0), 1e-9).unwrap();
447 // ∫|u − 0.3| du over [0, 1] = 0.045 + 0.245 = 0.29; ∫ v dv = 0.5.
448 assert_relative_eq!(ridge, 0.29 + 0.5, epsilon = 1e-8);
449 assert!(integrate_2d(|u, _| 1.0 / u, (0.0, 1.0), (0.0, 1.0), 1e-9).is_err());
450 assert_eq!(
451 integrate_2d(|u, v| u + v, (1.0, 1.0), (0.0, 1.0), 1e-9).unwrap(),
452 0.0
453 );
454 }
455
456 #[test]
457 fn a_reversed_interval_integrates_to_the_negative() {
458 // Rather than being quietly sorted, which would make an arc length
459 // computed backwards come out positive and hide the caller's mistake.
460 let forward = integrate(f64::sin, 0.0, PI, 1e-12).unwrap();
461 let backward = integrate(f64::sin, PI, 0.0, 1e-12).unwrap();
462 assert_relative_eq!(forward, -backward, epsilon = 1e-12);
463 }
464
465 #[test]
466 fn an_empty_interval_integrates_to_nothing() {
467 assert_eq!(integrate(f64::sin, 1.0, 1.0, 1e-12).unwrap(), 0.0);
468 }
469
470 #[test]
471 fn an_integrand_that_will_not_converge_says_so() {
472 // 1/x towards zero has no finite integral. Returning a large number
473 // would be worse than failing, because a caller cannot tell it apart
474 // from a genuinely large answer.
475 assert!(integrate(|x| 1.0 / x, 0.0, 1.0, 1e-12).is_err());
476 }
477
478 #[test]
479 fn non_finite_bounds_and_tolerances_are_refused() {
480 assert!(integrate(f64::sin, 0.0, f64::NAN, 1e-9).is_err());
481 assert!(integrate(f64::sin, f64::NEG_INFINITY, 0.0, 1e-9).is_err());
482 assert!(integrate(f64::sin, 0.0, 1.0, 0.0).is_err());
483 assert!(integrate(f64::sin, 0.0, 1.0, -1.0).is_err());
484 }
485}