@johnhenry/math-sample 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +244 -2
  2. package/dist/budget.d.ts +47 -0
  3. package/dist/budget.d.ts.map +1 -0
  4. package/dist/budget.js +44 -0
  5. package/dist/budget.js.map +1 -0
  6. package/dist/complex.d.ts +79 -0
  7. package/dist/complex.d.ts.map +1 -0
  8. package/dist/complex.js +236 -0
  9. package/dist/complex.js.map +1 -0
  10. package/dist/expr.d.ts +38 -0
  11. package/dist/expr.d.ts.map +1 -0
  12. package/dist/expr.js +264 -0
  13. package/dist/expr.js.map +1 -0
  14. package/dist/field2d.d.ts +58 -0
  15. package/dist/field2d.d.ts.map +1 -0
  16. package/dist/field2d.js +199 -0
  17. package/dist/field2d.js.map +1 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +10 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/mesh.d.ts +90 -0
  23. package/dist/mesh.d.ts.map +1 -0
  24. package/dist/mesh.js +294 -0
  25. package/dist/mesh.js.map +1 -0
  26. package/dist/ode.d.ts +147 -0
  27. package/dist/ode.d.ts.map +1 -0
  28. package/dist/ode.js +466 -0
  29. package/dist/ode.js.map +1 -0
  30. package/dist/sample.d.ts +114 -0
  31. package/dist/sample.d.ts.map +1 -0
  32. package/dist/sample.js +308 -0
  33. package/dist/sample.js.map +1 -0
  34. package/dist/series.d.ts +53 -0
  35. package/dist/series.d.ts.map +1 -0
  36. package/dist/series.js +158 -0
  37. package/dist/series.js.map +1 -0
  38. package/dist/svg.d.ts +37 -0
  39. package/dist/svg.d.ts.map +1 -0
  40. package/dist/svg.js +82 -0
  41. package/dist/svg.js.map +1 -0
  42. package/dist/ticks.d.ts +17 -0
  43. package/dist/ticks.d.ts.map +1 -0
  44. package/dist/ticks.js +47 -0
  45. package/dist/ticks.js.map +1 -0
  46. package/dist/viewport.d.ts +59 -0
  47. package/dist/viewport.d.ts.map +1 -0
  48. package/dist/viewport.js +111 -0
  49. package/dist/viewport.js.map +1 -0
  50. package/package.json +70 -4
  51. package/src/budget.ts +72 -0
  52. package/src/complex.ts +327 -0
  53. package/src/expr.ts +283 -0
  54. package/src/field2d.ts +259 -0
  55. package/src/index.ts +112 -0
  56. package/src/mesh.ts +376 -0
  57. package/src/ode.ts +633 -0
  58. package/src/sample.ts +391 -0
  59. package/src/series.ts +209 -0
  60. package/src/svg.ts +124 -0
  61. package/src/ticks.ts +46 -0
  62. package/src/viewport.ts +150 -0
