@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.
- package/README.md +244 -2
- package/dist/budget.d.ts +47 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +44 -0
- package/dist/budget.js.map +1 -0
- package/dist/complex.d.ts +79 -0
- package/dist/complex.d.ts.map +1 -0
- package/dist/complex.js +236 -0
- package/dist/complex.js.map +1 -0
- package/dist/expr.d.ts +38 -0
- package/dist/expr.d.ts.map +1 -0
- package/dist/expr.js +264 -0
- package/dist/expr.js.map +1 -0
- package/dist/field2d.d.ts +58 -0
- package/dist/field2d.d.ts.map +1 -0
- package/dist/field2d.js +199 -0
- package/dist/field2d.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/mesh.d.ts +90 -0
- package/dist/mesh.d.ts.map +1 -0
- package/dist/mesh.js +294 -0
- package/dist/mesh.js.map +1 -0
- package/dist/ode.d.ts +147 -0
- package/dist/ode.d.ts.map +1 -0
- package/dist/ode.js +466 -0
- package/dist/ode.js.map +1 -0
- package/dist/sample.d.ts +114 -0
- package/dist/sample.d.ts.map +1 -0
- package/dist/sample.js +308 -0
- package/dist/sample.js.map +1 -0
- package/dist/series.d.ts +53 -0
- package/dist/series.d.ts.map +1 -0
- package/dist/series.js +158 -0
- package/dist/series.js.map +1 -0
- package/dist/svg.d.ts +37 -0
- package/dist/svg.d.ts.map +1 -0
- package/dist/svg.js +82 -0
- package/dist/svg.js.map +1 -0
- package/dist/ticks.d.ts +17 -0
- package/dist/ticks.d.ts.map +1 -0
- package/dist/ticks.js +47 -0
- package/dist/ticks.js.map +1 -0
- package/dist/viewport.d.ts +59 -0
- package/dist/viewport.d.ts.map +1 -0
- package/dist/viewport.js +111 -0
- package/dist/viewport.js.map +1 -0
- package/package.json +70 -4
- package/src/budget.ts +72 -0
- package/src/complex.ts +327 -0
- package/src/expr.ts +283 -0
- package/src/field2d.ts +259 -0
- package/src/index.ts +112 -0
- package/src/mesh.ts +376 -0
- package/src/ode.ts +633 -0
- package/src/sample.ts +391 -0
- package/src/series.ts +209 -0
- package/src/svg.ts +124 -0
- package/src/ticks.ts +46 -0
- package/src/viewport.ts +150 -0
package/src/ode.ts
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { assertCount, assertRange, type Budget, type Cancellable, Meter, type Progress, progress } from "./budget.ts";
|
|
2
|
+
import { type Domain, SampleCancelledError, type Samples } from "./sample.ts";
|
|
3
|
+
|
|
4
|
+
/** `dy/dx = f(x, y)` for a scalar equation. */
|
|
5
|
+
export type Slope = (x: number, y: number) => number;
|
|
6
|
+
/** `dy/dt = f(t, y)` for a system; returns a new array (or the same-length output) of derivatives. */
|
|
7
|
+
export type System = (t: number, y: readonly number[]) => readonly number[];
|
|
8
|
+
/** Autonomous planar system `[dx/dt, dy/dt] = F(x, y)`. */
|
|
9
|
+
export type Field2 = (x: number, y: number) => readonly [number, number];
|
|
10
|
+
|
|
11
|
+
export interface SlopeField {
|
|
12
|
+
columns: number;
|
|
13
|
+
rows: number;
|
|
14
|
+
/** Grid points (cell centres when `centered`, else grid nodes), `columns * rows` each, row-major from the smallest y. */
|
|
15
|
+
x: Float64Array;
|
|
16
|
+
y: Float64Array;
|
|
17
|
+
slope: Float64Array;
|
|
18
|
+
/**
|
|
19
|
+
* Unit direction `(dirX, dirY)` of each segment in data space (`(1, m) / |(1, m)|`; a vertical slope `+-Infinity`
|
|
20
|
+
* is `(0, +-1)`). `NaN` where the slope is `NaN` (undefined there).
|
|
21
|
+
*/
|
|
22
|
+
dirX: Float64Array;
|
|
23
|
+
dirY: Float64Array;
|
|
24
|
+
progress: Progress;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SlopeFieldOptions extends Budget, Cancellable {
|
|
28
|
+
columns?: number;
|
|
29
|
+
rows?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Evaluate the slope field of `dy/dx = f(x, y)` on a `columns` x `rows` grid including both edges. */
|
|
33
|
+
export function sampleSlopeField(
|
|
34
|
+
f: Slope,
|
|
35
|
+
xDomain: Domain,
|
|
36
|
+
yDomain: Domain,
|
|
37
|
+
options: SlopeFieldOptions = {},
|
|
38
|
+
): SlopeField {
|
|
39
|
+
const columns = options.columns ?? 20;
|
|
40
|
+
const rows = options.rows ?? 20;
|
|
41
|
+
assertCount("columns", columns);
|
|
42
|
+
assertCount("rows", rows);
|
|
43
|
+
assertRange("x domain", xDomain);
|
|
44
|
+
assertRange("y domain", yDomain);
|
|
45
|
+
const meter = new Meter(options, options);
|
|
46
|
+
const n = columns * rows;
|
|
47
|
+
const x = new Float64Array(n);
|
|
48
|
+
const y = new Float64Array(n);
|
|
49
|
+
const slope = new Float64Array(n).fill(Number.NaN);
|
|
50
|
+
const dirX = new Float64Array(n).fill(Number.NaN);
|
|
51
|
+
const dirY = new Float64Array(n).fill(Number.NaN);
|
|
52
|
+
let done = 0;
|
|
53
|
+
let reason: "cells" | "time" | undefined;
|
|
54
|
+
for (let j = 0; j < rows; j++) {
|
|
55
|
+
reason = meter.check(columns);
|
|
56
|
+
if (reason) break;
|
|
57
|
+
const yy = j === rows - 1 ? yDomain.max : yDomain.min + ((yDomain.max - yDomain.min) * j) / (rows - 1);
|
|
58
|
+
for (let i = 0; i < columns; i++) {
|
|
59
|
+
const xx = i === columns - 1 ? xDomain.max : xDomain.min + ((xDomain.max - xDomain.min) * i) / (columns - 1);
|
|
60
|
+
const k = j * columns + i;
|
|
61
|
+
x[k] = xx;
|
|
62
|
+
y[k] = yy;
|
|
63
|
+
const m = f(xx, yy);
|
|
64
|
+
slope[k] = m;
|
|
65
|
+
if (Number.isNaN(m)) continue;
|
|
66
|
+
if (!Number.isFinite(m)) {
|
|
67
|
+
dirX[k] = 0;
|
|
68
|
+
dirY[k] = m > 0 ? 1 : -1;
|
|
69
|
+
} else {
|
|
70
|
+
const len = Math.hypot(1, m);
|
|
71
|
+
dirX[k] = 1 / len;
|
|
72
|
+
dirY[k] = m / len;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
meter.spend(columns);
|
|
76
|
+
done++;
|
|
77
|
+
}
|
|
78
|
+
const m = columns * done;
|
|
79
|
+
return {
|
|
80
|
+
columns,
|
|
81
|
+
rows: done,
|
|
82
|
+
x: x.slice(0, m),
|
|
83
|
+
y: y.slice(0, m),
|
|
84
|
+
slope: slope.slice(0, m),
|
|
85
|
+
dirX: dirX.slice(0, m),
|
|
86
|
+
dirY: dirY.slice(0, m),
|
|
87
|
+
progress: progress(done, rows, reason),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type StopReason = "end" | "blowup" | "maxSteps" | "stopped" | "stepTooSmall";
|
|
92
|
+
|
|
93
|
+
export interface Trajectory {
|
|
94
|
+
/** Times of the accepted steps, including the start. */
|
|
95
|
+
t: Float64Array;
|
|
96
|
+
/** `steps * dim` states, row-major. */
|
|
97
|
+
y: Float64Array;
|
|
98
|
+
dim: number;
|
|
99
|
+
steps: number;
|
|
100
|
+
reason: StopReason;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface OdeOptions extends Cancellable {
|
|
104
|
+
/** Stop (reason `"stopped"`) when this returns true for an accepted state. */
|
|
105
|
+
stop?: (t: number, y: readonly number[]) => boolean;
|
|
106
|
+
/** Abort with `"blowup"` when any component is non-finite or its magnitude exceeds this. Default 1e12. */
|
|
107
|
+
blowup?: number;
|
|
108
|
+
/** Hard cap on accepted steps. Default 100000. */
|
|
109
|
+
maxSteps?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface Rk4Options extends OdeOptions {
|
|
113
|
+
/** Number of equal steps from `t0` to `tEnd`. Default 200. */
|
|
114
|
+
steps?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface AdaptiveOdeOptions extends OdeOptions {
|
|
118
|
+
/** Absolute tolerance per step. Default 1e-8. */
|
|
119
|
+
atol?: number;
|
|
120
|
+
/** Relative tolerance per step. Default 1e-6. */
|
|
121
|
+
rtol?: number;
|
|
122
|
+
/** First step size; default `|tEnd - t0| / 100`. */
|
|
123
|
+
h0?: number;
|
|
124
|
+
/** Smallest step; below it the integration stops with `"stepTooSmall"`. Default `|tEnd - t0| * 1e-12`. */
|
|
125
|
+
hMin?: number;
|
|
126
|
+
/** Largest step. Default `|tEnd - t0|`. */
|
|
127
|
+
hMax?: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function bad(y: readonly number[], limit: number): boolean {
|
|
131
|
+
for (const v of y) if (!Number.isFinite(v) || Math.abs(v) > limit) return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function collect(ts: number[], ys: number[], dim: number, reason: StopReason): Trajectory {
|
|
136
|
+
return { t: Float64Array.from(ts), y: Float64Array.from(ys), dim, steps: ts.length, reason };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function check(opts: OdeOptions): void {
|
|
140
|
+
if (opts.signal?.aborted || opts.isCancelled?.()) throw new SampleCancelledError();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One classical RK4 step. */
|
|
144
|
+
function rk4Step(f: System, t: number, y: readonly number[], h: number): number[] {
|
|
145
|
+
const n = y.length;
|
|
146
|
+
const k1 = f(t, y);
|
|
147
|
+
const k2 = f(
|
|
148
|
+
t + h / 2,
|
|
149
|
+
y.map((v, i) => v + (h / 2) * (k1[i] as number)),
|
|
150
|
+
);
|
|
151
|
+
const k3 = f(
|
|
152
|
+
t + h / 2,
|
|
153
|
+
y.map((v, i) => v + (h / 2) * (k2[i] as number)),
|
|
154
|
+
);
|
|
155
|
+
const k4 = f(
|
|
156
|
+
t + h,
|
|
157
|
+
y.map((v, i) => v + h * (k3[i] as number)),
|
|
158
|
+
);
|
|
159
|
+
const out = new Array<number>(n);
|
|
160
|
+
for (let i = 0; i < n; i++)
|
|
161
|
+
out[i] =
|
|
162
|
+
(y[i] as number) +
|
|
163
|
+
(h / 6) * ((k1[i] as number) + 2 * (k2[i] as number) + 2 * (k3[i] as number) + (k4[i] as number));
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Fixed-step classical Runge-Kutta (order 4) for a system `y' = f(t, y)` from `t0` to `tEnd` (which may be
|
|
169
|
+
* smaller than `t0`: integrates backward). Stops early, with a reason, on blow-up or `options.stop`.
|
|
170
|
+
*/
|
|
171
|
+
export function integrateRk4(
|
|
172
|
+
f: System,
|
|
173
|
+
t0: number,
|
|
174
|
+
y0: readonly number[],
|
|
175
|
+
tEnd: number,
|
|
176
|
+
options: Rk4Options = {},
|
|
177
|
+
): Trajectory {
|
|
178
|
+
const steps = options.steps ?? 200;
|
|
179
|
+
assertCount("steps", steps, 1);
|
|
180
|
+
if (!Number.isFinite(t0) || !Number.isFinite(tEnd)) throw new RangeError("t0 and tEnd must be finite");
|
|
181
|
+
const limit = options.blowup ?? 1e12;
|
|
182
|
+
const maxSteps = Math.max(options.maxSteps ?? 100000, steps + 1); // never truncate a run the caller asked for
|
|
183
|
+
const dim = y0.length;
|
|
184
|
+
const h = (tEnd - t0) / steps;
|
|
185
|
+
const ts = [t0];
|
|
186
|
+
const ys = [...y0];
|
|
187
|
+
let y: readonly number[] = y0;
|
|
188
|
+
let reason: StopReason = "end";
|
|
189
|
+
for (let k = 0; k < steps; k++) {
|
|
190
|
+
if (ts.length >= maxSteps) {
|
|
191
|
+
reason = "maxSteps";
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
if (k % 64 === 0) check(options);
|
|
195
|
+
const t = t0 + k * h;
|
|
196
|
+
const next = rk4Step(f, t, y, h);
|
|
197
|
+
if (bad(next, limit)) {
|
|
198
|
+
reason = "blowup";
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
y = next;
|
|
202
|
+
const tn = k === steps - 1 ? tEnd : t0 + (k + 1) * h;
|
|
203
|
+
ts.push(tn);
|
|
204
|
+
ys.push(...y);
|
|
205
|
+
if (options.stop?.(tn, y)) {
|
|
206
|
+
reason = "stopped";
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return collect(ts, ys, dim, reason);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Dormand-Prince 5(4)
|
|
214
|
+
const C = [0, 1 / 5, 3 / 10, 4 / 5, 8 / 9, 1, 1];
|
|
215
|
+
const A: number[][] = [
|
|
216
|
+
[],
|
|
217
|
+
[1 / 5],
|
|
218
|
+
[3 / 40, 9 / 40],
|
|
219
|
+
[44 / 45, -56 / 15, 32 / 9],
|
|
220
|
+
[19372 / 6561, -25360 / 2187, 64448 / 6561, -212 / 729],
|
|
221
|
+
[9017 / 3168, -355 / 33, 46732 / 5247, 49 / 176, -5103 / 18656],
|
|
222
|
+
[35 / 384, 0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84],
|
|
223
|
+
];
|
|
224
|
+
const B5 = [35 / 384, 0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84, 0];
|
|
225
|
+
const B4 = [5179 / 57600, 0, 7571 / 16695, 393 / 640, -92097 / 339200, 187 / 2100, 1 / 40];
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Adaptive Dormand-Prince (RK45) with error control, forward or backward in `t`. Stops with `"blowup"` near a
|
|
229
|
+
* finite-time singularity, or `"stepTooSmall"` when the step would fall below `hMin`.
|
|
230
|
+
*/
|
|
231
|
+
export function integrateAdaptive(
|
|
232
|
+
f: System,
|
|
233
|
+
t0: number,
|
|
234
|
+
y0: readonly number[],
|
|
235
|
+
tEnd: number,
|
|
236
|
+
options: AdaptiveOdeOptions = {},
|
|
237
|
+
): Trajectory {
|
|
238
|
+
if (!Number.isFinite(t0) || !Number.isFinite(tEnd)) throw new RangeError("t0 and tEnd must be finite");
|
|
239
|
+
const span = Math.abs(tEnd - t0);
|
|
240
|
+
const dir = tEnd >= t0 ? 1 : -1;
|
|
241
|
+
const dim = y0.length;
|
|
242
|
+
const atol = options.atol ?? 1e-8;
|
|
243
|
+
const rtol = options.rtol ?? 1e-6;
|
|
244
|
+
const limit = options.blowup ?? 1e12;
|
|
245
|
+
const maxSteps = options.maxSteps ?? 100000;
|
|
246
|
+
const hMin = options.hMin ?? span * 1e-12;
|
|
247
|
+
const hMax = options.hMax ?? span;
|
|
248
|
+
let h = Math.min(options.h0 ?? span / 100, hMax);
|
|
249
|
+
const ts = [t0];
|
|
250
|
+
const ys = [...y0];
|
|
251
|
+
if (span === 0) return collect(ts, ys, dim, "end");
|
|
252
|
+
let t = t0;
|
|
253
|
+
let y: readonly number[] = y0;
|
|
254
|
+
let reason: StopReason = "end";
|
|
255
|
+
let iterations = 0;
|
|
256
|
+
let badSteps = 0;
|
|
257
|
+
let sinceBad = Number.POSITIVE_INFINITY;
|
|
258
|
+
while (dir * (tEnd - t) > 0) {
|
|
259
|
+
if (ts.length >= maxSteps || iterations++ > maxSteps * 20) {
|
|
260
|
+
reason = "maxSteps";
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
if (iterations % 64 === 0) check(options);
|
|
264
|
+
if (h < hMin) {
|
|
265
|
+
reason = "stepTooSmall";
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
const last = dir * (t + dir * h - tEnd) >= 0;
|
|
269
|
+
const hh = last ? Math.abs(tEnd - t) : h;
|
|
270
|
+
const step = dir * hh;
|
|
271
|
+
const k: Array<readonly number[]> = [];
|
|
272
|
+
for (let s = 0; s < 7; s++) {
|
|
273
|
+
const arg = y.map((v, i) => {
|
|
274
|
+
let acc = 0;
|
|
275
|
+
for (let m = 0; m < s; m++) acc += (A[s]?.[m] as number) * ((k[m] as readonly number[])[i] as number);
|
|
276
|
+
return v + step * acc;
|
|
277
|
+
});
|
|
278
|
+
k.push(f(t + (C[s] as number) * step, arg));
|
|
279
|
+
}
|
|
280
|
+
const y5 = y.map(
|
|
281
|
+
(v, i) => v + step * B5.reduce((acc, b, s) => acc + b * ((k[s] as readonly number[])[i] as number), 0),
|
|
282
|
+
);
|
|
283
|
+
const y4 = y.map(
|
|
284
|
+
(v, i) => v + step * B4.reduce((acc, b, s) => acc + b * ((k[s] as readonly number[])[i] as number), 0),
|
|
285
|
+
);
|
|
286
|
+
if (bad(y5, limit)) {
|
|
287
|
+
// may be a bad step rather than a blow-up: try smaller before giving up
|
|
288
|
+
// bad steps count only while they recur within 10 accepted steps (a Zeno approach to the limit);
|
|
289
|
+
// isolated ones over a long run are forgiven
|
|
290
|
+
if (sinceBad > 10) badSteps = 0;
|
|
291
|
+
sinceBad = 0;
|
|
292
|
+
if (++badSteps > 12 || hh <= hMin * 2) {
|
|
293
|
+
reason = "blowup";
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
h = hh / 4;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
let err = 0;
|
|
300
|
+
for (let i = 0; i < dim; i++) {
|
|
301
|
+
const sc = atol + rtol * Math.max(Math.abs(y[i] as number), Math.abs(y5[i] as number));
|
|
302
|
+
err = Math.max(err, Math.abs((y5[i] as number) - (y4[i] as number)) / sc);
|
|
303
|
+
}
|
|
304
|
+
if (err <= 1 || hh <= hMin) {
|
|
305
|
+
t = last ? tEnd : t + step;
|
|
306
|
+
y = y5;
|
|
307
|
+
sinceBad++;
|
|
308
|
+
ts.push(t);
|
|
309
|
+
ys.push(...y);
|
|
310
|
+
if (options.stop?.(t, y)) {
|
|
311
|
+
reason = "stopped";
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const factor = err === 0 ? 5 : Math.min(5, Math.max(0.2, 0.9 * err ** -0.2));
|
|
316
|
+
h = Math.min(hMax, hh * factor);
|
|
317
|
+
}
|
|
318
|
+
return collect(ts, ys, dim, reason);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface SolutionCurveOptions extends AdaptiveOdeOptions {
|
|
322
|
+
/** `"adaptive"` (default) or `"rk4"` with `steps` per direction. */
|
|
323
|
+
method?: "adaptive" | "rk4";
|
|
324
|
+
steps?: number;
|
|
325
|
+
/** Curve points with |y| beyond this end the curve (a blow-up asymptote). Default 1e6. */
|
|
326
|
+
yLimit?: number;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The solution curve of `dy/dx = f(x, y)` through `(x0, y0)`, extended both ways across `xDomain`, as
|
|
331
|
+
* `Samples` (x ascending). It ends where the solution blows up, so a vertical asymptote leaves a clean
|
|
332
|
+
* end, not a spike. `reasonBefore` / `reasonAfter` say why each side stopped.
|
|
333
|
+
*/
|
|
334
|
+
export function sampleSolutionCurve(
|
|
335
|
+
f: Slope,
|
|
336
|
+
x0: number,
|
|
337
|
+
y0: number,
|
|
338
|
+
xDomain: Domain,
|
|
339
|
+
options: SolutionCurveOptions = {},
|
|
340
|
+
): Samples & { reasonBefore: StopReason; reasonAfter: StopReason } {
|
|
341
|
+
assertRange("x domain", xDomain);
|
|
342
|
+
const sys: System = (t, y) => [f(t, y[0] as number)];
|
|
343
|
+
const limit = options.yLimit ?? 1e6;
|
|
344
|
+
const run = (end: number) => {
|
|
345
|
+
const o = { ...options, blowup: limit };
|
|
346
|
+
return options.method === "rk4"
|
|
347
|
+
? integrateRk4(sys, x0, [y0], end, { ...o, steps: options.steps ?? 200 })
|
|
348
|
+
: integrateAdaptive(sys, x0, [y0], end, o);
|
|
349
|
+
};
|
|
350
|
+
const xs: number[] = [];
|
|
351
|
+
const ys: number[] = [];
|
|
352
|
+
let reasonBefore: StopReason = "end";
|
|
353
|
+
let reasonAfter: StopReason = "end";
|
|
354
|
+
// A start outside the window is first carried to the nearest edge, so the curve begins exactly at the edge.
|
|
355
|
+
if (x0 < xDomain.min || x0 > xDomain.max) {
|
|
356
|
+
const edge = x0 < xDomain.min ? xDomain.min : xDomain.max;
|
|
357
|
+
const toEdge = run(edge);
|
|
358
|
+
if (toEdge.reason !== "end") {
|
|
359
|
+
const r = toEdge.reason;
|
|
360
|
+
return { x: new Float64Array(0), y: new Float64Array(0), reasonBefore: r, reasonAfter: r };
|
|
361
|
+
}
|
|
362
|
+
x0 = edge;
|
|
363
|
+
y0 = toEdge.y[toEdge.steps - 1] as number;
|
|
364
|
+
}
|
|
365
|
+
if (x0 > xDomain.min) {
|
|
366
|
+
const back = run(xDomain.min);
|
|
367
|
+
reasonBefore = back.reason;
|
|
368
|
+
for (let k = back.steps - 1; k >= 1; k--) {
|
|
369
|
+
xs.push(back.t[k] as number);
|
|
370
|
+
ys.push(back.y[k] as number);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
xs.push(x0);
|
|
374
|
+
ys.push(y0);
|
|
375
|
+
if (x0 < xDomain.max) {
|
|
376
|
+
const fwd = run(xDomain.max);
|
|
377
|
+
reasonAfter = fwd.reason;
|
|
378
|
+
for (let k = 1; k < fwd.steps; k++) {
|
|
379
|
+
xs.push(fwd.t[k] as number);
|
|
380
|
+
ys.push(fwd.y[k] as number);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { x: Float64Array.from(xs), y: Float64Array.from(ys), reasonBefore, reasonAfter };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export type FixedPointType =
|
|
387
|
+
| "stable node"
|
|
388
|
+
| "unstable node"
|
|
389
|
+
| "saddle"
|
|
390
|
+
| "stable spiral"
|
|
391
|
+
| "unstable spiral"
|
|
392
|
+
| "center"
|
|
393
|
+
| "stable star or degenerate node"
|
|
394
|
+
| "unstable star or degenerate node"
|
|
395
|
+
| "non-hyperbolic";
|
|
396
|
+
|
|
397
|
+
export interface FixedPoint {
|
|
398
|
+
x: number;
|
|
399
|
+
y: number;
|
|
400
|
+
/** `[[dFx/dx, dFx/dy], [dFy/dx, dFy/dy]]` by central differences. */
|
|
401
|
+
jacobian: [[number, number], [number, number]];
|
|
402
|
+
trace: number;
|
|
403
|
+
determinant: number;
|
|
404
|
+
/** Eigenvalues as `[re, im]` pairs (complex conjugates for a spiral or center). */
|
|
405
|
+
eigenvalues: [[number, number], [number, number]];
|
|
406
|
+
type: FixedPointType;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Numerical Jacobian of a planar field at a point, by central differences. */
|
|
410
|
+
export function jacobian2(F: Field2, x: number, y: number): [[number, number], [number, number]] {
|
|
411
|
+
const h = 1e-6 * Math.max(1, Math.abs(x), Math.abs(y));
|
|
412
|
+
const xp = F(x + h, y);
|
|
413
|
+
const xm = F(x - h, y);
|
|
414
|
+
const yp = F(x, y + h);
|
|
415
|
+
const ym = F(x, y - h);
|
|
416
|
+
return [
|
|
417
|
+
[(xp[0] - xm[0]) / (2 * h), (yp[0] - ym[0]) / (2 * h)],
|
|
418
|
+
[(xp[1] - xm[1]) / (2 * h), (yp[1] - ym[1]) / (2 * h)],
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Classify an equilibrium from its 2x2 Jacobian (linearisation; `tolerance` is the zero threshold, default 1e-7). */
|
|
423
|
+
export function classifyFixedPoint(
|
|
424
|
+
J: [[number, number], [number, number]],
|
|
425
|
+
tolerance = 1e-7,
|
|
426
|
+
): Pick<FixedPoint, "trace" | "determinant" | "eigenvalues" | "type"> {
|
|
427
|
+
const tr = J[0][0] + J[1][1];
|
|
428
|
+
const det = J[0][0] * J[1][1] - J[0][1] * J[1][0];
|
|
429
|
+
const disc = tr * tr - 4 * det;
|
|
430
|
+
const scale = Math.max(1, Math.abs(tr), Math.abs(det));
|
|
431
|
+
let eigenvalues: [[number, number], [number, number]];
|
|
432
|
+
if (disc >= 0) {
|
|
433
|
+
const s = Math.sqrt(disc);
|
|
434
|
+
eigenvalues = [
|
|
435
|
+
[(tr + s) / 2, 0],
|
|
436
|
+
[(tr - s) / 2, 0],
|
|
437
|
+
];
|
|
438
|
+
} else {
|
|
439
|
+
const s = Math.sqrt(-disc) / 2;
|
|
440
|
+
eigenvalues = [
|
|
441
|
+
[tr / 2, s],
|
|
442
|
+
[tr / 2, -s],
|
|
443
|
+
];
|
|
444
|
+
}
|
|
445
|
+
let type: FixedPointType;
|
|
446
|
+
if (Math.abs(det) <= tolerance * scale) type = "non-hyperbolic";
|
|
447
|
+
else if (det < 0) type = "saddle";
|
|
448
|
+
else if (Math.abs(tr) <= tolerance * scale) type = "center";
|
|
449
|
+
else if (disc > tolerance * scale * scale) type = tr < 0 ? "stable node" : "unstable node";
|
|
450
|
+
else if (disc >= -tolerance * scale * scale)
|
|
451
|
+
type = tr < 0 ? "stable star or degenerate node" : "unstable star or degenerate node";
|
|
452
|
+
else type = tr < 0 ? "stable spiral" : "unstable spiral";
|
|
453
|
+
return { trace: tr, determinant: det, eigenvalues, type };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export interface PhasePortraitOptions extends Budget, Cancellable {
|
|
457
|
+
/** Field arrows per axis (`[columns, rows]` or one number). Default 15. */
|
|
458
|
+
grid?: number | [number, number];
|
|
459
|
+
/** Trajectory start points. Default: a 5 x 5 interior grid. */
|
|
460
|
+
seeds?: Array<[number, number]>;
|
|
461
|
+
/** Integrate each seed forward to `+tMax` and backward to `-tMax`. Default: 10. */
|
|
462
|
+
tMax?: number;
|
|
463
|
+
/** Search for fixed points with Newton from this many starts per axis. Default 8. 0 disables. */
|
|
464
|
+
fixedPointStarts?: number;
|
|
465
|
+
/** Trajectories are cut when they leave the window grown by this factor on each side. Default 0.5. */
|
|
466
|
+
margin?: number;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export interface PhasePortrait {
|
|
470
|
+
field: {
|
|
471
|
+
columns: number;
|
|
472
|
+
rows: number;
|
|
473
|
+
x: Float64Array;
|
|
474
|
+
y: Float64Array;
|
|
475
|
+
/** The vector at each node; `NaN` where `F` is non-finite. */
|
|
476
|
+
u: Float64Array;
|
|
477
|
+
v: Float64Array;
|
|
478
|
+
magnitude: Float64Array;
|
|
479
|
+
};
|
|
480
|
+
/** One entry per seed: backward half reversed then forward half, joined at the seed. */
|
|
481
|
+
trajectories: Array<{
|
|
482
|
+
seed: [number, number];
|
|
483
|
+
x: Float64Array;
|
|
484
|
+
y: Float64Array;
|
|
485
|
+
reasonBefore: StopReason;
|
|
486
|
+
reasonAfter: StopReason;
|
|
487
|
+
}>;
|
|
488
|
+
fixedPoints: FixedPoint[];
|
|
489
|
+
progress: Progress;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Phase portrait of an autonomous planar system: the vector field on a grid, trajectories from seed points
|
|
494
|
+
* (forward and backward, cut at the window's margin or at blow-up), and fixed points classified from the Jacobian.
|
|
495
|
+
* With a budget, `progress.rows` counts field rows and the trajectories and fixed points are only computed
|
|
496
|
+
* when the field finished.
|
|
497
|
+
*/
|
|
498
|
+
export function samplePhasePortrait(
|
|
499
|
+
F: Field2,
|
|
500
|
+
xDomain: Domain,
|
|
501
|
+
yDomain: Domain,
|
|
502
|
+
options: PhasePortraitOptions = {},
|
|
503
|
+
): PhasePortrait {
|
|
504
|
+
assertRange("x domain", xDomain);
|
|
505
|
+
assertRange("y domain", yDomain);
|
|
506
|
+
const g = options.grid ?? 15;
|
|
507
|
+
const [columns, rows] = typeof g === "number" ? [g, g] : g;
|
|
508
|
+
assertCount("columns", columns);
|
|
509
|
+
assertCount("rows", rows);
|
|
510
|
+
const meter = new Meter(options, options);
|
|
511
|
+
const n = columns * rows;
|
|
512
|
+
const fx = new Float64Array(n);
|
|
513
|
+
const fy = new Float64Array(n);
|
|
514
|
+
const u = new Float64Array(n).fill(Number.NaN);
|
|
515
|
+
const v = new Float64Array(n).fill(Number.NaN);
|
|
516
|
+
const mag = new Float64Array(n).fill(Number.NaN);
|
|
517
|
+
let done = 0;
|
|
518
|
+
let reason: "cells" | "time" | undefined;
|
|
519
|
+
for (let j = 0; j < rows; j++) {
|
|
520
|
+
reason = meter.check(columns);
|
|
521
|
+
if (reason) break;
|
|
522
|
+
const y = j === rows - 1 ? yDomain.max : yDomain.min + ((yDomain.max - yDomain.min) * j) / (rows - 1);
|
|
523
|
+
for (let i = 0; i < columns; i++) {
|
|
524
|
+
const x = i === columns - 1 ? xDomain.max : xDomain.min + ((xDomain.max - xDomain.min) * i) / (columns - 1);
|
|
525
|
+
const k = j * columns + i;
|
|
526
|
+
fx[k] = x;
|
|
527
|
+
fy[k] = y;
|
|
528
|
+
const w = F(x, y);
|
|
529
|
+
if (Number.isFinite(w[0]) && Number.isFinite(w[1])) {
|
|
530
|
+
u[k] = w[0];
|
|
531
|
+
v[k] = w[1];
|
|
532
|
+
mag[k] = Math.hypot(w[0], w[1]);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
meter.spend(columns);
|
|
536
|
+
done++;
|
|
537
|
+
}
|
|
538
|
+
const m = columns * done;
|
|
539
|
+
const out: PhasePortrait = {
|
|
540
|
+
field: {
|
|
541
|
+
columns,
|
|
542
|
+
rows: done,
|
|
543
|
+
x: fx.slice(0, m),
|
|
544
|
+
y: fy.slice(0, m),
|
|
545
|
+
u: u.slice(0, m),
|
|
546
|
+
v: v.slice(0, m),
|
|
547
|
+
magnitude: mag.slice(0, m),
|
|
548
|
+
},
|
|
549
|
+
trajectories: [],
|
|
550
|
+
fixedPoints: [],
|
|
551
|
+
progress: progress(done, rows, reason),
|
|
552
|
+
};
|
|
553
|
+
if (reason) return out;
|
|
554
|
+
|
|
555
|
+
const w = xDomain.max - xDomain.min;
|
|
556
|
+
const hgt = yDomain.max - yDomain.min;
|
|
557
|
+
const mar = options.margin ?? 0.5;
|
|
558
|
+
const inside = (x: number, y: number) =>
|
|
559
|
+
x >= xDomain.min - mar * w &&
|
|
560
|
+
x <= xDomain.max + mar * w &&
|
|
561
|
+
y >= yDomain.min - mar * hgt &&
|
|
562
|
+
y <= yDomain.max + mar * hgt;
|
|
563
|
+
const sys: System = (_t, s) => {
|
|
564
|
+
const r = F(s[0] as number, s[1] as number);
|
|
565
|
+
return [r[0], r[1]];
|
|
566
|
+
};
|
|
567
|
+
const tMax = options.tMax ?? 10;
|
|
568
|
+
const seeds =
|
|
569
|
+
options.seeds ??
|
|
570
|
+
Array.from({ length: 25 }, (_, q): [number, number] => [
|
|
571
|
+
xDomain.min + (w * ((q % 5) + 0.5)) / 5,
|
|
572
|
+
yDomain.min + (hgt * (Math.floor(q / 5) + 0.5)) / 5,
|
|
573
|
+
]);
|
|
574
|
+
for (const seed of seeds) {
|
|
575
|
+
const o: AdaptiveOdeOptions = {
|
|
576
|
+
...options,
|
|
577
|
+
stop: (_t, s) => !inside(s[0] as number, s[1] as number),
|
|
578
|
+
hMax: tMax / 20,
|
|
579
|
+
};
|
|
580
|
+
const fwd = integrateAdaptive(sys, 0, seed, tMax, o);
|
|
581
|
+
const bwd = integrateAdaptive(sys, 0, seed, -tMax, o);
|
|
582
|
+
const xs: number[] = [];
|
|
583
|
+
const ys: number[] = [];
|
|
584
|
+
for (let k = bwd.steps - 1; k >= 1; k--) {
|
|
585
|
+
xs.push(bwd.y[2 * k] as number);
|
|
586
|
+
ys.push(bwd.y[2 * k + 1] as number);
|
|
587
|
+
}
|
|
588
|
+
for (let k = 0; k < fwd.steps; k++) {
|
|
589
|
+
xs.push(fwd.y[2 * k] as number);
|
|
590
|
+
ys.push(fwd.y[2 * k + 1] as number);
|
|
591
|
+
}
|
|
592
|
+
out.trajectories.push({
|
|
593
|
+
seed,
|
|
594
|
+
x: Float64Array.from(xs),
|
|
595
|
+
y: Float64Array.from(ys),
|
|
596
|
+
reasonBefore: bwd.reason,
|
|
597
|
+
reasonAfter: fwd.reason,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const starts = options.fixedPointStarts ?? 8;
|
|
602
|
+
const eps = 1e-8 * Math.max(w, hgt);
|
|
603
|
+
for (let a = 0; a < starts && starts > 0; a++) {
|
|
604
|
+
for (let b = 0; b < starts; b++) {
|
|
605
|
+
let x = xDomain.min + (w * (a + 0.5)) / starts;
|
|
606
|
+
let y = yDomain.min + (hgt * (b + 0.5)) / starts;
|
|
607
|
+
let ok = false;
|
|
608
|
+
for (let it = 0; it < 50; it++) {
|
|
609
|
+
const r = F(x, y);
|
|
610
|
+
if (!Number.isFinite(r[0]) || !Number.isFinite(r[1])) break;
|
|
611
|
+
const J = jacobian2(F, x, y);
|
|
612
|
+
const det = J[0][0] * J[1][1] - J[0][1] * J[1][0];
|
|
613
|
+
if (!Number.isFinite(det) || Math.abs(det) < 1e-14) break;
|
|
614
|
+
const dx = (J[1][1] * r[0] - J[0][1] * r[1]) / det;
|
|
615
|
+
const dy = (-J[1][0] * r[0] + J[0][0] * r[1]) / det;
|
|
616
|
+
x -= dx;
|
|
617
|
+
y -= dy;
|
|
618
|
+
if (Math.hypot(dx, dy) < eps) {
|
|
619
|
+
ok = true;
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (!ok || !inside(x, y) || x < xDomain.min || x > xDomain.max || y < yDomain.min || y > yDomain.max) continue;
|
|
624
|
+
const r = F(x, y);
|
|
625
|
+
if (Math.hypot(r[0], r[1]) > 1e-6) continue;
|
|
626
|
+
if (out.fixedPoints.some((p) => Math.hypot(p.x - x, p.y - y) < 1e-5 * Math.max(w, hgt))) continue;
|
|
627
|
+
const J = jacobian2(F, x, y);
|
|
628
|
+
out.fixedPoints.push({ x, y, jacobian: J, ...classifyFixedPoint(J) });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
out.fixedPoints.sort((p, q) => p.x - q.x || p.y - q.y);
|
|
632
|
+
return out;
|
|
633
|
+
}
|