Public: anyone can read this; the crew can change it.

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Data view: a session's joint traces (intent against actual) #524

Parked opened this
Closed without merging

smith/arm:smith/521-data-view-a-session-s-joint-traces-inten → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
6 files changed +173 −5
Software/so101/src/precision-history.test.ts +33
@@ -115,6 +115,11 @@ describe("GET /precision on the arm server", () => {
115115 expect(((await (await fetch(`${base}/precision?hours=336`)).json()) as Scorecard).hours).toHaveLength(1);
116116 expect(after.session).toBeNull();
117117 const [row] = after.sessions;
118 const kept = (await (await fetch(`${base}/precision/trace?id=${row!.id}`)).json()) as { joints: Record<string, unknown[]>; events: Array<{ kind: string }> };
119 expect(kept.joints["1"]!.length).toBeGreaterThan(0);
120 expect(kept.events.map((e) => e.kind)).toContain("STOP");
121 expect((await fetch(`${base}/precision/trace?id=x`)).status).toBe(400);
122 expect((await fetch(`${base}/precision/trace?id=99999`)).status).toBe(404);
118123 expect(row).toMatchObject({ commit: "feed123", sessions: 1 });
119124 const causes = after.window.snapBacks.byCause;
120125 expect(causes.cap).toBe(1);
@@ -179,3 +184,31 @@ describe("GET /precision on the arm server", () => {
179184 expect(after.sessions.at(-1)).toMatchObject({ commit: "feed123", releases: 1 });
180185 });
181186 });
187
188 describe("the session trace", () => {
189 test("kept at 5 Hz with the marks, stored with the session, and dropped from the oldest beyond the newest 50", () => {
190 let now = Date.UTC(2026, 9, 22, 10, 0);
191 const history = new PrecisionHistory({ commit: "abc1234", now: () => now });
192 const t = history.begin();
193 t.target(1, 2048, now);
194 for (let ms = 0; ms < 1000; ms += 20) t.sample(1, 2000 + ms / 10, now + ms); // 50 read-backs in a second
195 t.release(now + 500);
196 t.snapBack("jam", now + 600, 1, 30);
197 now += 1000;
198 history.end(t);
199 const id = history.rows("sessions")[0]!.id!;
200 const trace = history.trace(id)!;
201 expect(trace.joints[1]).toHaveLength(5);
202 expect(trace.joints[1]![0]).toEqual([0, 2048, 2000]);
203 expect(trace.events).toEqual([{ at: 500, kind: "release", id: null }, { at: 600, kind: "jam", id: 1 }]);
204 expect(history.trace(id + 99)).toBeNull();
205 for (let i = 0; i < 50; i++) {
206 const u = history.begin();
207 u.target(1, 2048, now);
208 u.sample(1, 2000, now);
209 history.end(u);
210 }
211 expect(history.trace(id)).toBeNull();
212 expect(history.trace(id + 50)).not.toBeNull();
213 });
214 });
Software/so101/src/precision-history.ts +18 −5
@@ -8,7 +8,7 @@ import { openDb, type Db } from "./sqlite.ts";
88 import { existsSync, readFileSync } from "node:fs";
99 import { join } from "node:path";
1010 import type { PExperiment } from "./tuning-run.ts";
11 import { emptyRecords, summarize, trim, Tracker, WINDOW_MS, type Card, type Cause } from "./precision.ts";
11 import { emptyRecords, summarize, trim, Tracker, WINDOW_MS, type Card, type Cause, type Trace } from "./precision.ts";
1212
1313 /** The deployed commit: the forge's or CI's variable when set, else the checkout's HEAD, else "unknown". Never a shell. */
1414 export function deployedCommit(env: Record<string, string | undefined> = process.env, root = join(import.meta.dirname, "../../.."), tree = join(import.meta.dirname, "..")): string {
@@ -34,7 +34,12 @@ export function deployedCommit(env: Record<string, string | undefined> = process
3434 }
3535
3636 /** One stored drive session, or one hour of them: the headline numbers. */
37 /** How many of the newest sessions keep their traces; older rows keep the card alone. */
38 export const TRACES_KEPT = 50;
39
3740 export interface Row {
41 /** The stored session's id (what GET /precision/trace asks for); hourly rows have none. */
42 id?: number;
3843 at: string;
3944 commit: string;
4045 sessions: number;
@@ -66,8 +71,8 @@ export interface Scorecard {
6671 hours: Row[];
6772 }
6873
69 type Stored = { at: string; commit: string; sessions: number; seconds: number; tracking_p95_deg: number | null; tracking_max_deg: number | null; arrive_p95_ms: number | null; snap_backs: number; releases: number; latency_p95_ms: number | null; worst_joint: string | null };
70 const fromStored = (r: Stored): Row => ({ at: r.at, commit: r.commit, sessions: r.sessions, seconds: r.seconds, trackingP95Deg: r.tracking_p95_deg, trackingMaxDeg: r.tracking_max_deg, arriveP95Ms: r.arrive_p95_ms, snapBacks: r.snap_backs, releases: r.releases, latencyP95Ms: r.latency_p95_ms, worstJoint: r.worst_joint });
74 type Stored = { id?: number; at: string; commit: string; sessions: number; seconds: number; tracking_p95_deg: number | null; tracking_max_deg: number | null; arrive_p95_ms: number | null; snap_backs: number; releases: number; latency_p95_ms: number | null; worst_joint: string | null };
75 const fromStored = (r: Stored): Row => ({ ...(r.id === undefined ? {} : { id: r.id }), at: r.at, commit: r.commit, sessions: r.sessions, seconds: r.seconds, trackingP95Deg: r.tracking_p95_deg, trackingMaxDeg: r.tracking_max_deg, arriveP95Ms: r.arrive_p95_ms, snapBacks: r.snap_backs, releases: r.releases, latencyP95Ms: r.latency_p95_ms, worstJoint: r.worst_joint });
7176
7277 export class PrecisionHistory {
7378 readonly commit: string;
@@ -86,6 +91,7 @@ export class PrecisionHistory {
8691 const columns = `commit_id TEXT NOT NULL, sessions INTEGER NOT NULL, seconds REAL NOT NULL, tracking_p95_deg REAL, tracking_max_deg REAL, arrive_p95_ms REAL,
8792 snap_backs INTEGER NOT NULL, releases INTEGER NOT NULL, latency_p95_ms REAL, worst_joint TEXT`;
8893 this.#db.run(`CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, at TEXT NOT NULL, ${columns}, card TEXT NOT NULL)`);
94 if (!(this.#db.query("PRAGMA table_info(sessions)").all() as Array<{ name: string }>).some((c) => c.name === "trace")) this.#db.run("ALTER TABLE sessions ADD COLUMN trace TEXT");
8995 this.#db.run("CREATE TABLE IF NOT EXISTS tunings (id INTEGER PRIMARY KEY, commit_id TEXT NOT NULL, result TEXT NOT NULL)");
9096 // An hour's roll-up keeps the worst of its sessions' p95s: one bad session in the hour is what the trend must show.
9197 this.#db.run(`CREATE TABLE IF NOT EXISTS hours (at TEXT NOT NULL, ${columns}, PRIMARY KEY (at, commit_id))`);
@@ -125,7 +131,8 @@ export class PrecisionHistory {
125131 const maxDeg = card.joints.reduce<number | null>((m, j) => (j.errorDeg.max === null ? m : Math.max(m ?? 0, j.errorDeg.max)), null);
126132 const row = [this.commit, 1, seconds, card.trackingP95Deg, maxDeg, card.arriveP95Ms, card.snapBacks.total, card.releases, card.latencyP95Ms, card.worstJoint] as const;
127133 const iso = new Date(at).toISOString();
128 this.#db.query(`INSERT INTO sessions (at, commit_id, sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint, card) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(iso, ...row, JSON.stringify(card));
134 const { lastInsertRowid } = this.#db.query(`INSERT INTO sessions (at, commit_id, sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint, card, trace) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(iso, ...row, JSON.stringify(card), JSON.stringify(tracker.trace()));
135 this.#db.query("UPDATE sessions SET trace = NULL WHERE trace IS NOT NULL AND id <= ?").run(Number(lastInsertRowid) - TRACES_KEPT);
129136 const hour = `${iso.slice(0, 13)}:00:00.000Z`;
130137 this.#db
131138 .query(
@@ -146,12 +153,18 @@ export class PrecisionHistory {
146153 rows(table: "sessions" | "hours", limit = 48): Row[] {
147154 const sql =
148155 table === "sessions"
149 ? `SELECT at, commit_id AS "commit", sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint FROM sessions ORDER BY id DESC LIMIT ?`
156 ? `SELECT id, at, commit_id AS "commit", sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint FROM sessions ORDER BY id DESC LIMIT ?`
150157 : `SELECT at, commit_id AS "commit", sessions, seconds, tracking_p95_deg, tracking_max_deg, arrive_p95_ms, snap_backs, releases, latency_p95_ms, worst_joint FROM hours ORDER BY at DESC LIMIT ?`;
151158 const rows = this.#db.query(sql).all(limit) as Stored[];
152159 return rows.reverse().map(fromStored);
153160 }
154161
162 /** A stored session's traces and marks, or null when the session is unknown or old enough to have lost them. */
163 trace(id: number): Trace | null {
164 const row = this.#db.query("SELECT trace FROM sessions WHERE id = ?").get(id) as { trace: string | null } | null;
165 return row?.trace ? (JSON.parse(row.trace) as Trace) : null;
166 }
167
155168 /** Keep one finished P experiment. */
156169 addTuning(result: PExperiment): void {
157170 this.#db.query("INSERT INTO tunings (commit_id, result) VALUES (?, ?)").run(this.commit, JSON.stringify(result));
Software/so101/src/precision.ts +26
@@ -188,10 +188,22 @@ interface Joint {
188188 written?: { at: number; from: number };
189189 }
190190
191 /** The trace keeps a point per joint at most this often (5 Hz), and at most TRACE_POINTS of them (ten minutes of a drive). */
192 export const TRACE_STEP_MS = 200;
193 export const TRACE_POINTS = 3000;
194 /** One trace point: ms since the session began, the driver's target, the read-back; both in ticks. */
195 export type TracePoint = readonly [number, number, number];
196 /** A session's decimated traces by joint id, and the event marks to draw on the same axis (ms since the session began). */
197 export interface Trace {
198 joints: Record<number, TracePoint[]>;
199 events: Array<{ at: number; kind: "release" | Cause; id: number | null }>;
200 }
201
191202 /** One drive session's measurements; every record also goes to the sinks (the rolling window). */
192203 export class Tracker {
193204 readonly startedAt: number;
194205 readonly records = emptyRecords();
206 readonly #trace = new Map<number, TracePoint[]>();
195207 readonly #sinks: Records[];
196208 readonly #joints = new Map<number, Joint>();
197209
@@ -252,6 +264,10 @@ export class Tracker {
252264 if (j.target === undefined) return;
253265 const error = j.target - present;
254266 this.#put(id, "error", at, Math.abs(error));
267 let trace = this.#trace.get(id);
268 if (!trace) this.#trace.set(id, (trace = []));
269 const rel = at - this.startedAt;
270 if (trace.length < TRACE_POINTS && (!trace.length || rel - trace[trace.length - 1]![0] >= TRACE_STEP_MS)) trace.push([rel, j.target, present]);
255271 if (j.pending && j.pending.from === null) {
256272 // The first read-back: already there is no chase (it cannot be timed); otherwise the chase is on from here.
257273 if (Math.abs(error) <= ARRIVE_TICKS) j.pending = undefined;
@@ -324,6 +340,16 @@ export class Tracker {
324340 for (const [jid, j] of this.#joints) if (id === undefined || id === jid) (j.target = undefined), (j.pending = undefined), (j.settling = undefined);
325341 }
326342
343 /** The decimated traces and the marks (releases, snap-backs by cause) so far. */
344 trace(): Trace {
345 const rel = (at: number) => Math.max(0, at - this.startedAt);
346 const events: Trace["events"] = [
347 ...this.records.releases.map((at) => ({ at: rel(at), kind: "release" as const, id: null })),
348 ...this.records.snaps.map((s) => ({ at: rel(s.at), kind: s.cause, id: s.id })),
349 ].sort((a, b) => a.at - b.at);
350 return { joints: Object.fromEntries(this.#trace), events };
351 }
352
327353 /** Ends any settle watch still open, so the last arrival's overshoot counts. */
328354 finish(at: number): void {
329355 for (const [id, j] of this.#joints) if (j.settling) this.#settled(id, j, at);
Software/so101/src/server.ts +8
@@ -43,6 +43,7 @@
4343 * POST /tuning/p {"joint": id, "p": 1-64} run the P experiment (src/tuning-run.ts) on that joint while nobody drives: sweep, write, sweep, keep or put back; 202 at once,
4444 * 409 while a run is under way or anything drives, 423 on E-stop or hold; the result joins GET /precision's tunings
4545 * GET /tuning the run's phase while one is under way, and the last outcome
46 * GET /precision/trace?id=N a stored session's decimated joint traces (5 Hz, target against read-back) and event marks; the id is on its row in /precision; 404 once older than the newest 50
4647 * POST /precision/snap-back {"cause": one of CAUSES, "sizeDeg"}: a snap-back only the page saw, counted beside the server's own (400 for any other cause)
4748 * GET /model.json the arm model: joints, the model's floor (floorZ) and the enforced table (tableZ), where the URDF is served
4849 * GET /leader-map.js the leader calibration mapping, for the browser (src/leader-map.js)
@@ -890,6 +891,13 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
890891 if (!/^\d{1,3}$/.test(hours) || Number(hours) < 1 || Number(hours) > 720) throw new HttpError(400, "hours must be 1-720");
891892 return json(res, 200, precision.scorecard(Number(hours)));
892893 }
894 case "GET /precision/trace": {
895 const id = url.searchParams.get("id") ?? "";
896 if (!/^\d{1,9}$/.test(id)) throw new HttpError(400, "id is a stored session's id, from GET /precision");
897 const trace = precision.trace(Number(id));
898 if (!trace) throw new HttpError(404, "no trace kept for that session");
899 return json(res, 200, trace);
900 }
893901 case "POST /precision/snap-back": {
894902 // What only the page sees (a model or slider moved off the driver's target, a reconnect): counted beside the server's own.
895903 const snap = parseSnapBack(await readBody(req));
Software/so101/src/trace-chart.test.ts added +22
@@ -0,0 +1,22 @@
1 import { describe, expect, test } from "bun:test";
2 import { readTrace, traceSvg } from "./trace-chart.ts";
3
4 describe("the trace chart", () => {
5 const trace = { joints: { 1: [[0, 2048, 2000], [200, 2100, 2060], [400, 2100, 2100]] }, events: [{ at: 300, kind: "jam", id: 1 }, { at: 350, kind: "release", id: null }, { at: 100, kind: "cap", id: 2 }] };
6
7 test("draws target against read-back with only this joint's marks", () => {
8 const svg = traceSvg(readTrace(trace)!, 1);
9 expect(svg).toContain("trace-intent");
10 expect(svg).toContain("trace-actual");
11 expect(svg.match(/trace-mark/g)).toHaveLength(2);
12 expect(traceSvg(readTrace(trace)!, 3)).toContain("No trace kept");
13 });
14
15 test("reads an answer field by field: junk is dropped, markup never gets through", () => {
16 expect(readTrace("x")).toBeNull();
17 expect(readTrace({ joints: {}, events: [{ at: 1, kind: '"><script>', id: null }, { at: "1", kind: "jam" }] })!.events).toEqual([]);
18 const messy = readTrace({ joints: { 1: [[0, 1, 2], [0, "a", 2], [1, NaN, 2]], x: [[0, 1, 2]] }, events: [] })!;
19 expect(messy.joints[1]).toHaveLength(1);
20 expect(Object.keys(messy.joints)).toEqual(["1"]);
21 });
22 });
Software/so101/src/trace-chart.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * A session's joint trace drawn: the driver's target (dashed) against the arm's read-back (solid) for one joint, with
3 * the event marks (releases, snap-backs by cause) as vertical lines on the same time axis. The trace is the arm
4 * server's GET /precision/trace, read field by field (it comes over the network); the output is an SVG string in
5 * which every number is a number and every word is escaped. No script.
6 */
7
8 import { CAUSES, isCause, type Trace, type TracePoint } from "./precision.ts";
9 import { JOINTS } from "./sts3215.ts";
10
11 const TICKS_PER_DEGREE = 4096 / 360;
12 const MAX_POINTS = 3000;
13 const MAX_EVENTS = 500;
14
15 const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
16 const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
17
18 /** The trace from whatever was answered, bounded and checked; null when it was not a trace. */
19 export function readTrace(body: unknown): Trace | null {
20 if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
21 const { joints, events } = body as Record<string, unknown>;
22 if (typeof joints !== "object" || joints === null || !Array.isArray(events)) return null;
23 const out: Trace = { joints: {}, events: [] };
24 for (const [key, list] of Object.entries(joints)) {
25 const id = Number(key);
26 if (!Number.isInteger(id) || id < 0 || id > 253 || !Array.isArray(list)) continue;
27 out.joints[id] = list.slice(0, MAX_POINTS).filter((p): p is TracePoint => Array.isArray(p) && p.length === 3 && p.every(finite));
28 }
29 for (const e of events.slice(0, MAX_EVENTS)) {
30 const { at, kind, id } = (e ?? {}) as Record<string, unknown>;
31 if (finite(at) && (kind === "release" || isCause(kind))) out.events.push({ at, kind, id: Number.isInteger(id) ? (id as number) : null });
32 }
33 return out;
34 }
35
36 const WIDTH = 640;
37 const HEIGHT = 200;
38 const PAD = { l: 44, r: 8, t: 8, b: 20 };
39 const n = (v: number) => Number(v.toFixed(1));
40
41 /** One joint's target against read-back, in degrees over seconds, with the session's marks. Empty trace: a one-line note. */
42 export function traceSvg(trace: Trace, id: number): string {
43 const points = trace.joints[id] ?? [];
44 const name = JOINTS[id] ?? `joint ${id}`;
45 if (!points.length) return `<p class="trace-empty">No trace kept for ${escape(name)}.</p>`;
46 const end = Math.max(points[points.length - 1]![0], ...trace.events.map((e) => e.at), 1);
47 const deg = points.flatMap(([, a, b]) => [a / TICKS_PER_DEGREE, b / TICKS_PER_DEGREE]);
48 let lo = Math.min(...deg), hi = Math.max(...deg);
49 if (hi - lo < 1) (lo -= 0.5), (hi += 0.5);
50 const x = (ms: number) => n(PAD.l + (ms / end) * (WIDTH - PAD.l - PAD.r));
51 const y = (d: number) => n(HEIGHT - PAD.b - ((d - lo) / (hi - lo)) * (HEIGHT - PAD.t - PAD.b));
52 const line = (pick: 1 | 2) => points.map((p, i) => `${i ? "L" : "M"}${x(p[0])} ${y(p[pick] / TICKS_PER_DEGREE)}`).join("");
53 const marks = trace.events
54 .filter((e) => e.id === null || e.id === id)
55 .map((e) => `<line class="trace-mark" data-kind="${escape(e.kind)}" x1="${x(e.at)}" x2="${x(e.at)}" y1="${PAD.t}" y2="${HEIGHT - PAD.b}"><title>${escape(e.kind)} at ${n(e.at / 1000)} s</title></line>`)
56 .join("");
57 return (
58 `<svg class="trace" viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img" aria-label="${escape(name)}: target against read-back, degrees over ${n(end / 1000)} seconds">` +
59 `<text x="${PAD.l - 4}" y="${PAD.t + 8}" text-anchor="end">${n(hi)}°</text><text x="${PAD.l - 4}" y="${HEIGHT - PAD.b}" text-anchor="end">${n(lo)}°</text>` +
60 `<text x="${WIDTH - PAD.r}" y="${HEIGHT - 4}" text-anchor="end">${n(end / 1000)} s</text>` +
61 `${marks}<path class="trace-intent" fill="none" stroke="currentColor" stroke-dasharray="4 3" d="${line(1)}"/><path class="trace-actual" fill="none" stroke="currentColor" d="${line(2)}"/></svg>`
62 );
63 }
64
65 /** The kinds a mark can have, for a legend. */
66 export const MARK_KINDS: readonly string[] = ["release", ...CAUSES];

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs