Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
bench/limits-search.ts <url> runs the limits search per joint against the fake bus or a given arm server and prints a before/after table a person can paste on #439 #505
smith/arm:smith/495-bench-limits-search-ts-url-runs-the-limi → main 2 commits fork
4 files
changed +194
−22
Software/README.md +4
| @@ -1105,6 +1105,10 @@ Where to read it: | ||
| 1105 | 1105 | `bun Software/so101/bench/limits-sweep.ts [url]` drives each joint in turn through a fixed set of targets inside its bounds (the others held still) and prints that joint's p95 error, time to arrive, overshoot and snap-backs from `/precision`; with no url it runs on the fake bus, with a url it refuses to start unless nobody is driving and stops at once on STOP or any refusal. |
| 1106 | 1106 | The sweep's targets are fixed; the measured numbers move a little with the machine's timers. |
| 1107 | 1107 | |
| 1108 | ### Faster limits | |
| 1109 | ||
| 1110 | `bun Software/so101/bench/limits-search.ts [url] [--joint N]` steps each joint's speed and acceleration limits up from today's (90 °/s, 360 °/s²), sweeping at each step, keeps the last step that did not worsen p95 error or overshoot, writes it through `PUT /settings`, and prints one markdown table of limits and p95 error / overshoot / time to arrive, before → after. The gripper is skipped unless named with `--joint 6`. With no url it runs on the fake bus. It moves the arm: run it at the arm, by a person, while nobody drives; it refuses to start unless nobody is driving and no STOP or E-stop is in force, and on any of those mid-run it stops and puts the starting limits back. | |
| 1111 | ||
| 1108 | 1112 | `bun bench/precision-report.ts <url>` (the arm server's URL) reads the hourly roll-up from `GET /precision` and prints |
| 1109 | 1113 | the weekly comment for #439: each headline number this week against last, the worst joint and the open experiment. It |
| 1110 | 1114 | only reads; the crew posts it. The experiment is the line below, which the crew edits: |
Software/so101/bench/limits-search.test.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { get } from "./limits-sweep.ts"; | |
| 3 | import { fakeArm } from "./precision.ts"; | |
| 4 | import { limitsSearch, table } from "./limits-search.ts"; | |
| 5 | ||
| 6 | test("on the fake bus a table comes out and the settings end at the reported after limits", async () => { | |
| 7 | const arm = await fakeArm(); | |
| 8 | try { | |
| 9 | const rows = await limitsSearch(arm.url, 1, 400, [{ speedDegS: 120, accelDegS2: 480 }, { speedDegS: 150, accelDegS2: 600 }]); | |
| 10 | expect(rows).toHaveLength(1); | |
| 11 | const out = table(rows); | |
| 12 | expect(out).toContain("| shoulder_pan |"); | |
| 13 | expect(out.split("\n")).toHaveLength(3); | |
| 14 | const s = await get<{ jointSpeedDegS: Record<string, number>; jointAccelDegS2: Record<string, number> }>(arm.url, "/settings"); | |
| 15 | expect(s.jointSpeedDegS.shoulder_pan).toBe(rows[0]!.search.to.speedDegS); | |
| 16 | expect(s.jointAccelDegS2.shoulder_pan).toBe(rows[0]!.search.to.accelDegS2); | |
| 17 | } finally { | |
| 18 | await arm.close(); | |
| 19 | } | |
| 20 | }, 60000); |
Software/so101/bench/limits-search.ts added +142
| @@ -0,0 +1,142 @@ | ||
| 1 | /** | |
| 2 | * Raise each joint's speed and acceleration limits while the sweep stays as good, and print what changed. | |
| 3 | * bun bench/limits-search.ts on the fake bus, as bench/limits-sweep.ts does | |
| 4 | * bun bench/limits-search.ts <url> on a given arm server (http://host:8101) | |
| 5 | * bun bench/limits-search.ts <url> --joint 2 only that joint id (the gripper, 6, is skipped unless named) | |
| 6 | * It refuses to start unless /status shows nobody driving, no STOP and no E-stop. On any of those mid-run, or any | |
| 7 | * refusal, it stops at once and puts the starting limits back. Run it at the arm, by a person, while nobody drives. | |
| 8 | */ | |
| 9 | import { searchLimits, type Limits, type LimitsSearch } from "../src/limits-search.ts"; | |
| 10 | import type { Scorecard } from "../src/precision-history.ts"; | |
| 11 | import type { SweepResult } from "../src/tuning-run.ts"; | |
| 12 | import { fakeArm } from "./precision.ts"; | |
| 13 | import { driveJoint, get, stopped, type Status } from "./limits-sweep.ts"; | |
| 14 | ||
| 15 | const GRIPPER = 6; | |
| 16 | /** From today's 90 °/s and 360 °/s² (src/safety.ts) up in modest steps, within RANGES there. */ | |
| 17 | export const STEPS: Limits[] = [ | |
| 18 | { speedDegS: 120, accelDegS2: 480 }, | |
| 19 | { speedDegS: 150, accelDegS2: 600 }, | |
| 20 | { speedDegS: 180, accelDegS2: 800 }, | |
| 21 | { speedDegS: 240, accelDegS2: 1100 }, | |
| 22 | { speedDegS: 300, accelDegS2: 1500 }, | |
| 23 | { speedDegS: 360, accelDegS2: 2000 }, | |
| 24 | ]; | |
| 25 | ||
| 26 | interface Settings { | |
| 27 | maxSpeedDegS: number; | |
| 28 | maxAccelDegS2: number; | |
| 29 | jointSpeedDegS: Record<string, number>; | |
| 30 | jointAccelDegS2: Record<string, number>; | |
| 31 | } | |
| 32 | ||
| 33 | export interface Row { | |
| 34 | search: LimitsSearch; | |
| 35 | name: string; | |
| 36 | before: SweepResult; | |
| 37 | after: SweepResult; | |
| 38 | } | |
| 39 | ||
| 40 | const put = async (url: string, body: unknown) => { | |
| 41 | const res = await fetch(`${url}/settings`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 42 | if (!res.ok) throw new Error(`PUT /settings: ${res.status} ${await res.text()}`); | |
| 43 | }; | |
| 44 | ||
| 45 | /** Throws, naming why, unless nobody is driving and no STOP or E-stop is in force. */ | |
| 46 | async function assertQuiet(url: string): Promise<Status> { | |
| 47 | const status = await get<Status>(url, "/status"); | |
| 48 | if (status.driving) throw new Error(`refusing to start: ${status.driving} is driving the arm`); | |
| 49 | const why = stopped(status); | |
| 50 | if (why) throw new Error(`refusing to start: ${why} is in force`); | |
| 51 | return status; | |
| 52 | } | |
| 53 | ||
| 54 | /** Searches each joint in `only` (the arm's joints but the gripper when none), answering one row each. Restores every starting limit on any throw. */ | |
| 55 | export async function limitsSearch(url: string, only: number | null = null, hold = 1500, steps: Limits[] = STEPS): Promise<Row[]> { | |
| 56 | const status = await assertQuiet(url); | |
| 57 | const joints = status.joints.filter((j) => (only === null ? j.id !== GRIPPER : j.id === only)); | |
| 58 | if (!joints.length) throw new Error(`no joint ${only}`); | |
| 59 | const start = new Map<number, Limits>(); | |
| 60 | const rows: Row[] = []; | |
| 61 | const settings = () => get<Settings>(url, "/settings"); | |
| 62 | try { | |
| 63 | for (const joint of joints) { | |
| 64 | const name = joint.joint; | |
| 65 | if (!name) throw new Error(`joint ${joint.id} has no name`); | |
| 66 | await assertQuiet(url); | |
| 67 | const results: SweepResult[] = []; | |
| 68 | const limits = (s: Settings): Limits => ({ speedDegS: s.jointSpeedDegS[name] ?? s.maxSpeedDegS, accelDegS2: s.jointAccelDegS2[name] ?? s.maxAccelDegS2 }); | |
| 69 | const search = await searchLimits( | |
| 70 | { joint: joint.id, steps }, | |
| 71 | { | |
| 72 | isIdle: () => true, // asserted above, and the sweep stops on STOP, an E-stop or a refused stream | |
| 73 | readLimits: async () => { | |
| 74 | const l = limits(await settings()); | |
| 75 | start.set(joint.id, l); | |
| 76 | return l; | |
| 77 | }, | |
| 78 | writeLimits: (_j, l) => put(url, { jointSpeedDegS: { [name]: l.speedDegS }, jointAccelDegS2: { [name]: l.accelDegS2 } }), | |
| 79 | sweep: async () => { | |
| 80 | await new Promise((r) => setTimeout(r, 200)); // the server frees the stream a moment after it closes | |
| 81 | const total = (await get<Scorecard>(url, "/precision")).window.snapBacks.total; | |
| 82 | const c = await driveJoint(url, joint, hold, total); | |
| 83 | if (!c || c.errorDeg.p95 === null || c.arriveMs.p95 === null) throw new Error(`nothing was measured for ${name}`); | |
| 84 | const r = { p95Error: c.errorDeg.p95, overshoot: c.overshootTicks.max ?? 0, timeToArrive: c.arriveMs.p95 }; | |
| 85 | results.push(r); | |
| 86 | return r; | |
| 87 | }, | |
| 88 | }, | |
| 89 | ); | |
| 90 | const at = search.runs.findIndex((r) => r.step === search.to); | |
| 91 | rows.push({ search, name, before: results[0]!, after: at < 0 ? results[0]! : results[at + 1]! }); | |
| 92 | } | |
| 93 | } catch (err) { | |
| 94 | for (const [id, l] of start) { | |
| 95 | const name = status.joints.find((j) => j.id === id)!.joint!; | |
| 96 | await put(url, { jointSpeedDegS: { [name]: l.speedDegS }, jointAccelDegS2: { [name]: l.accelDegS2 } }).catch(() => {}); | |
| 97 | } | |
| 98 | throw err; | |
| 99 | } | |
| 100 | return rows; | |
| 101 | } | |
| 102 | ||
| 103 | export function table(rows: Row[]): string { | |
| 104 | const pair = (a: number, b: number, unit: string) => `${a}${unit} → ${b}${unit}`; | |
| 105 | const lines = [ | |
| 106 | "| joint | speed °/s | accel °/s² | p95 error | overshoot | time to arrive |", | |
| 107 | "|---|---|---|---|---|---|", | |
| 108 | ]; | |
| 109 | for (const { search: s, name, before: b, after: a } of rows) { | |
| 110 | lines.push(`| ${name} | ${pair(s.from.speedDegS, s.to.speedDegS, "")} | ${pair(s.from.accelDegS2, s.to.accelDegS2, "")} | ${pair(b.p95Error, a.p95Error, "°")} | ${pair(b.overshoot, a.overshoot, " ticks")} | ${pair(b.timeToArrive, a.timeToArrive, " ms")} |`); | |
| 111 | } | |
| 112 | return lines.join("\n"); | |
| 113 | } | |
| 114 | ||
| 115 | function parse(argv: string[]): { url: string | null; joint: number | null } { | |
| 116 | let url: string | null = null; | |
| 117 | let joint: number | null = null; | |
| 118 | for (let i = 0; i < argv.length; i++) { | |
| 119 | if (argv[i] === "--joint") { | |
| 120 | joint = Number(argv[++i]); | |
| 121 | if (!Number.isInteger(joint) || joint < 1) throw new Error("--joint takes a joint id, 1 or more"); | |
| 122 | } else if (argv[i]!.startsWith("http")) url = argv[i]!.replace(/\/$/, ""); | |
| 123 | else throw new Error(`unknown argument ${argv[i]}`); | |
| 124 | } | |
| 125 | return { url, joint }; | |
| 126 | } | |
| 127 | ||
| 128 | if (import.meta.main) { | |
| 129 | let arm: { url: string; close: () => Promise<void> } | null = null; | |
| 130 | try { | |
| 131 | const args = parse(process.argv.slice(2)); | |
| 132 | arm = args.url ? { url: args.url, close: async () => {} } : await fakeArm(); | |
| 133 | const rows = await limitsSearch(arm.url, args.joint); | |
| 134 | console.log(`\`bun bench/limits-search.ts${process.argv.length > 2 ? " " + process.argv.slice(2).join(" ") : ""}\`\n`); | |
| 135 | console.log(table(rows)); | |
| 136 | } catch (err) { | |
| 137 | console.error((err as Error).message); | |
| 138 | process.exitCode = 1; | |
| 139 | } finally { | |
| 140 | await arm?.close(); | |
| 141 | } | |
| 142 | } |
Software/so101/bench/limits-sweep.ts +28 −22
| @@ -16,21 +16,21 @@ import { fakeArm } from "./precision.ts"; | ||
| 16 | 16 | export const OFFSETS = [0, 250, 0, -250, 0] as const; |
| 17 | 17 | const HOLD_MS = 1500; |
| 18 | 18 | |
| 19 | interface Status { | |
| 19 | export interface Status { | |
| 20 | 20 | joints: Array<{ id: number; joint: string | null; lo: number | null; hi: number | null }>; |
| 21 | 21 | driving: string | null; |
| 22 | 22 | held: unknown; |
| 23 | 23 | estop: unknown; |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | const get = async <T>(url: string, path: string): Promise<T> => { | |
| 26 | export const get = async <T>(url: string, path: string): Promise<T> => { | |
| 27 | 27 | const res = await fetch(url + path); |
| 28 | 28 | if (!res.ok) throw new Error(`${path}: ${res.status}`); |
| 29 | 29 | return (await res.json()) as T; |
| 30 | 30 | }; |
| 31 | 31 | const card = async (url: string): Promise<Card> => (await get<Scorecard>(url, "/precision")).window; |
| 32 | 32 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); |
| 33 | const stopped = (s: Status) => (s.estop ? "emergency stop" : s.held ? "STOP" : null); | |
| 33 | export const stopped = (s: Status) => (s.estop ? "emergency stop" : s.held ? "STOP" : null); | |
| 34 | 34 | |
| 35 | 35 | /** The joint's targets: the offsets from the middle of its bounds, kept inside them. */ |
| 36 | 36 | export function targets(lo: number, hi: number): number[] { |
| @@ -43,6 +43,30 @@ export function line(name: string, j: JointCard | undefined, snaps: number): str | ||
| 43 | 43 | return `${name.padEnd(14)} p95 error ${fmt(j?.errorDeg.p95 ?? null, "°").padEnd(8)} arrive ${fmt(j?.arriveMs.p95 ?? null, " ms").padEnd(8)} overshoot ${fmt(j?.overshootTicks.max ?? null, " ticks").padEnd(10)} snap-backs ${snaps}`; |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | /** Drives one joint through its targets, the others held still; answers the joint's card from the drive session, read before the stream closes. */ | |
| 47 | export async function driveJoint(url: string, joint: Status["joints"][number], hold: number, total: number): Promise<JointCard | undefined> { | |
| 48 | if (joint.lo === null || joint.hi === null) throw new Error(`joint ${joint.id} has no bounds`); | |
| 49 | const ws = new WebSocket(`${url.replace(/^http/, "ws")}/follow`); | |
| 50 | let closed: number | null = null; | |
| 51 | ws.on("close", (code) => (closed = code)); | |
| 52 | await new Promise((r, j) => (ws.once("open", r), ws.once("error", j), ws.once("close", (c) => j(new Error(`the stream was refused (${c})`))))); | |
| 53 | try { | |
| 54 | for (const t of targets(joint.lo, joint.hi)) { | |
| 55 | ws.send(JSON.stringify({ [joint.id]: t })); | |
| 56 | await sleep(hold); | |
| 57 | const now = await get<Status>(url, "/status"); | |
| 58 | const halt = stopped(now) ?? (closed !== null ? `the stream closed (${closed})` : null); | |
| 59 | if (halt) throw new Error(`stopped at ${joint.joint ?? joint.id}: ${halt}`); | |
| 60 | const snaps = (await get<Scorecard>(url, "/precision")).window.snapBacks.total; | |
| 61 | if (snaps > total) throw new Error(`stopped at ${joint.joint ?? joint.id}: the arm refused or held back a target (${snaps - total} snap-back)`); | |
| 62 | } | |
| 63 | return (await get<Scorecard>(url, "/precision")).session?.joints.find((j) => j.id === joint.id); | |
| 64 | } finally { | |
| 65 | ws.close(1000); | |
| 66 | await new Promise((r) => (ws.readyState === WebSocket.CLOSED ? r(null) : ws.once("close", r))); | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 46 | 70 | /** Drives every joint through its targets and answers the lines, one per joint. Throws, naming why, where it must stop. */ |
| 47 | 71 | export async function sweep(url: string, hold = HOLD_MS, out: (s: string) => void = () => {}): Promise<string[]> { |
| 48 | 72 | const status = await get<Status>(url, "/status"); |
| @@ -54,25 +78,7 @@ export async function sweep(url: string, hold = HOLD_MS, out: (s: string) => voi | ||
| 54 | 78 | const lines: string[] = []; |
| 55 | 79 | let total = before.snapBacks.total; |
| 56 | 80 | for (const joint of status.joints) { |
| 57 | if (joint.lo === null || joint.hi === null) throw new Error(`joint ${joint.id} has no bounds`); | |
| 58 | const ws = new WebSocket(`${url.replace(/^http/, "ws")}/follow`); | |
| 59 | let closed: number | null = null; | |
| 60 | ws.on("close", (code) => (closed = code)); | |
| 61 | await new Promise((r, j) => (ws.once("open", r), ws.once("error", j), ws.once("close", (c) => j(new Error(`the stream was refused (${c})`))))); | |
| 62 | try { | |
| 63 | for (const t of targets(joint.lo, joint.hi)) { | |
| 64 | ws.send(JSON.stringify({ [joint.id]: t })); | |
| 65 | await sleep(hold); | |
| 66 | const now = await get<Status>(url, "/status"); | |
| 67 | const halt = stopped(now) ?? (closed !== null ? `the stream closed (${closed})` : null); | |
| 68 | if (halt) throw new Error(`stopped at ${joint.joint ?? joint.id}: ${halt}`); | |
| 69 | const snaps = (await card(url)).snapBacks.total; | |
| 70 | if (snaps > total) throw new Error(`stopped at ${joint.joint ?? joint.id}: the arm refused or held back a target (${snaps - total} snap-back)`); | |
| 71 | } | |
| 72 | } finally { | |
| 73 | ws.close(1000); | |
| 74 | await new Promise((r) => (ws.readyState === WebSocket.CLOSED ? r(null) : ws.once("close", r))); | |
| 75 | } | |
| 81 | await driveJoint(url, joint, hold, total); | |
| 76 | 82 | await sleep(150); |
| 77 | 83 | const after = await card(url); |
| 78 | 84 | const l = line(joint.joint ?? String(joint.id), after.joints.find((j) => j.id === joint.id), after.snapBacks.total - total); |
new above
offline