@danielsimonjr/sciengjs 0.3.2 → 0.5.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 (55) hide show
  1. package/README.md +119 -5
  2. package/THIRD_PARTY_NOTICES.md +33 -1
  3. package/dist/browser/page.d.ts +7 -0
  4. package/dist/browser/page.js +27 -1
  5. package/dist/browser/page.js.map +1 -1
  6. package/dist/browser/pinned.d.ts +8 -0
  7. package/dist/browser/pinned.js +8 -0
  8. package/dist/browser/pinned.js.map +1 -1
  9. package/dist/math/backend-pdf.d.ts +3 -0
  10. package/dist/math/backend-pdf.js +14 -0
  11. package/dist/math/backend-pdf.js.map +1 -0
  12. package/dist/math/backend-png.d.ts +3 -0
  13. package/dist/math/backend-png.js +15 -0
  14. package/dist/math/backend-png.js.map +1 -0
  15. package/dist/math/backend-svg.d.ts +3 -0
  16. package/dist/math/backend-svg.js +12 -0
  17. package/dist/math/backend-svg.js.map +1 -0
  18. package/dist/math/font-ranges.d.ts +59 -0
  19. package/dist/math/font-ranges.js +93 -0
  20. package/dist/math/font-ranges.js.map +1 -0
  21. package/dist/math/index.d.ts +3 -0
  22. package/dist/math/index.js +3 -0
  23. package/dist/math/index.js.map +1 -0
  24. package/dist/math/tex.d.ts +39 -0
  25. package/dist/math/tex.js +130 -0
  26. package/dist/math/tex.js.map +1 -0
  27. package/dist/plot/backend-html.d.ts +3 -0
  28. package/dist/plot/backend-html.js +13 -0
  29. package/dist/plot/backend-html.js.map +1 -0
  30. package/dist/plot/backend-png.d.ts +3 -0
  31. package/dist/plot/backend-png.js +12 -0
  32. package/dist/plot/backend-png.js.map +1 -0
  33. package/dist/plot/backend-svg.d.ts +7 -0
  34. package/dist/plot/backend-svg.js +21 -0
  35. package/dist/plot/backend-svg.js.map +1 -0
  36. package/dist/plot/control.d.ts +93 -0
  37. package/dist/plot/control.js +811 -0
  38. package/dist/plot/control.js.map +1 -0
  39. package/dist/plot/index.d.ts +6 -0
  40. package/dist/plot/index.js +6 -0
  41. package/dist/plot/index.js.map +1 -0
  42. package/dist/plot/plotter.d.ts +7 -0
  43. package/dist/plot/plotter.js +491 -0
  44. package/dist/plot/plotter.js.map +1 -0
  45. package/dist/plot/reader.d.ts +10 -0
  46. package/dist/plot/reader.js +266 -0
  47. package/dist/plot/reader.js.map +1 -0
  48. package/dist/plot/ticks.d.ts +13 -0
  49. package/dist/plot/ticks.js +67 -0
  50. package/dist/plot/ticks.js.map +1 -0
  51. package/dist/registry.js +6 -0
  52. package/dist/registry.js.map +1 -1
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +12 -2
@@ -0,0 +1,811 @@
1
+ import { RenderError } from "../errors.js";
2
+ // Magnitude is clamped to a finite band so that |G| = 0 or a pole on the axis stays plottable.
3
+ const MAG_DB_FLOOR = -300;
4
+ const MAG_DB_CEIL = 300;
5
+ // The default frequency range spans this many decades beyond the outermost root.
6
+ const RANGE_DECADES = 2;
7
+ const DEFAULT_W_LOW = 0.01;
8
+ const DEFAULT_W_HIGH = 100;
9
+ const DEFAULT_FREQUENCY_POINTS = 400;
10
+ const DEFAULT_TIME_POINTS = 1000;
11
+ const DEFAULT_LOCUS_POINTS = 400;
12
+ const DEFAULT_LOCUS_KMAX = 1000;
13
+ // The root locus starts at K = 0 and spans this ratio below kMax on a log scale.
14
+ const LOCUS_K_MIN_RATIO = 1e-4;
15
+ // Used as the time horizon when no pole has a nonzero real part, in seconds.
16
+ const DEFAULT_TIME_HORIZON = 10;
17
+ // The default horizon is this many time constants of the slowest pole.
18
+ const HORIZON_TIME_CONSTANTS = 10;
19
+ // Durand-Kerner stops when the largest correction falls below this relative size.
20
+ const ROOT_TOL = 1e-11;
21
+ const ROOT_MAX_ITER = 1000;
22
+ // A root is also accepted when its residual is below this many ulps of the rounding scale.
23
+ const ROOT_RESIDUAL_ULPS = 64;
24
+ const ROOT_SEED = { re: 0.4, im: 0.9 };
25
+ // Bisection steps on a log-frequency bracket; 80 halvings reach double precision.
26
+ const BISECT_STEPS = 80;
27
+ // Golden-section steps for the least Nyquist distance; each step keeps 0.618 of the bracket.
28
+ const GOLDEN_STEPS = 100;
29
+ // Settling band of the step response, as a fraction of the final value.
30
+ const SETTLE_BAND = 0.02;
31
+ const RISE_LOW = 0.1;
32
+ const RISE_HIGH = 0.9;
33
+ // Scaling-and-squaring keeps the norm at or below this value before the Taylor series.
34
+ const EXPM_NORM_TARGET = 0.5;
35
+ const EXPM_TERMS = 20;
36
+ // A root counts as real when its imaginary part is below this relative size.
37
+ const REAL_AXIS_TOL = 1e-6;
38
+ // The frequency range ignores a root smaller than this fraction of the largest root: it is a pole or zero at 0.
39
+ const ROOT_ZERO_REL = 1e-9;
40
+ // A pole counts as stable when its real part is below minus this size, relative to the pole.
41
+ const STABLE_TOL = 1e-9;
42
+ function inputError(message) {
43
+ return new RenderError("E_INPUT", message);
44
+ }
45
+ function coefficients(value, name) {
46
+ if (!Array.isArray(value) || value.length === 0) {
47
+ throw inputError(`${name} must be a non-empty array of numbers`);
48
+ }
49
+ value.forEach((entry, index) => {
50
+ if (typeof entry !== "number" || !Number.isFinite(entry)) {
51
+ throw inputError(`${name}[${index}] must be a finite number`);
52
+ }
53
+ });
54
+ return value;
55
+ }
56
+ function trimLeading(p) {
57
+ let start = 0;
58
+ while (start < p.length - 1 && p[start] === 0)
59
+ start += 1;
60
+ return p.slice(start);
61
+ }
62
+ function degree(p) {
63
+ return trimLeading(p).length - 1;
64
+ }
65
+ /** Validates a transfer function. Rejects with E_INPUT: empty, non-finite, leading-zero den, or improper. */
66
+ export function checkTransferFunction(input) {
67
+ if (typeof input !== "object" || input === null) {
68
+ throw inputError("transfer function must be an object with num and den arrays");
69
+ }
70
+ const { num, den } = input;
71
+ const checkedNum = coefficients(num, "num");
72
+ const checkedDen = coefficients(den, "den");
73
+ if (checkedDen[0] === 0)
74
+ throw inputError("den must not have a leading zero");
75
+ if (degree(checkedNum) > checkedDen.length - 1) {
76
+ throw inputError("transfer function is improper: deg num exceeds deg den");
77
+ }
78
+ return { num: checkedNum, den: checkedDen };
79
+ }
80
+ function cmul(a, b) {
81
+ return { re: a.re * b.re - a.im * b.im, im: a.re * b.im + a.im * b.re };
82
+ }
83
+ function cdiv(a, b) {
84
+ const d = b.re * b.re + b.im * b.im;
85
+ return { re: (a.re * b.re + a.im * b.im) / d, im: (a.im * b.re - a.re * b.im) / d };
86
+ }
87
+ function csub(a, b) {
88
+ return { re: a.re - b.re, im: a.im - b.im };
89
+ }
90
+ function cabs(a) {
91
+ return Math.hypot(a.re, a.im);
92
+ }
93
+ /** The sum of |c_k| x^k: the rounding scale of evalPoly at a point of size x. */
94
+ function absPoly(p, x) {
95
+ let acc = 0;
96
+ for (const c of p)
97
+ acc = acc * x + Math.abs(c);
98
+ return acc;
99
+ }
100
+ function evalPoly(p, s) {
101
+ let acc = { re: 0, im: 0 };
102
+ for (const c of p) {
103
+ const next = cmul(acc, s);
104
+ acc = { re: next.re + c, im: next.im };
105
+ }
106
+ return acc;
107
+ }
108
+ /** The roots of a polynomial with descending coefficients. Each trailing zero coefficient gives an exact root at 0; Durand-Kerner finds the others. No convergence rejects with E_INPUT. */
109
+ export function polynomialRoots(coeffs) {
110
+ const p = trimLeading(coeffs);
111
+ let end = p.length;
112
+ while (end > 1 && p[end - 1] === 0)
113
+ end -= 1;
114
+ const roots = [];
115
+ for (let i = end; i < p.length; i += 1)
116
+ roots.push({ re: 0, im: 0 });
117
+ if (end > 1)
118
+ roots.push(...durandKerner(p.slice(0, end)));
119
+ return roots;
120
+ }
121
+ /** The roots of a polynomial with a nonzero constant term, by Durand-Kerner iteration. No convergence rejects with E_INPUT. */
122
+ function durandKerner(p) {
123
+ const n = p.length - 1;
124
+ if (n < 1)
125
+ return [];
126
+ const lead = p[0] ?? 1;
127
+ const monic = p.map((c) => c / lead);
128
+ let roots = [];
129
+ let power = { re: 1, im: 0 };
130
+ for (let i = 0; i < n; i += 1) {
131
+ roots.push(power);
132
+ power = cmul(power, ROOT_SEED);
133
+ }
134
+ // A multiple root stalls at the rounding floor above the step tolerance, and the iterates wander there.
135
+ // The loop keeps the iterate with the least residual, measured in ulps of sum |a_k| |r|^k.
136
+ const residualUlps = (set) => Math.max(...set.map((root) => {
137
+ const size = cabs(root);
138
+ const ratio = cabs(evalPoly(monic, root)) / (Number.EPSILON * absPoly(monic, size));
139
+ return Number.isFinite(size) && !Number.isNaN(ratio) ? ratio : Infinity;
140
+ }));
141
+ let best = roots;
142
+ let bestUlps = residualUlps(roots);
143
+ for (let iter = 0; iter < ROOT_MAX_ITER; iter += 1) {
144
+ let biggestStep = 0;
145
+ let largest = 0;
146
+ const current = roots;
147
+ roots = current.map((r, i) => {
148
+ let denom = { re: 1, im: 0 };
149
+ current.forEach((other, j) => {
150
+ if (j !== i)
151
+ denom = cmul(denom, csub(r, other));
152
+ });
153
+ const step = cdiv(evalPoly(monic, r), denom);
154
+ biggestStep = Math.max(biggestStep, cabs(step));
155
+ largest = Math.max(largest, cabs(r));
156
+ return csub(r, step);
157
+ });
158
+ if (!Number.isFinite(biggestStep) || !Number.isFinite(largest))
159
+ break;
160
+ if (biggestStep <= ROOT_TOL * Math.max(1, largest))
161
+ return roots;
162
+ const ulps = residualUlps(roots);
163
+ if (ulps < bestUlps) {
164
+ best = roots;
165
+ bestUlps = ulps;
166
+ }
167
+ }
168
+ // Accept the best iterate when every residual is rounding error only.
169
+ if (bestUlps <= ROOT_RESIDUAL_ULPS)
170
+ return best;
171
+ throw inputError(`polynomialRoots: the roots of the degree ${n} polynomial did not converge in ${ROOT_MAX_ITER} iterations: the coefficients are out of range or a root has a high multiplicity`);
172
+ }
173
+ /** Rejects with E_INPUT when a computed value is not finite, so that no NaN leaves a helper. */
174
+ function checkFinite(values, name) {
175
+ values.forEach((v, i) => {
176
+ if (!Number.isFinite(v))
177
+ throw inputError(`${name}[${i}] is not finite: the model is outside the numeric range`);
178
+ });
179
+ return values;
180
+ }
181
+ /** Rejects with E_INPUT when a derived number is NaN or infinite; null stays null. */
182
+ function checkNumbers(numbers, helper) {
183
+ for (const [key, value] of Object.entries(numbers)) {
184
+ if (typeof value === "number" && !Number.isFinite(value)) {
185
+ throw inputError(`${helper}: ${key} is not finite: the model is outside the numeric range`);
186
+ }
187
+ }
188
+ return numbers;
189
+ }
190
+ function positiveOption(value, name) {
191
+ if (value === undefined)
192
+ return undefined;
193
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
194
+ throw inputError(`${name} must be a positive finite number`);
195
+ }
196
+ return value;
197
+ }
198
+ function countOption(value, name, fallback) {
199
+ if (value === undefined)
200
+ return fallback;
201
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 2) {
202
+ throw inputError(`${name} must be an integer of at least 2`);
203
+ }
204
+ return value;
205
+ }
206
+ function logSpace(lo, hi, count) {
207
+ if (count === 1)
208
+ return [lo];
209
+ const a = Math.log10(lo);
210
+ const b = Math.log10(hi);
211
+ return Array.from({ length: count }, (_unused, i) => 10 ** (a + ((b - a) * i) / (count - 1)));
212
+ }
213
+ function response(tf, w) {
214
+ const s = { re: 0, im: w };
215
+ return cdiv(evalPoly(tf.num, s), evalPoly(tf.den, s));
216
+ }
217
+ function magnitudeDb(tf, w) {
218
+ const db = 20 * Math.log10(cabs(response(tf, w)));
219
+ return Math.min(Math.max(db, MAG_DB_FLOOR), MAG_DB_CEIL);
220
+ }
221
+ function principalPhaseDeg(c) {
222
+ return (Math.atan2(c.im, c.re) * 180) / Math.PI;
223
+ }
224
+ function trailingZeros(p) {
225
+ let count = 0;
226
+ while (count < p.length - 1 && p[p.length - 1 - count] === 0)
227
+ count += 1;
228
+ return count;
229
+ }
230
+ /** Phase of G near w -> 0 from the power law of the lowest-order terms, or null for a zero numerator. */
231
+ function lowFrequencyPhaseDeg(tf) {
232
+ const kNum = trailingZeros(tf.num);
233
+ const kDen = trailingZeros(tf.den);
234
+ const cNum = tf.num[tf.num.length - 1 - kNum];
235
+ const cDen = tf.den[tf.den.length - 1 - kDen];
236
+ if (cNum === undefined || cDen === undefined || cNum === 0)
237
+ return null;
238
+ return 90 * (kNum - kDen) + (cNum / cDen < 0 ? -180 : 0);
239
+ }
240
+ /** The phase branch closest to ref, so that a crossing search stays on one continuous curve. */
241
+ function continuousPhaseDeg(tf, w, ref) {
242
+ const p = principalPhaseDeg(response(tf, w));
243
+ return p + 360 * Math.round((ref - p) / 360);
244
+ }
245
+ /** Phase in degrees, unwrapped along the grid and aligned with the low-frequency asymptote. */
246
+ function unwrappedPhaseDeg(tf, w) {
247
+ const raw = w.map((x) => principalPhaseDeg(response(tf, x)));
248
+ const out = [];
249
+ raw.forEach((p, i) => {
250
+ if (i === 0) {
251
+ out.push(p);
252
+ return;
253
+ }
254
+ const step = p - (raw[i - 1] ?? p);
255
+ out.push((out[i - 1] ?? p) + (step - 360 * Math.round(step / 360)));
256
+ });
257
+ const target = lowFrequencyPhaseDeg(tf);
258
+ if (target === null || out.length === 0)
259
+ return out;
260
+ const offset = 360 * Math.round((target - (out[0] ?? 0)) / 360);
261
+ return out.map((v) => v + offset);
262
+ }
263
+ function frequencyRange(tf, options) {
264
+ const all = [...polynomialRoots(tf.num), ...polynomialRoots(tf.den)].map(cabs);
265
+ const largest = all.length > 0 ? Math.max(...all) : 0;
266
+ const magnitudes = all.filter((m) => m > 0 && m >= ROOT_ZERO_REL * largest);
267
+ const low = magnitudes.length > 0 ? Math.min(...magnitudes) * 10 ** -RANGE_DECADES : DEFAULT_W_LOW;
268
+ const high = magnitudes.length > 0 ? Math.max(...magnitudes) * 10 ** RANGE_DECADES : DEFAULT_W_HIGH;
269
+ const wMin = positiveOption(options.wMin, "wMin") ?? low;
270
+ const wMax = positiveOption(options.wMax, "wMax") ?? high;
271
+ if (wMax <= wMin)
272
+ throw inputError("wMax must be greater than wMin");
273
+ return [wMin, wMax];
274
+ }
275
+ /** The first crossing of a level by a sampled curve, refined by bisection in log frequency. */
276
+ function findCrossing(w, samples, level, valueAt) {
277
+ for (let i = 0; i + 1 < w.length; i += 1) {
278
+ const lo0 = w[i];
279
+ const hi0 = w[i + 1];
280
+ const ref = samples[i];
281
+ const next = samples[i + 1];
282
+ if (lo0 === undefined || hi0 === undefined || ref === undefined || next === undefined)
283
+ continue;
284
+ const fLo0 = ref - level;
285
+ const fHi0 = next - level;
286
+ if (fLo0 < 0 === fHi0 < 0)
287
+ continue;
288
+ let lo = lo0;
289
+ let hi = hi0;
290
+ let fLo = fLo0;
291
+ for (let step = 0; step < BISECT_STEPS; step += 1) {
292
+ const mid = Math.sqrt(lo * hi);
293
+ const fMid = valueAt(mid, ref) - level;
294
+ if (fMid < 0 === fLo < 0) {
295
+ lo = mid;
296
+ fLo = fMid;
297
+ }
298
+ else {
299
+ hi = mid;
300
+ }
301
+ }
302
+ return { w: Math.sqrt(lo * hi), index: i };
303
+ }
304
+ return null;
305
+ }
306
+ function bodeFigure(w, magDb, phaseDeg) {
307
+ return {
308
+ data: [
309
+ { type: "scatter", mode: "lines", x: w, y: magDb, name: "magnitude", xaxis: "x", yaxis: "y", line: { width: 2 } },
310
+ { type: "scatter", mode: "lines", x: w, y: phaseDeg, name: "phase", xaxis: "x2", yaxis: "y2", line: { width: 2 } },
311
+ ],
312
+ layout: {
313
+ title: { text: "Bode plot" },
314
+ grid: { rows: 2, columns: 1, pattern: "independent" },
315
+ xaxis: { type: "log", title: { text: "omega (rad/s)" } },
316
+ yaxis: { title: { text: "magnitude (dB)" } },
317
+ xaxis2: { type: "log", title: { text: "omega (rad/s)" } },
318
+ yaxis2: { title: { text: "phase (deg)" } },
319
+ },
320
+ };
321
+ }
322
+ /** Bode plot. Crossovers and margins are null where no crossing exists. */
323
+ export function bode(input, options = {}) {
324
+ const tf = checkTransferFunction(input);
325
+ const [wMin, wMax] = frequencyRange(tf, options);
326
+ const points = countOption(options.points, "points", DEFAULT_FREQUENCY_POINTS);
327
+ const w = logSpace(wMin, wMax, points);
328
+ const magDb = checkFinite(w.map((x) => magnitudeDb(tf, x)), "bode magnitude");
329
+ const phase = checkFinite(unwrappedPhaseDeg(tf, w), "bode phase");
330
+ const gain = findCrossing(w, magDb, 0, (x) => magnitudeDb(tf, x));
331
+ const phaseHit = findCrossing(w, phase, -180, (x, ref) => continuousPhaseDeg(tf, x, ref));
332
+ const gainCrossover = gain?.w ?? null;
333
+ const phaseCrossover = phaseHit?.w ?? null;
334
+ const gainMargin = phaseHit === null ? null : -magnitudeDb(tf, phaseHit.w);
335
+ const phaseMargin = gain === null ? null : 180 + continuousPhaseDeg(tf, gain.w, phase[gain.index] ?? 0);
336
+ return {
337
+ figure: bodeFigure(w, magDb, phase),
338
+ numbers: checkNumbers({ gainCrossover, phaseCrossover, gainMargin, phaseMargin }, "bode"),
339
+ };
340
+ }
341
+ function controllableForm(tf) {
342
+ const d0 = tf.den[0] ?? 1;
343
+ const a = tf.den.map((c) => c / d0);
344
+ const n = a.length - 1;
345
+ const nt = trimLeading(tf.num);
346
+ const padded = new Array(n + 1 - nt.length).fill(0).concat(nt).map((c) => c / d0);
347
+ const D = padded[0] ?? 0;
348
+ const rem = padded.map((c, i) => c - D * (a[i] ?? 0));
349
+ const A = Array.from({ length: n }, () => new Array(n).fill(0));
350
+ for (let j = 0; j < n; j += 1) {
351
+ const row = A[0];
352
+ if (row !== undefined)
353
+ row[j] = -(a[j + 1] ?? 0);
354
+ }
355
+ for (let i = 1; i < n; i += 1) {
356
+ const row = A[i];
357
+ if (row !== undefined)
358
+ row[i - 1] = 1;
359
+ }
360
+ const B = new Array(n).fill(0);
361
+ if (n > 0)
362
+ B[0] = 1;
363
+ return { A, B, C: rem.slice(1), D };
364
+ }
365
+ function identity(size) {
366
+ return Array.from({ length: size }, (_unused, i) => Array.from({ length: size }, (_unused2, j) => (i === j ? 1 : 0)));
367
+ }
368
+ function matMul(a, b) {
369
+ const size = a.length;
370
+ const inner = b.length;
371
+ const cols = b[0]?.length ?? 0;
372
+ return Array.from({ length: size }, (_unused, i) => Array.from({ length: cols }, (_unused2, j) => {
373
+ let sum = 0;
374
+ for (let k = 0; k < inner; k += 1)
375
+ sum += (a[i]?.[k] ?? 0) * (b[k]?.[j] ?? 0);
376
+ return sum;
377
+ }));
378
+ }
379
+ /** Matrix exponential by scaling and squaring, with a Taylor series on the scaled matrix. */
380
+ function expm(M) {
381
+ const size = M.length;
382
+ let norm = 0;
383
+ for (const row of M)
384
+ norm = Math.max(norm, row.reduce((acc, v) => acc + Math.abs(v), 0));
385
+ let squarings = 0;
386
+ while (norm / 2 ** squarings > EXPM_NORM_TARGET)
387
+ squarings += 1;
388
+ const scale = 2 ** -squarings;
389
+ const X = M.map((row) => row.map((v) => v * scale));
390
+ let result = identity(size);
391
+ let term = identity(size);
392
+ for (let k = 1; k <= EXPM_TERMS; k += 1) {
393
+ term = matMul(term, X).map((row) => row.map((v) => v / k));
394
+ result = result.map((row, i) => row.map((v, j) => v + (term[i]?.[j] ?? 0)));
395
+ }
396
+ for (let s = 0; s < squarings; s += 1)
397
+ result = matMul(result, result);
398
+ return result;
399
+ }
400
+ /** Exact zero-order-hold discretization: Ad = e^(A dt), Bd = integral of e^(A s) ds times B. */
401
+ function discretize(ss, dt) {
402
+ const n = ss.B.length;
403
+ const M = Array.from({ length: n + 1 }, () => new Array(n + 1).fill(0));
404
+ for (let i = 0; i < n; i += 1) {
405
+ for (let j = 0; j < n; j += 1) {
406
+ const row = M[i];
407
+ if (row !== undefined)
408
+ row[j] = (ss.A[i]?.[j] ?? 0) * dt;
409
+ }
410
+ const row = M[i];
411
+ if (row !== undefined)
412
+ row[n] = (ss.B[i] ?? 0) * dt;
413
+ }
414
+ const E = expm(M);
415
+ return {
416
+ Ad: E.slice(0, n).map((row) => row.slice(0, n)),
417
+ Bd: E.slice(0, n).map((row) => row[n] ?? 0),
418
+ };
419
+ }
420
+ function matVec(M, x) {
421
+ return M.map((row) => row.reduce((acc, v, j) => acc + v * (x[j] ?? 0), 0));
422
+ }
423
+ function dot(a, x) {
424
+ return a.reduce((acc, v, j) => acc + v * (x[j] ?? 0), 0);
425
+ }
426
+ function timeHorizon(tf, options) {
427
+ const decays = polynomialRoots(tf.den)
428
+ .map((p) => Math.abs(p.re))
429
+ .filter((v) => v > 0);
430
+ const fallback = decays.length > 0 ? HORIZON_TIME_CONSTANTS / Math.min(...decays) : DEFAULT_TIME_HORIZON;
431
+ const tEnd = positiveOption(options.tEnd, "tEnd");
432
+ return tEnd ?? fallback;
433
+ }
434
+ function timeFigure(t, y, title, yTitle) {
435
+ return {
436
+ data: [{ type: "scatter", mode: "lines", x: t, y, name: yTitle, line: { width: 2 } }],
437
+ layout: {
438
+ title: { text: title },
439
+ xaxis: { title: { text: "t (s)" } },
440
+ yaxis: { title: { text: yTitle } },
441
+ },
442
+ };
443
+ }
444
+ /** The peak of a sampled curve, refined by a parabola through the peak sample and its neighbours. */
445
+ function refinedPeak(y, dt) {
446
+ let index = 0;
447
+ y.forEach((v, i) => {
448
+ if (v > (y[index] ?? -Infinity))
449
+ index = i;
450
+ });
451
+ const y0 = y[index] ?? 0;
452
+ const yPrev = y[index - 1];
453
+ const yNext = y[index + 1];
454
+ if (index === 0 || index === y.length - 1 || yPrev === undefined || yNext === undefined) {
455
+ return { value: y0, time: index * dt };
456
+ }
457
+ const curvature = yPrev - 2 * y0 + yNext;
458
+ if (curvature === 0)
459
+ return { value: y0, time: index * dt };
460
+ const offset = Math.min(0.5, Math.max(-0.5, (0.5 * (yPrev - yNext)) / curvature));
461
+ return { value: y0 - 0.25 * (yPrev - yNext) * offset, time: (index + offset) * dt };
462
+ }
463
+ function firstAtOrAbove(ratio, level, dt) {
464
+ for (let i = 0; i < ratio.length; i += 1) {
465
+ const r = ratio[i];
466
+ if (r === undefined || r < level)
467
+ continue;
468
+ if (i === 0)
469
+ return 0;
470
+ const prev = ratio[i - 1] ?? 0;
471
+ return (i - 1 + (level - prev) / (r - prev)) * dt;
472
+ }
473
+ return null;
474
+ }
475
+ function stepNumbers(y, dt, finalValue) {
476
+ if (finalValue === null) {
477
+ return { finalValue, riseTime: null, settlingTime: null, overshoot: null, peakTime: null };
478
+ }
479
+ // The peak is the extreme in the direction of the final value; it counts only before the last sample.
480
+ const sign = finalValue < 0 ? -1 : 1;
481
+ const peak = refinedPeak(y.map((v) => sign * v), dt);
482
+ const peakTime = peak.time < (y.length - 1) * dt ? peak.time : null;
483
+ if (finalValue === 0) {
484
+ return { finalValue, riseTime: null, settlingTime: null, overshoot: null, peakTime };
485
+ }
486
+ const ratio = y.map((v) => v / finalValue);
487
+ const low = firstAtOrAbove(ratio, RISE_LOW, dt);
488
+ const high = firstAtOrAbove(ratio, RISE_HIGH, dt);
489
+ const band = SETTLE_BAND * Math.abs(finalValue);
490
+ let lastOutside = -1;
491
+ y.forEach((v, i) => {
492
+ if (Math.abs(v - finalValue) > band)
493
+ lastOutside = i;
494
+ });
495
+ // Linear interpolation of the error |y - finalValue| across the band, between the last sample outside and the next.
496
+ let settlingTime = 0;
497
+ if (lastOutside >= 0) {
498
+ const outside = y[lastOutside];
499
+ const inside = y[lastOutside + 1];
500
+ if (outside === undefined || inside === undefined) {
501
+ settlingTime = null;
502
+ }
503
+ else {
504
+ const eOut = Math.abs(outside - finalValue);
505
+ const eIn = Math.abs(inside - finalValue);
506
+ settlingTime = (lastOutside + (eOut - band) / (eOut - eIn)) * dt;
507
+ }
508
+ }
509
+ return {
510
+ finalValue,
511
+ riseTime: low === null || high === null ? null : high - low,
512
+ settlingTime,
513
+ overshoot: Math.max(0, ((peak.value - Math.abs(finalValue)) / Math.abs(finalValue)) * 100),
514
+ peakTime,
515
+ };
516
+ }
517
+ /** True when every pole lies strictly left of the imaginary axis, beyond a tolerance relative to its size. */
518
+ function isStable(tf) {
519
+ return polynomialRoots(tf.den).every((p) => p.re < -STABLE_TOL * Math.max(1, cabs(p)));
520
+ }
521
+ /** Step response by exact zero-order-hold simulation. finalValue is the DC gain, null for a system that is not stable. */
522
+ export function step(input, options = {}) {
523
+ const tf = checkTransferFunction(input);
524
+ const tEnd = timeHorizon(tf, options);
525
+ const points = countOption(options.points, "points", DEFAULT_TIME_POINTS);
526
+ const dt = tEnd / points;
527
+ const ss = controllableForm(tf);
528
+ const n = ss.B.length;
529
+ const y = [];
530
+ if (n === 0) {
531
+ for (let k = 0; k <= points; k += 1)
532
+ y.push(ss.D);
533
+ }
534
+ else {
535
+ const { Ad, Bd } = discretize(ss, dt);
536
+ let x = new Array(n).fill(0);
537
+ for (let k = 0; k <= points; k += 1) {
538
+ y.push(dot(ss.C, x) + ss.D);
539
+ const advanced = matVec(Ad, x);
540
+ x = advanced.map((v, i) => v + (Bd[i] ?? 0));
541
+ }
542
+ }
543
+ const dc = evalPoly(tf.den, { re: 0, im: 0 });
544
+ const finalValue = dc.re === 0 || !isStable(tf) ? null : evalPoly(tf.num, { re: 0, im: 0 }).re / dc.re;
545
+ const t = y.map((_unused, k) => k * dt);
546
+ return {
547
+ figure: timeFigure(t, checkFinite(y, "step y"), "Step response", "y(t)"),
548
+ numbers: checkNumbers(stepNumbers(y, dt, finalValue), "step"),
549
+ };
550
+ }
551
+ /** Impulse response of the strictly proper part, C e^(At) B for t > 0. A direct feedthrough term is not plotted. */
552
+ export function impulse(input, options = {}) {
553
+ const tf = checkTransferFunction(input);
554
+ const tEnd = timeHorizon(tf, options);
555
+ const points = countOption(options.points, "points", DEFAULT_TIME_POINTS);
556
+ const dt = tEnd / points;
557
+ const ss = controllableForm(tf);
558
+ const n = ss.B.length;
559
+ const y = [];
560
+ if (n === 0) {
561
+ for (let k = 0; k <= points; k += 1)
562
+ y.push(0);
563
+ }
564
+ else {
565
+ const { Ad } = discretize(ss, dt);
566
+ let x = ss.B.slice();
567
+ for (let k = 0; k <= points; k += 1) {
568
+ y.push(dot(ss.C, x));
569
+ x = matVec(Ad, x);
570
+ }
571
+ }
572
+ const peak = refinedPeak(y, dt);
573
+ const t = y.map((_unused, k) => k * dt);
574
+ return {
575
+ figure: timeFigure(t, checkFinite(y, "impulse y"), "Impulse response", "y(t)"),
576
+ numbers: checkNumbers({ peak: peak.value, peakTime: peak.time }, "impulse"),
577
+ };
578
+ }
579
+ /** Pole-zero map: poles as crosses, zeros as circles. */
580
+ export function poleZero(input) {
581
+ const tf = checkTransferFunction(input);
582
+ const poles = polynomialRoots(tf.den);
583
+ const zeros = polynomialRoots(tf.num);
584
+ const data = [];
585
+ if (zeros.length > 0) {
586
+ data.push({
587
+ type: "scatter",
588
+ mode: "markers",
589
+ x: zeros.map((z) => z.re),
590
+ y: zeros.map((z) => z.im),
591
+ name: "zeros",
592
+ marker: { symbol: "circle", size: 10, color: "#1f4e79" },
593
+ });
594
+ }
595
+ if (poles.length > 0) {
596
+ data.push({
597
+ type: "scatter",
598
+ mode: "markers",
599
+ x: poles.map((p) => p.re),
600
+ y: poles.map((p) => p.im),
601
+ name: "poles",
602
+ marker: { symbol: "x", size: 12, color: "#b22222" },
603
+ });
604
+ }
605
+ return {
606
+ figure: {
607
+ data,
608
+ layout: {
609
+ title: { text: "Pole-zero map" },
610
+ xaxis: { title: { text: "Re(s)" } },
611
+ yaxis: { title: { text: "Im(s)" } },
612
+ },
613
+ },
614
+ numbers: { poles, zeros },
615
+ };
616
+ }
617
+ /** Minimum of f on [lo, hi] by golden-section search. f must be unimodal on the interval. */
618
+ function goldenMin(f, lo, hi, steps) {
619
+ const ratio = (Math.sqrt(5) - 1) / 2;
620
+ let a = lo;
621
+ let b = hi;
622
+ if (!(b > a))
623
+ return { x: a, value: f(a) };
624
+ let c = b - ratio * (b - a);
625
+ let d = a + ratio * (b - a);
626
+ let fc = f(c);
627
+ let fd = f(d);
628
+ for (let k = 0; k < steps; k += 1) {
629
+ if (fc < fd) {
630
+ b = d;
631
+ d = c;
632
+ fd = fc;
633
+ c = b - ratio * (b - a);
634
+ fc = f(c);
635
+ }
636
+ else {
637
+ a = c;
638
+ c = d;
639
+ fc = fd;
640
+ d = a + ratio * (b - a);
641
+ fd = f(d);
642
+ }
643
+ }
644
+ return fc < fd ? { x: c, value: fc } : { x: d, value: fd };
645
+ }
646
+ /** Nyquist plot of G(j omega) for negative and positive frequencies, with the critical point -1. */
647
+ export function nyquist(input, options = {}) {
648
+ const tf = checkTransferFunction(input);
649
+ const [wMin, wMax] = frequencyRange(tf, options);
650
+ const points = countOption(options.points, "points", DEFAULT_FREQUENCY_POINTS);
651
+ const grid = logSpace(wMin, wMax, points);
652
+ const positive = grid.map((x) => response(tf, x));
653
+ // A real-coefficient model gives G(-j omega) = conj(G(j omega)).
654
+ const negative = positive
655
+ .slice()
656
+ .reverse()
657
+ .map((c) => ({ re: c.re, im: -c.im }));
658
+ const path = [...negative, ...positive];
659
+ // The negative half mirrors the positive half, so the distances repeat: scan the positive half,
660
+ // then refine the least sample by golden-section search in log frequency between its neighbours.
661
+ const distanceAt = (u) => {
662
+ const c = response(tf, 10 ** u);
663
+ return Math.hypot(c.re + 1, c.im);
664
+ };
665
+ let best = 0;
666
+ const distances = positive.map((c) => Math.hypot(c.re + 1, c.im));
667
+ distances.forEach((d, i) => {
668
+ if (d < (distances[best] ?? Infinity))
669
+ best = i;
670
+ });
671
+ const refined = goldenMin(distanceAt, Math.log10(grid[Math.max(0, best - 1)] ?? wMin), Math.log10(grid[Math.min(grid.length - 1, best + 1)] ?? wMax), GOLDEN_STEPS);
672
+ const minDistance = Math.min(distances[best] ?? Infinity, refined.value);
673
+ checkFinite(path.flatMap((c) => [c.re, c.im]), "nyquist G(j omega)");
674
+ return {
675
+ figure: {
676
+ data: [
677
+ { type: "scatter", mode: "lines", x: path.map((c) => c.re), y: path.map((c) => c.im), name: "G(j omega)", line: { width: 2 } },
678
+ { type: "scatter", mode: "markers", x: [-1], y: [0], name: "-1", marker: { symbol: "x", size: 12, color: "#b22222" } },
679
+ ],
680
+ layout: {
681
+ title: { text: "Nyquist plot" },
682
+ xaxis: { title: { text: "Re G" } },
683
+ yaxis: { title: { text: "Im G" } },
684
+ },
685
+ },
686
+ numbers: checkNumbers({ minDistanceToMinusOne: minDistance }, "nyquist"),
687
+ };
688
+ }
689
+ function polyMul(a, b) {
690
+ const out = new Array(a.length + b.length - 1).fill(0);
691
+ a.forEach((x, i) => {
692
+ b.forEach((y, j) => {
693
+ out[i + j] = (out[i + j] ?? 0) + x * y;
694
+ });
695
+ });
696
+ return out;
697
+ }
698
+ function polyDeriv(p) {
699
+ if (p.length <= 1)
700
+ return [0];
701
+ return p.slice(0, -1).map((c, i) => c * (p.length - 1 - i));
702
+ }
703
+ function polyAdd(a, b) {
704
+ const len = Math.max(a.length, b.length);
705
+ const pa = new Array(len - a.length).fill(0).concat(a);
706
+ const pb = new Array(len - b.length).fill(0).concat(b);
707
+ return pa.map((v, i) => v + (pb[i] ?? 0));
708
+ }
709
+ /** The locus polynomial den + K num, with den's leading coefficient unchanged. */
710
+ function locusPolynomial(tf, k) {
711
+ const d = tf.den.slice();
712
+ const nt = trimLeading(tf.num);
713
+ const offset = d.length - nt.length;
714
+ nt.forEach((c, j) => {
715
+ d[j + offset] = (d[j + offset] ?? 0) + k * c;
716
+ });
717
+ return d;
718
+ }
719
+ /** Matches each root at one gain to its nearest root at the previous gain, so that branches stay continuous. */
720
+ function trackBranches(rootSets) {
721
+ const first = rootSets[0] ?? [];
722
+ const branches = first.map((r) => [r]);
723
+ for (let k = 1; k < rootSets.length; k += 1) {
724
+ const current = rootSets[k] ?? [];
725
+ const pairs = [];
726
+ branches.forEach((branch, i) => {
727
+ const last = branch[branch.length - 1];
728
+ if (last === undefined)
729
+ return;
730
+ current.forEach((c, j) => pairs.push({ i, j, d: cabs(csub(last, c)) }));
731
+ });
732
+ pairs.sort((p, q) => p.d - q.d);
733
+ const usedBranches = new Set();
734
+ const usedRoots = new Set();
735
+ for (const pair of pairs) {
736
+ if (usedBranches.has(pair.i) || usedRoots.has(pair.j))
737
+ continue;
738
+ const root = current[pair.j];
739
+ if (root !== undefined)
740
+ branches[pair.i]?.push(root);
741
+ usedBranches.add(pair.i);
742
+ usedRoots.add(pair.j);
743
+ }
744
+ }
745
+ return branches;
746
+ }
747
+ /** Root locus of den + K num = 0 for K from 0 to kMax, with breakaway points on the real axis. */
748
+ export function rootLocus(input, options = {}) {
749
+ const tf = checkTransferFunction(input);
750
+ const kMax = positiveOption(options.kMax, "kMax") ?? DEFAULT_LOCUS_KMAX;
751
+ const points = countOption(options.points, "points", DEFAULT_LOCUS_POINTS);
752
+ const gains = [0, ...logSpace(kMax * LOCUS_K_MIN_RATIO, kMax, points - 1)];
753
+ const rootSets = gains.map((k) => polynomialRoots(locusPolynomial(tf, k)));
754
+ const branches = trackBranches(rootSets);
755
+ // Breakaway candidates are the roots of num den' - den num', kept where K = -den/num is positive.
756
+ const candidates = polynomialRoots(polyAdd(polyMul(tf.num, polyDeriv(tf.den)), polyMul(tf.den, polyDeriv(tf.num)).map((c) => -c)));
757
+ const breakawayPoints = [];
758
+ for (const r of candidates) {
759
+ if (Math.abs(r.im) > REAL_AXIS_TOL * (1 + Math.abs(r.re)))
760
+ continue;
761
+ const s = { re: r.re, im: 0 };
762
+ const num = evalPoly(tf.num, s);
763
+ if (cabs(num) === 0)
764
+ continue;
765
+ const k = -cdiv(evalPoly(tf.den, s), num).re;
766
+ if (k > 0)
767
+ breakawayPoints.push({ re: r.re, k });
768
+ }
769
+ const data = branches.map((branch, i) => ({
770
+ type: "scatter",
771
+ mode: "lines",
772
+ x: branch.map((c) => c.re),
773
+ y: branch.map((c) => c.im),
774
+ name: `branch ${i + 1}`,
775
+ line: { width: 1.5 },
776
+ }));
777
+ const poles = polynomialRoots(tf.den);
778
+ const zeros = polynomialRoots(tf.num);
779
+ if (zeros.length > 0) {
780
+ data.push({
781
+ type: "scatter",
782
+ mode: "markers",
783
+ x: zeros.map((z) => z.re),
784
+ y: zeros.map((z) => z.im),
785
+ name: "zeros",
786
+ marker: { symbol: "circle", size: 10, color: "#1f4e79" },
787
+ });
788
+ }
789
+ if (poles.length > 0) {
790
+ data.push({
791
+ type: "scatter",
792
+ mode: "markers",
793
+ x: poles.map((p) => p.re),
794
+ y: poles.map((p) => p.im),
795
+ name: "open-loop poles",
796
+ marker: { symbol: "x", size: 12, color: "#b22222" },
797
+ });
798
+ }
799
+ return {
800
+ figure: {
801
+ data,
802
+ layout: {
803
+ title: { text: "Root locus" },
804
+ xaxis: { title: { text: "Re(s)" } },
805
+ yaxis: { title: { text: "Im(s)" } },
806
+ },
807
+ },
808
+ numbers: { breakawayPoints },
809
+ };
810
+ }
811
+ //# sourceMappingURL=control.js.map