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

Parked opened this
Closed without merging

smith/arm:smith/495-bench-limits-search-ts-url-runs-the-limi → main 2 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
4 files changed +194 −22
Software/README.md +4
@@ -1105,6 +1105,10 @@ Where to read it:
11051105 `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.
11061106 The sweep's targets are fixed; the measured numbers move a little with the machine's timers.
11071107
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
11081112 `bun bench/precision-report.ts <url>` (the arm server's URL) reads the hourly roll-up from `GET /precision` and prints
11091113 the weekly comment for #439: each headline number this week against last, the worst joint and the open experiment. It
11101114 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";
1616 export const OFFSETS = [0, 250, 0, -250, 0] as const;
1717 const HOLD_MS = 1500;
1818
19 interface Status {
19 export interface Status {
2020 joints: Array<{ id: number; joint: string | null; lo: number | null; hi: number | null }>;
2121 driving: string | null;
2222 held: unknown;
2323 estop: unknown;
2424 }
2525
26 const get = async <T>(url: string, path: string): Promise<T> => {
26 export const get = async <T>(url: string, path: string): Promise<T> => {
2727 const res = await fetch(url + path);
2828 if (!res.ok) throw new Error(`${path}: ${res.status}`);
2929 return (await res.json()) as T;
3030 };
3131 const card = async (url: string): Promise<Card> => (await get<Scorecard>(url, "/precision")).window;
3232 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);
3434
3535 /** The joint's targets: the offsets from the middle of its bounds, kept inside them. */
3636 export function targets(lo: number, hi: number): number[] {
@@ -43,6 +43,30 @@ export function line(name: string, j: JointCard | undefined, snaps: number): str
4343 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}`;
4444 }
4545
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
4670 /** Drives every joint through its targets and answers the lines, one per joint. Throws, naming why, where it must stop. */
4771 export async function sweep(url: string, hold = HOLD_MS, out: (s: string) => void = () => {}): Promise<string[]> {
4872 const status = await get<Status>(url, "/status");
@@ -54,25 +78,7 @@ export async function sweep(url: string, hold = HOLD_MS, out: (s: string) => voi
5478 const lines: string[] = [];
5579 let total = before.snapBacks.total;
5680 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);
7682 await sleep(150);
7783 const after = await card(url);
7884 const l = line(joint.joint ?? String(joint.id), after.joints.find((j) => j.id === joint.id), after.snapBacks.total - total);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs