| 1 | import { describe, expect, test } from 'bun:test';
|
| 2 | import { cubicSolve, isClose, isComplex, moveDistT3, peakRateT3, pyRound, rateT3 } from '../src/calc.js';
|
| 3 | import { rng } from './helpers.js';
|
| 4 |
|
| 5 | const ACC = 2147483648;
|
| 6 |
|
| 7 | /** Independent tick-by-tick model of the firmware's T3 accumulator loop. */
|
| 8 | function tickT3(time, rate, accel, jerk, accum) {
|
| 9 | let r = rate - Math.trunc(accel / 2) + Math.trunc(jerk / 6);
|
| 10 | let a = accel;
|
| 11 | let acc = BigInt(accum);
|
| 12 | for (let t = 0; t < time; t++) {
|
| 13 | r += a;
|
| 14 | a += jerk;
|
| 15 | acc += BigInt(r);
|
| 16 | }
|
| 17 | const big = BigInt(ACC);
|
| 18 | let pos = acc / big;
|
| 19 | if (acc % big < 0n) pos -= 1n;
|
| 20 | return { steps: Number(pos), accum: Number(acc - pos * big), rate: r };
|
| 21 | }
|
| 22 |
|
| 23 | describe('pyRound', () => {
|
| 24 | test('rounds half to even like Python', () => {
|
| 25 | expect(pyRound(0.5)).toBe(0);
|
| 26 | expect(pyRound(1.5)).toBe(2);
|
| 27 | expect(pyRound(2.5)).toBe(2);
|
| 28 | expect(pyRound(-0.5) === 0).toBe(true); // Python: round(-0.5) == 0
|
| 29 | expect(pyRound(-1.5)).toBe(-2);
|
| 30 | expect(pyRound(-2.5)).toBe(-2);
|
| 31 | expect(pyRound(2.4999999)).toBe(2);
|
| 32 | expect(pyRound(-2.6)).toBe(-3);
|
| 33 | expect(pyRound(1234567.5)).toBe(1234568);
|
| 34 | });
|
| 35 | });
|
| 36 |
|
| 37 | describe('isClose', () => {
|
| 38 | test('matches math.isclose defaults', () => {
|
| 39 | expect(isClose(1, 1 + 1e-10)).toBe(true);
|
| 40 | expect(isClose(1, 1 + 1e-8)).toBe(false);
|
| 41 | expect(isClose(0, 1e-12)).toBe(false); // rel_tol alone never matches zero
|
| 42 | expect(isClose(0, 1e-12, 1e-9)).toBe(true);
|
| 43 | });
|
| 44 | });
|
| 45 |
|
| 46 | describe('moveDistT3', () => {
|
| 47 | test('known values', () => {
|
| 48 | expect(moveDistT3(0, 5, 5, 5, 0)).toEqual([0, 0]);
|
| 49 | // One step per tick for 10 ticks.
|
| 50 | expect(moveDistT3(10, ACC, 0, 0, 0)).toEqual([10, 0]);
|
| 51 | // Half a step per tick for 3 ticks: 1 step, remainder half.
|
| 52 | expect(moveDistT3(3, ACC / 2, 0, 0, 0)).toEqual([1, ACC / 2]);
|
| 53 | // Negative rate from cleared accumulator starts at 2^31 - 1.
|
| 54 | expect(moveDistT3(1, -1, 0, 0)).toEqual([0, 2147483646]);
|
| 55 | });
|
| 56 |
|
| 57 | test('matches a tick-by-tick firmware model on random moves', () => {
|
| 58 | const rand = rng(1234);
|
| 59 | for (let i = 0; i < 400; i++) {
|
| 60 | const time = 1 + Math.floor(rand() * 3000);
|
| 61 | const jerk = Math.floor((rand() - 0.5) * 4000);
|
| 62 | const accel = Math.floor((rand() - 0.5) * 2e6);
|
| 63 | // Keep |rate| below 2^31 over the whole move, as the planner must.
|
| 64 | const rate = Math.floor((rand() - 0.5) * 8e8);
|
| 65 | const accum = Math.floor(rand() * ACC);
|
| 66 | const sim = tickT3(time, rate, accel, jerk, accum);
|
| 67 | if (Math.abs(sim.rate) >= ACC) continue;
|
| 68 | expect(moveDistT3(time, rate, accel, jerk, accum)).toEqual([sim.steps, sim.accum]);
|
| 69 | expect(rateT3(time, rate, accel, jerk)).toBe(sim.rate);
|
| 70 | }
|
| 71 | });
|
| 72 | });
|
| 73 |
|
| 74 | describe('peakRateT3', () => {
|
| 75 | test('equals the largest |rate| seen tick by tick', () => {
|
| 76 | const rand = rng(77);
|
| 77 | for (let i = 0; i < 300; i++) {
|
| 78 | const time = 1 + Math.floor(rand() * 2000);
|
| 79 | const jerk = Math.floor((rand() - 0.5) * 4000);
|
| 80 | const accel = Math.floor((rand() - 0.5) * 2e6);
|
| 81 | const rate = Math.floor((rand() - 0.5) * 8e8);
|
| 82 | let r = rate - Math.trunc(accel / 2) + Math.trunc(jerk / 6);
|
| 83 | let a = accel;
|
| 84 | let peak = 0;
|
| 85 | for (let t = 0; t < time; t++) {
|
| 86 | r += a;
|
| 87 | a += jerk;
|
| 88 | peak = Math.max(peak, Math.abs(r));
|
| 89 | }
|
| 90 | expect(peakRateT3(time, rate, accel, jerk)).toBe(peak);
|
| 91 | }
|
| 92 | });
|
| 93 | });
|
| 94 |
|
| 95 | describe('cubicSolve', () => {
|
| 96 | test('real root of the S-curve cubic j t^3 + 2 v t - d = 0', () => {
|
| 97 | const rand = rng(7);
|
| 98 | for (let i = 0; i < 200; i++) {
|
| 99 | const j = 100 + rand() * 50000;
|
| 100 | const v = rand() * 10;
|
| 101 | const d = 1e-4 + rand() * 5;
|
| 102 | const roots = cubicSolve(j, 0, 2 * v, -d).filter((r) => !isComplex(r));
|
| 103 | expect(roots.length).toBeGreaterThan(0);
|
| 104 | const t = roots[0];
|
| 105 | expect(Math.abs(j * t ** 3 + 2 * v * t - d)).toBeLessThan(1e-9 * Math.max(1, d));
|
| 106 | }
|
| 107 | });
|
| 108 |
|
| 109 | test('three real roots', () => {
|
| 110 | const roots = cubicSolve(1, -6, 11, -6).sort((a, b) => a - b); // (x-1)(x-2)(x-3)
|
| 111 | expect(roots[0]).toBeCloseTo(1, 9);
|
| 112 | expect(roots[1]).toBeCloseTo(2, 9);
|
| 113 | expect(roots[2]).toBeCloseTo(3, 9);
|
| 114 | });
|
| 115 | });
|