Numerical Quadrature
Rectangles, trapezoids, parabola tops.
Intermediate / integration / error-analysis
Three quadrature rules approximate the same definite integral against its exact value: left Riemann sums err like 1/n, trapezoids like 1/n-squared, Simpson like 1/n-to-the-fourth. Halve the panels and watch the error drop by the predicted factor - convergence orders you can measure on the screen.
Tagged: Riemann sum, trapezoid rule, Simpson, convergence order
Live simulation
Double the panels: Riemann error quarters, trapezoid /4, Simpson /16. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Double the panels: Riemann error quarters, trapezoid /4, Simpson /16.
Mathematics
Definite integrals can be approximated to any accuracy by summing simple shapes: left Riemann rectangles (error O(n^-1)), trapezoids (O(n^-2)), and Simpson's parabola-topped panels (O(n^-4)). The exact value is known for each test integrand, so the error is a measurement.
left Riemann: sum f(x_i) h
trapezoid: h (f(a)/2 + sum_{i=1}^{n-1} f(x_i) + f(b)/2)
Simpson: h/3 (f(a) + 4 sum odd + 2 sum even + f(b))
The error column is the lesson: double the panels and the Riemann error quarters, the trapezoid error divides by four, the Simpson error by sixteen - the convergence orders of the three rules, measured rather than recited. For exp(-x^2) the 'exact' value is the erf-based constant quoted to 15 digits.
How it works
Panels are drawn as filled shapes under the curve (trapezoid draws quads, Simpson uses the fitted parabola value at each panel's midpoint), so the approximation is visually auditable against the curve.
Simpson silently rounds odd panel counts up - the parity requirement is part of the rule.
Implementation
src/engine/experiments/quadrature.ts · 171 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | type Fn = { label: string; f: (x: number) => number; exact: string; exactValue: (a: number, b: number) => number; domain: [number, number] }; |
| 5 | |
| 6 | const FUNCTIONS: Record<string, Fn> = { |
| 7 | sin: { |
| 8 | label: "sin(x)", |
| 9 | f: Math.sin, |
| 10 | exact: "2", |
| 11 | exactValue: (a, b) => Math.cos(a) - Math.cos(b), |
| 12 | domain: [0, Math.PI], |
| 13 | }, |
| 14 | parabola: { |
| 15 | label: "x^2", |
| 16 | f: (x) => x * x, |
| 17 | exact: "1/3", |
| 18 | exactValue: (a, b) => (b ** 3 - a ** 3) / 3, |
| 19 | domain: [0, 1], |
| 20 | }, |
| 21 | gaussian: { |
| 22 | label: "exp(-x^2)", |
| 23 | f: (x) => Math.exp(-x * x), |
| 24 | exact: "sqrt(pi)/2 erf(1)", |
| 25 | exactValue: () => 0.746824132812427, |
| 26 | domain: [0, 1], |
| 27 | }, |
| 28 | }; |
| 29 | |
| 30 | /** |
| 31 | * Numerical quadrature three ways - left Riemann, trapezoid, Simpson - |
| 32 | * against the exact value. The error metric exposes the convergence orders: |
| 33 | * Riemann O(n^-1), trapezoid O(n^-2), Simpson O(n^-4). |
| 34 | */ |
| 35 | export class QuadratureExperiment extends BaseExperiment { |
| 36 | readonly id = "quadrature"; |
| 37 | |
| 38 | protected params(): ParameterDef[] { |
| 39 | return [ |
| 40 | { |
| 41 | key: "fn", |
| 42 | label: "Integrand", |
| 43 | options: [ |
| 44 | { value: "sin", label: "sin(x) on [0, pi]" }, |
| 45 | { value: "parabola", label: "x^2 on [0, 1]" }, |
| 46 | { value: "gaussian", label: "exp(-x^2) on [0, 1]" }, |
| 47 | ], |
| 48 | defaultValue: "sin", |
| 49 | }, |
| 50 | { |
| 51 | key: "method", |
| 52 | label: "Rule", |
| 53 | options: [ |
| 54 | { value: "left", label: "Left Riemann" }, |
| 55 | { value: "trapezoid", label: "Trapezoid" }, |
| 56 | { value: "simpson", label: "Simpson" }, |
| 57 | ], |
| 58 | defaultValue: "trapezoid", |
| 59 | }, |
| 60 | { key: "n", label: "Panels n", min: 2, max: 200, step: 2, defaultValue: 12 }, |
| 61 | ]; |
| 62 | } |
| 63 | |
| 64 | protected onReset(): void {} |
| 65 | |
| 66 | protected onUpdate(): void {} |
| 67 | |
| 68 | private fn(): Fn { |
| 69 | return FUNCTIONS[this.str("fn")]; |
| 70 | } |
| 71 | |
| 72 | private approximate(): number { |
| 73 | const f = this.fn().f; |
| 74 | const [a, b] = this.fn().domain; |
| 75 | const n = this.num("n"); |
| 76 | const h = (b - a) / n; |
| 77 | const method = this.str("method"); |
| 78 | if (method === "left") { |
| 79 | let sum = 0; |
| 80 | for (let i = 0; i < n; i++) sum += f(a + i * h); |
| 81 | return sum * h; |
| 82 | } |
| 83 | if (method === "trapezoid") { |
| 84 | let sum = (f(a) + f(b)) / 2; |
| 85 | for (let i = 1; i < n; i++) sum += f(a + i * h); |
| 86 | return sum * h; |
| 87 | } |
| 88 | // Simpson needs an even n. |
| 89 | const m = n % 2 === 0 ? n : n + 1; |
| 90 | const hh = (b - a) / m; |
| 91 | let sum = f(a) + f(b); |
| 92 | for (let i = 1; i < m; i++) sum += f(a + i * hh) * (i % 2 === 1 ? 4 : 2); |
| 93 | return (sum * hh) / 3; |
| 94 | } |
| 95 | |
| 96 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 97 | ctx.fillStyle = theme.bg; |
| 98 | ctx.fillRect(0, 0, this.width, this.height); |
| 99 | const f = this.fn(); |
| 100 | const [a, b] = f.domain; |
| 101 | const toX = (x: number) => ((x - a) / (b - a)) * this.width; |
| 102 | const valueSpan = 2.4; |
| 103 | const toY = (y: number) => this.height - 24 - (y / valueSpan) * (this.height - 40); |
| 104 | const n = this.num("n"); |
| 105 | const h = (b - a) / n; |
| 106 | const method = this.str("method"); |
| 107 | // Panels |
| 108 | ctx.fillStyle = theme.raised; |
| 109 | for (let i = 0; i < n; i++) { |
| 110 | const x0 = a + i * h; |
| 111 | const x1 = x0 + h; |
| 112 | const yv = method === "left" ? f.f(x0) : method === "trapezoid" ? NaN : (f.f(x0) + 4 * f.f((x0 + x1) / 2) + f.f(x1)) / 6; |
| 113 | if (Number.isNaN(yv)) { |
| 114 | // Trapezoid panel: draw as a quad. |
| 115 | ctx.beginPath(); |
| 116 | ctx.moveTo(toX(x0), toY(0)); |
| 117 | ctx.lineTo(toX(x0), toY(f.f(x0))); |
| 118 | ctx.lineTo(toX(x1), toY(f.f(x1))); |
| 119 | ctx.lineTo(toX(x1), toY(0)); |
| 120 | ctx.closePath(); |
| 121 | ctx.fill(); |
| 122 | continue; |
| 123 | } |
| 124 | const px = toX(x0); |
| 125 | const pw = toX(x1) - toX(x0); |
| 126 | const py = toY(Math.max(0, yv)); |
| 127 | ctx.fillRect(px, py, pw, toY(0) - py); |
| 128 | } |
| 129 | // Curve |
| 130 | ctx.strokeStyle = theme.fg; |
| 131 | ctx.lineWidth = 1.8; |
| 132 | ctx.beginPath(); |
| 133 | for (let px = 0; px <= this.width; px += 1) { |
| 134 | const xv = a + (px / this.width) * (b - a); |
| 135 | const py = toY(f.f(xv)); |
| 136 | if (px === 0) ctx.moveTo(px, py); |
| 137 | else ctx.lineTo(px, py); |
| 138 | } |
| 139 | ctx.stroke(); |
| 140 | ctx.strokeStyle = theme.line; |
| 141 | ctx.beginPath(); |
| 142 | ctx.moveTo(0, toY(0)); |
| 143 | ctx.lineTo(this.width, toY(0)); |
| 144 | ctx.stroke(); |
| 145 | ctx.lineWidth = 1; |
| 146 | } |
| 147 | |
| 148 | getMetrics() { |
| 149 | const f = this.fn(); |
| 150 | const [a, b] = f.domain; |
| 151 | const approx = this.approximate(); |
| 152 | const exact = f.exactValue(a, b); |
| 153 | return { |
| 154 | method: this.str("method"), |
| 155 | panels: this.num("n"), |
| 156 | approximation: approx.toFixed(8), |
| 157 | exact: f.exact, |
| 158 | error: Math.abs(approx - exact).toExponential(3), |
| 159 | }; |
| 160 | } |
| 161 | |
| 162 | describe(): string { |
| 163 | const m = this.getMetrics(); |
| 164 | return `${m.method} with ${m.panels} panels approximates the integral as ${m.approximation} (exact ${m.exact}); error ${m.error}.`; |
| 165 | } |
| 166 | |
| 167 | entities(): number { |
| 168 | return this.num("n"); |
| 169 | } |
| 170 | } |
| 171 | |
Performance
- Time per tick
- O(n) evaluations per approximation
- Space
- None
- Notes
- Simpson with n = 200 on sin over [0, pi] matches 2 to ten decimals: try to catch it being wrong.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.