package/src/sample.ts ADDED
@@ -0,0 +1,391 @@
1
+ /** A real function of one variable, e.g. a compiled expression. */
2
+ export type Fn1 = (x: number) => number;
3
+
4
+ /** A closed interval of the x-axis. */
5
+ export interface Domain {
6
+ min: number;
7
+ max: number;
8
+ }
9
+
10
+ /** The y-range currently on screen; lets the sampler cap blow-ups and scale its tolerance. */
11
+ export interface YRange {
12
+ min: number;
13
+ max: number;
14
+ }
15
+
16
+ /**
17
+ * Sampled curve as two parallel typed arrays. A row whose `y` is `NaN` is a
18
+ * gap: the curve is not drawn between the rows on either side of it (a pole,
19
+ * a domain edge, a jump). Gaps are never first, last, or adjacent to another
20
+ * gap.
21
+ */
22
+ export interface Samples {
23
+ x: Float64Array;
24
+ y: Float64Array;
25
+ }
26
+
27
+ /** Anything that looks like an `AbortSignal`; kept structural so the package needs no DOM or Node types. */
28
+ export interface CancelSignal {
29
+ readonly aborted: boolean;
30
+ }
31
+
32
+ export interface SampleOptions {
33
+ /**
34
+ * The y-range on screen. Samples far outside it (a pole's shoulders, which
35
+ * are finite but huge) are treated as gaps, and the curve is capped with an
36
+ * interpolated point at the edge of the range. Also sets the default
37
+ * adaptive tolerance, and (for `sampleAdaptive` only; `sampleUniform` does
38
+ * not detect jumps) enables jump detection.
39
+ */
40
+ visibleYRange?: YRange;
41
+ /** Abort when `signal.aborted` becomes true. Checked every few hundred evaluations. */
42
+ signal?: CancelSignal;
43
+ /** Polled alongside `signal`; return true to abort (the generation-counter pattern: `() => gen !== latest`). */
44
+ isCancelled?: () => boolean;
45
+ }
46
+
47
+ export interface AdaptiveOptions extends SampleOptions {
48
+ /** How many times one base-grid cell may be bisected. Default 4. */
49
+ maxDepth?: number;
50
+ /**
51
+ * Bisect a cell while its midpoint sits further than this (in y units) from
52
+ * the straight line between its ends. Default: `1e-4` of the visible y span
53
+ * when `visibleYRange` is given, else `1e-3`.
54
+ */
55
+ tolerance?: number;
56
+ /**
57
+ * A cell that is still this far apart at `maxDepth` (as a fraction of the
58
+ * visible y span) is a jump, not a steep slope, and becomes a gap. Default
59
+ * `0.5` when `visibleYRange` is given; with no range there is no scale to
60
+ * judge against and jumps are not detected. `Infinity` disables it. To tell
61
+ * a jump from a steep slope, the height must also persist (at least 80%)
62
+ * from the parent cell to the finest one. A function that oscillates faster
63
+ * than the grid can resolve can still look like a jump; raise the
64
+ * resolution or `maxDepth`.
65
+ */
66
+ jumpFraction?: number;
67
+ }
68
+
69
+ /** Thrown by the samplers when `signal` or `isCancelled` reports cancellation. */
70
+ export class SampleCancelledError extends Error {
71
+ constructor() {
72
+ super("sampling cancelled");
73
+ this.name = "SampleCancelledError";
74
+ }
75
+ }
76
+
77
+ const CANCEL_CHECK_INTERVAL = 256;
78
+ const DEFAULT_MAX_DEPTH = 4;
79
+ const FALLBACK_TOLERANCE = 1e-3;
80
+ const RELATIVE_TOLERANCE = 1e-4;
81
+ const DEFAULT_JUMP_FRACTION = 0.5;
82
+ const JUMP_PERSISTENCE = 0.8;
83
+
84
+ function checkDomain(domain: Domain, resolution: number): void {
85
+ if (!Number.isFinite(domain.min) || !Number.isFinite(domain.max) || !(domain.max > domain.min)) {
86
+ throw new RangeError(`domain must be finite with min < max, got [${domain.min}, ${domain.max}]`);
87
+ }
88
+ if (!Number.isInteger(resolution) || resolution < 2) {
89
+ throw new RangeError(`resolution must be an integer >= 2, got ${resolution}`);
90
+ }
91
+ }
92
+
93
+ function guardedEval(f: Fn1, options: SampleOptions): Fn1 {
94
+ const { signal, isCancelled } = options;
95
+ if (!signal && !isCancelled) return f;
96
+ let count = 0;
97
+ const poll = () => {
98
+ if (signal?.aborted || isCancelled?.()) throw new SampleCancelledError();
99
+ };
100
+ poll();
101
+ return (x) => {
102
+ if (++count % CANCEL_CHECK_INTERVAL === 0) poll();
103
+ return f(x);
104
+ };
105
+ }
106
+
107
+ function isOffRange(y: number, range: YRange | undefined): boolean {
108
+ if (!range) return false;
109
+ const span = range.max - range.min;
110
+ return Math.abs(y) > Math.abs(range.min) + Math.abs(range.max) + 5 * span;
111
+ }
112
+
113
+ /** Where segment a-b crosses the nearer edge of `range`, inset 3% so end markers are not clipped. Null if undefined. */
114
+ function edgeCrossing(
115
+ ax: number,
116
+ ay: number,
117
+ bx: number,
118
+ by: number,
119
+ range: YRange | undefined,
120
+ ): [number, number] | null {
121
+ if (!range || !Number.isFinite(ay) || !Number.isFinite(by) || ay === by) return null;
122
+ const span = range.max - range.min;
123
+ const edge = by > ay ? range.max - 0.03 * span : range.min + 0.03 * span;
124
+ const t = (edge - ay) / (by - ay);
125
+ if (!Number.isFinite(t) || t < 0 || t > 1) return null;
126
+ return [ax + t * (bx - ax), edge];
127
+ }
128
+
129
+ /** Turn raw points (NaN / Infinity / off-range = invalid) into the gap-marked `Samples` form. */
130
+ function toSamples(rx: number[], ry: number[], range: YRange | undefined): Samples {
131
+ const ox: number[] = [];
132
+ const oy: number[] = [];
133
+ let prevValid = false;
134
+ let px = 0;
135
+ let py = 0;
136
+ let havePrev = false;
137
+ for (let i = 0; i < rx.length; i++) {
138
+ const x = rx[i];
139
+ const y = ry[i];
140
+ const finite = Number.isFinite(y);
141
+ const valid = finite && !isOffRange(y, range);
142
+ if (valid) {
143
+ if (!prevValid && havePrev) {
144
+ const e = edgeCrossing(px, py, x, y, range);
145
+ if (e) {
146
+ ox.push(e[0]);
147
+ oy.push(e[1]);
148
+ }
149
+ }
150
+ ox.push(x);
151
+ oy.push(y);
152
+ } else {
153
+ if (oy.length > 0 && !Number.isNaN(oy[oy.length - 1])) {
154
+ if (prevValid && finite) {
155
+ const e = edgeCrossing(px, py, x, y, range);
156
+ if (e) {
157
+ ox.push(e[0]);
158
+ oy.push(e[1]);
159
+ }
160
+ }
161
+ ox.push(x);
162
+ oy.push(Number.NaN);
163
+ }
164
+ }
165
+ px = x;
166
+ py = y;
167
+ prevValid = valid;
168
+ havePrev = true;
169
+ }
170
+ if (oy.length > 0 && Number.isNaN(oy[oy.length - 1])) {
171
+ ox.pop();
172
+ oy.pop();
173
+ }
174
+ return { x: Float64Array.from(ox), y: Float64Array.from(oy) };
175
+ }
176
+
177
+ /** Evaluate `f` at `resolution` evenly spaced points over `domain` (both ends included). */
178
+ export function sampleUniform(f: Fn1, domain: Domain, resolution: number, options: SampleOptions = {}): Samples {
179
+ checkDomain(domain, resolution);
180
+ const g = guardedEval(f, options);
181
+ const rx = new Array<number>(resolution);
182
+ const ry = new Array<number>(resolution);
183
+ const span = domain.max - domain.min;
184
+ for (let i = 0; i < resolution; i++) {
185
+ const x = i === resolution - 1 ? domain.max : domain.min + (i / (resolution - 1)) * span;
186
+ rx[i] = x;
187
+ ry[i] = g(x);
188
+ }
189
+ return toSamples(rx, ry, options.visibleYRange);
190
+ }
191
+
192
+ /**
193
+ * Like {@link sampleUniform}, but bisects each cell of the base grid where the
194
+ * curve bends: a cell is split while its midpoint deviates from the straight
195
+ * line through its ends by more than the tolerance, up to `maxDepth` times. A
196
+ * straight stretch costs nothing extra; a sharp bend or narrow spike that a
197
+ * uniform grid would straddle is resolved. Cells that still jump at the
198
+ * deepest level become gaps (see `jumpFraction`).
199
+ */
200
+ export function sampleAdaptive(f: Fn1, domain: Domain, baseResolution: number, options: AdaptiveOptions = {}): Samples {
201
+ checkDomain(domain, baseResolution);
202
+ const range = options.visibleYRange;
203
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
204
+ const tolerance = options.tolerance ?? (range ? (range.max - range.min) * RELATIVE_TOLERANCE : FALLBACK_TOLERANCE);
205
+ const jumpFraction = options.jumpFraction ?? (range ? DEFAULT_JUMP_FRACTION : Number.POSITIVE_INFINITY);
206
+ const jumpAbs = range ? jumpFraction * (range.max - range.min) : Number.POSITIVE_INFINITY;
207
+ const g = guardedEval(f, options);
208
+
209
+ const rx: number[] = [];
210
+ const ry: number[] = [];
211
+
212
+ // Pushes `a` and the refinement points strictly between a and b (not b).
213
+ const refine = (ax: number, ay: number, bx: number, by: number, depth: number, parentDy: number): void => {
214
+ if (!Number.isFinite(ay) || !Number.isFinite(by) || isOffRange(ay, range) || isOffRange(by, range)) {
215
+ rx.push(ax);
216
+ ry.push(ay);
217
+ return;
218
+ }
219
+ if (depth >= maxDepth) {
220
+ rx.push(ax);
221
+ ry.push(ay);
222
+ const dy = Math.abs(by - ay);
223
+ // A jump keeps its height as the cell halves; a steep slope loses about half of it per halving.
224
+ if (dy > jumpAbs && dy >= JUMP_PERSISTENCE * parentDy) {
225
+ rx.push((ax + bx) / 2);
226
+ ry.push(Number.NaN);
227
+ }
228
+ return;
229
+ }
230
+ const mx = (ax + bx) / 2;
231
+ const my = g(mx);
232
+ // A non-finite midpoint is a singularity between a and b: record it as the gap it is.
233
+ if (!Number.isFinite(my)) {
234
+ rx.push(ax, mx);
235
+ ry.push(ay, my);
236
+ return;
237
+ }
238
+ if (Math.abs(my - (ay + by) / 2) <= tolerance) {
239
+ rx.push(ax);
240
+ ry.push(ay);
241
+ return;
242
+ }
243
+ const dy = Math.abs(by - ay);
244
+ refine(ax, ay, mx, my, depth + 1, dy);
245
+ refine(mx, my, bx, by, depth + 1, dy);
246
+ };
247
+
248
+ const span = domain.max - domain.min;
249
+ let lx = domain.min;
250
+ let ly = g(lx);
251
+ for (let i = 1; i < baseResolution; i++) {
252
+ const x = i === baseResolution - 1 ? domain.max : domain.min + (i / (baseResolution - 1)) * span;
253
+ const y = g(x);
254
+ refine(lx, ly, x, y, 0, Number.POSITIVE_INFINITY);
255
+ lx = x;
256
+ ly = y;
257
+ }
258
+ rx.push(lx);
259
+ ry.push(ly);
260
+ return toSamples(rx, ry, range);
261
+ }
262
+
263
+ /** Half-open row ranges `[start, end)` of consecutive data rows, i.e. the polylines to draw. */
264
+ export function runsOf(samples: Samples): [number, number][] {
265
+ const runs: [number, number][] = [];
266
+ let start = -1;
267
+ for (let i = 0; i < samples.y.length; i++) {
268
+ if (Number.isNaN(samples.y[i])) {
269
+ if (start >= 0) runs.push([start, i]);
270
+ start = -1;
271
+ } else if (start < 0) {
272
+ start = i;
273
+ }
274
+ }
275
+ if (start >= 0) runs.push([start, samples.y.length]);
276
+ return runs;
277
+ }
278
+
279
+ /**
280
+ * The two edges of every gap: the last point before it and the first after.
281
+ * Where the curve really breaks lies between them; the distance is at most one
282
+ * sampling cell, so mark both edges rather than guessing a position.
283
+ */
284
+ export function findDiscontinuities(samples: Samples): { before: Point; after: Point }[] {
285
+ const out: { before: Point; after: Point }[] = [];
286
+ for (let i = 1; i < samples.y.length - 1; i++) {
287
+ if (!Number.isNaN(samples.y[i])) continue;
288
+ out.push({
289
+ before: { x: samples.x[i - 1], y: samples.y[i - 1] },
290
+ after: { x: samples.x[i + 1], y: samples.y[i + 1] },
291
+ });
292
+ }
293
+ return out;
294
+ }
295
+
296
+ export interface Point {
297
+ x: number;
298
+ y: number;
299
+ }
300
+
301
+ /**
302
+ * Where the polyline crosses a boundary. `signedDistance(y)` is negative on
303
+ * one side and non-negative on the other, and linear in y (`y` for the x-axis,
304
+ * `y - c` for the line y = c), so zero can be interpolated between two rows.
305
+ * Pairs across a gap never count.
306
+ */
307
+ export function findCrossings(samples: Samples, signedDistance: (y: number) => number): Point[] {
308
+ const out: Point[] = [];
309
+ for (const [start, end] of runsOf(samples)) {
310
+ for (let i = start + 1; i < end; i++) {
311
+ const d0 = signedDistance(samples.y[i - 1]);
312
+ const d1 = signedDistance(samples.y[i]);
313
+ if (d0 >= 0 === d1 >= 0) continue;
314
+ const t = d0 / (d0 - d1);
315
+ out.push({
316
+ x: samples.x[i - 1] + t * (samples.x[i] - samples.x[i - 1]),
317
+ y: samples.y[i - 1] + t * (samples.y[i] - samples.y[i - 1]),
318
+ });
319
+ }
320
+ }
321
+ return out;
322
+ }
323
+
324
+ export interface RootOptions extends SampleOptions {
325
+ /** Grid points scanned for sign changes. Default 1000. */
326
+ resolution?: number;
327
+ }
328
+
329
+ /**
330
+ * Roots of `f` on `domain`: scan a uniform grid for sign changes (or exact
331
+ * zeros) and polish each by bisection. Poles (sign change through infinity)
332
+ * are not roots and are rejected. Roots where `f` only touches zero without
333
+ * changing sign (such as x^2 at 0) fall between grid points and are missed.
334
+ */
335
+ export function findRoots(f: Fn1, domain: Domain, options: RootOptions = {}): number[] {
336
+ const n = options.resolution ?? 1000;
337
+ checkDomain(domain, n);
338
+ const g = guardedEval(f, options);
339
+ const span = domain.max - domain.min;
340
+ const roots: number[] = [];
341
+ let ax = domain.min;
342
+ let ay = g(ax);
343
+ if (ay === 0) roots.push(ax);
344
+ for (let i = 1; i < n; i++) {
345
+ const bx = i === n - 1 ? domain.max : domain.min + (i / (n - 1)) * span;
346
+ const by = g(bx);
347
+ if (by === 0) {
348
+ roots.push(bx);
349
+ } else if (Number.isFinite(ay) && Number.isFinite(by) && ay !== 0 && ay < 0 !== by < 0) {
350
+ let lo = ax;
351
+ let hi = bx;
352
+ let flo = ay;
353
+ for (let k = 0; k < 100; k++) {
354
+ const mid = (lo + hi) / 2;
355
+ if (mid === lo || mid === hi) break;
356
+ const fm = g(mid);
357
+ if (fm === 0) {
358
+ lo = hi = mid;
359
+ break;
360
+ }
361
+ if (flo < 0 === fm < 0) {
362
+ lo = mid;
363
+ flo = fm;
364
+ } else {
365
+ hi = mid;
366
+ }
367
+ }
368
+ const r = (lo + hi) / 2;
369
+ const fr = g(r);
370
+ if (Math.abs(fr) <= Math.max(Math.abs(ay), Math.abs(by))) roots.push(r);
371
+ }
372
+ ax = bx;
373
+ ay = by;
374
+ }
375
+ return roots;
376
+ }
377
+
378
+ /** Where `f` and `g` meet on `domain`: the roots of `f - g`, each with `y = f(x)`. Same caveats as {@link findRoots}. */
379
+ export function findIntersections(f: Fn1, g: Fn1, domain: Domain, options: RootOptions = {}): Point[] {
380
+ return findRoots((x) => f(x) - g(x), domain, options).map((x) => ({ x, y: f(x) }));
381
+ }
382
+
383
+ /** `[x, y]` pairs as plain arrays, with `y = null` at gaps (JSON-safe, unlike NaN). */
384
+ export function toPoints(samples: Samples): [number, number | null][] {
385
+ const out: [number, number | null][] = new Array(samples.x.length);
386
+ for (let i = 0; i < out.length; i++) {
387
+ const y = samples.y[i];
388
+ out[i] = [samples.x[i], Number.isNaN(y) ? null : y];
389
+ }
390
+ return out;
391
+ }
package/src/series.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { assertCount, assertRange } from "./budget.ts";
2
+ import type { Domain, Samples } from "./sample.ts";
3
+
4
+ /** Evaluate `sum a_k (x - x0)^k` for `k <= order` by Horner's rule. */
5
+ export function evaluateTaylor(
6
+ coefficients: ArrayLike<number>,
7
+ x0: number,
8
+ x: number,
9
+ order = coefficients.length - 1,
10
+ ): number {
11
+ const top = Math.min(order, coefficients.length - 1);
12
+ let acc = 0;
13
+ for (let k = top; k >= 0; k--) acc = acc * (x - x0) + (coefficients[k] as number);
14
+ return acc;
15
+ }
16
+
17
+ export interface PartialSumCurves {
18
+ /** The orders requested, ascending as given. */
19
+ orders: number[];
20
+ /** One `Samples` per order; `y` is `NaN` where the partial sum is not finite (gap rules as in 1D). */
21
+ curves: Samples[];
22
+ }
23
+
24
+ function cleanSamples(x: Float64Array, y: Float64Array): Samples {
25
+ // enforce: gaps not first/last/adjacent
26
+ const xs: number[] = [];
27
+ const ys: number[] = [];
28
+ for (let k = 0; k < x.length; k++) {
29
+ const yy = y[k] as number;
30
+ if (Number.isFinite(yy)) {
31
+ xs.push(x[k] as number);
32
+ ys.push(yy);
33
+ } else if (ys.length > 0 && !Number.isNaN(ys[ys.length - 1] as number)) {
34
+ xs.push(x[k] as number);
35
+ ys.push(Number.NaN);
36
+ }
37
+ }
38
+ while (ys.length > 0 && Number.isNaN(ys[ys.length - 1] as number)) {
39
+ ys.pop();
40
+ xs.pop();
41
+ }
42
+ return { x: Float64Array.from(xs), y: Float64Array.from(ys) };
43
+ }
44
+
45
+ function grid(domain: Domain, n: number): Float64Array {
46
+ assertRange("domain", domain);
47
+ assertCount("samples", n);
48
+ const x = new Float64Array(n);
49
+ for (let k = 0; k < n; k++) x[k] = k === n - 1 ? domain.max : domain.min + ((domain.max - domain.min) * k) / (n - 1);
50
+ return x;
51
+ }
52
+
53
+ /**
54
+ * Taylor partial sums of the given coefficients (`a_k = f^(k)(x0) / k!`) about `x0`, one curve per requested
55
+ * order, over a uniform grid. With `@johnhenry/math`, `taylorCoefficients` in `@johnhenry/math-sample/expr`
56
+ * produces the coefficients from an expression.
57
+ */
58
+ export function sampleTaylorPartialSums(
59
+ coefficients: ArrayLike<number>,
60
+ x0: number,
61
+ domain: Domain,
62
+ orders: number[],
63
+ samples = 400,
64
+ ): PartialSumCurves {
65
+ const x = grid(domain, samples);
66
+ const curves = orders.map((order) => {
67
+ if (!Number.isInteger(order) || order < 0)
68
+ throw new RangeError(`order must be a non-negative integer, got ${order}`);
69
+ const y = new Float64Array(samples);
70
+ for (let k = 0; k < samples; k++) y[k] = evaluateTaylor(coefficients, x0, x[k] as number, order);
71
+ return cleanSamples(x, y);
72
+ });
73
+ return { orders: [...orders], curves };
74
+ }
75
+
76
+ /**
77
+ * Partial sums of a function series `sum_{k=from}^{n} term(k, x)`, one curve per order `n`, computed
78
+ * in a single pass over `k` so each term is evaluated once per sample.
79
+ */
80
+ export function sampleSeriesPartialSums(
81
+ term: (k: number, x: number) => number,
82
+ from: number,
83
+ domain: Domain,
84
+ orders: number[],
85
+ samples = 400,
86
+ ): PartialSumCurves {
87
+ if (!Number.isInteger(from)) throw new RangeError("from must be an integer");
88
+ const x = grid(domain, samples);
89
+ const sorted = [...new Set(orders)].sort((a, b) => a - b);
90
+ for (const o of sorted)
91
+ if (!Number.isInteger(o) || o < from) throw new RangeError(`order ${o} must be an integer >= from (${from})`);
92
+ const sums = new Float64Array(samples);
93
+ const byOrder = new Map<number, Samples>();
94
+ let k = from;
95
+ for (const o of sorted) {
96
+ for (; k <= o; k++) for (let i = 0; i < samples; i++) sums[i] = (sums[i] as number) + term(k, x[i] as number);
97
+ byOrder.set(o, cleanSamples(x, Float64Array.from(sums)));
98
+ }
99
+ return { orders: [...orders], curves: orders.map((o) => byOrder.get(o) as Samples) };
100
+ }
101
+
102
+ export interface NumericSeries {
103
+ /** `partial[n - from]` is the sum of terms `from..n`. */
104
+ partial: Float64Array;
105
+ from: number;
106
+ /** Aitken delta-squared extrapolation of the last three partial sums; `NaN` when it cannot be formed. A hint, not a proof of convergence. */
107
+ limitEstimate: number;
108
+ }
109
+
110
+ /** Partial sums of the numeric series `sum_{k=from}^{to} term(k)`, with an Aitken limit estimate. */
111
+ export function partialSums(term: (k: number) => number, from: number, to: number): NumericSeries {
112
+ if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) throw new RangeError("need integers from <= to");
113
+ const partial = new Float64Array(to - from + 1);
114
+ let s = 0;
115
+ for (let k = from; k <= to; k++) {
116
+ s += term(k);
117
+ partial[k - from] = s;
118
+ }
119
+ let limitEstimate = Number.NaN;
120
+ const n = partial.length;
121
+ if (n >= 3) {
122
+ const a = partial[n - 3] as number;
123
+ const b = partial[n - 2] as number;
124
+ const c = partial[n - 1] as number;
125
+ const den = c - 2 * b + a;
126
+ limitEstimate = den === 0 ? c : c - ((c - b) * (c - b)) / den;
127
+ }
128
+ return { partial, from, limitEstimate };
129
+ }
130
+
131
+ export interface FourierCoefficients {
132
+ /** Half-period `L`: the function has period `2L`. */
133
+ halfPeriod: number;
134
+ /** The centre of the period interval used `[center - L, center + L]`. */
135
+ center: number;
136
+ /** Constant term `a0 / 2` is `a0 / 2`; `a0` is `(1/L) * integral f`. */
137
+ a0: number;
138
+ /** `a[k - 1]` multiplies `cos(k pi (x - center) / L)`. */
139
+ a: Float64Array;
140
+ /** `b[k - 1]` multiplies `sin(k pi (x - center) / L)`. */
141
+ b: Float64Array;
142
+ }
143
+
144
+ /**
145
+ * Fourier coefficients of `f` on `[a, b]` taken as one period, by the midpoint rule on `samples` points
146
+ * (exact for trig polynomials below the Nyquist order, being a full-period rule; for a function with jumps the coefficients carry
147
+ * ordinary O(1/samples) quadrature error). `terms` is the highest harmonic. Trig form:
148
+ * `a0/2 + sum a_k cos(k w x) + b_k sin(k w x)` about the interval centre.
149
+ */
150
+ export function fourierCoefficients(
151
+ f: (x: number) => number,
152
+ period: Domain,
153
+ terms: number,
154
+ samples = 4096,
155
+ ): FourierCoefficients {
156
+ assertRange("period", period);
157
+ assertCount("terms", terms, 1);
158
+ assertCount("samples", samples, 4);
159
+ const L = (period.max - period.min) / 2;
160
+ const center = (period.max + period.min) / 2;
161
+ const a = new Float64Array(terms);
162
+ const b = new Float64Array(terms);
163
+ const vals = new Float64Array(samples);
164
+ for (let i = 0; i < samples; i++) vals[i] = f(period.min + ((period.max - period.min) * (i + 0.5)) / samples); // midpoint
165
+ let a0 = 0;
166
+ for (let i = 0; i < samples; i++) a0 += vals[i] as number;
167
+ a0 = (2 * a0) / samples;
168
+ for (let k = 1; k <= terms; k++) {
169
+ let sa = 0;
170
+ let sb = 0;
171
+ for (let i = 0; i < samples; i++) {
172
+ const t = Math.PI * k * (((i + 0.5) / samples) * 2 - 1);
173
+ sa += (vals[i] as number) * Math.cos(t);
174
+ sb += (vals[i] as number) * Math.sin(t);
175
+ }
176
+ a[k - 1] = (2 * sa) / samples;
177
+ b[k - 1] = (2 * sb) / samples;
178
+ }
179
+ return { halfPeriod: L, center, a0, a, b };
180
+ }
181
+
182
+ /** Value of the Fourier partial sum with harmonics up to `n` at `x`. */
183
+ export function evaluateFourier(c: FourierCoefficients, x: number, n = c.a.length): number {
184
+ const top = Math.min(n, c.a.length);
185
+ const w = Math.PI / c.halfPeriod;
186
+ let s = c.a0 / 2;
187
+ for (let k = 1; k <= top; k++) {
188
+ const t = k * w * (x - c.center);
189
+ s += (c.a[k - 1] as number) * Math.cos(t) + (c.b[k - 1] as number) * Math.sin(t);
190
+ }
191
+ return s;
192
+ }
193
+
194
+ /** One curve per harmonic count in `orders` (0 = just the constant term). */
195
+ export function sampleFourierPartialSums(
196
+ c: FourierCoefficients,
197
+ domain: Domain,
198
+ orders: number[],
199
+ samples = 400,
200
+ ): PartialSumCurves {
201
+ const x = grid(domain, samples);
202
+ const curves = orders.map((n) => {
203
+ if (!Number.isInteger(n) || n < 0) throw new RangeError(`order must be a non-negative integer, got ${n}`);
204
+ const y = new Float64Array(samples);
205
+ for (let i = 0; i < samples; i++) y[i] = evaluateFourier(c, x[i] as number, n);
206
+ return cleanSamples(x, y);
207
+ });
208
+ return { orders: [...orders], curves };
209
+ }