Skip to content

Commit 67edde9

Browse files
jaysukclaude
andcommitted
feat: automatic PID auto-tune (P -> D -> I, hands-off)
Add a one-click Auto-tune that cycles each PID term automatically with minimal user interaction: - New pure, unit-tested controller (model/autotune.ts): per-term strategies (P/D/I) that, given the attempts so far, decide increase/accept/back-off. P raises until rise time plateaus or it oscillates; D raises until overshoot is within target; I raises until steady-state error clears. Every term is bounded (value caps + max attempts) and backs off on oscillation. - Component drives it: an awaitable step capture (waits on the closed-loop run counter, loads + analyses the CSV), applied in a loop across P->D->I, with a live status + log, a single confirm (axis-will-move) and an Abort button. Manual per-term tuning still available underneath. 41 tests green; typecheck + verify-build OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 55c69b1 commit 67edde9

3 files changed

Lines changed: 352 additions & 6 deletions

File tree

src/__tests__/autotune.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { D_STRATEGY, I_STRATEGY, P_STRATEGY, describeMetrics, type Attempt } from "../model/autotune";
4+
import type { StepMetrics } from "../model/analysis";
5+
6+
function m(over: Partial<StepMetrics>): StepMetrics {
7+
return { stepSize: 4, riseTime: 0.02, overshootPct: 0, settlingTime: 0.03, steadyStateError: 0, peakError: 0.1, rmsError: 0.05, oscillations: 0, hasStep: true, ...over };
8+
}
9+
const at = (value: number, metrics: StepMetrics): Attempt => ({ value, metrics });
10+
11+
describe("P strategy", () => {
12+
it("fails when there is no step", () => {
13+
expect(P_STRATEGY.decide([at(30, m({ hasStep: false }))]).kind).toBe("fail");
14+
});
15+
it("keeps increasing while rise time improves", () => {
16+
const d = P_STRATEGY.decide([at(30, m({ riseTime: 0.05 })), at(50, m({ riseTime: 0.03 }))]);
17+
expect(d.kind).toBe("set");
18+
if (d.kind === "set") expect(d.value).toBeGreaterThan(50);
19+
});
20+
it("accepts when rise time plateaus", () => {
21+
const d = P_STRATEGY.decide([at(100, m({ riseTime: 0.021 })), at(125, m({ riseTime: 0.0205 }))]);
22+
expect(d.kind).toBe("accept");
23+
if (d.kind === "accept") expect(d.value).toBe(125);
24+
});
25+
it("backs off after oscillation", () => {
26+
const d = P_STRATEGY.decide([at(100, m({ riseTime: 0.02, oscillations: 1 })), at(150, m({ oscillations: 12, overshootPct: 70 }))]);
27+
expect(d.kind).toBe("accept");
28+
if (d.kind === "accept") expect(d.value).toBe(100); // last stable
29+
});
30+
});
31+
32+
describe("D strategy", () => {
33+
it("accepts when overshoot is within target", () => {
34+
expect(D_STRATEGY.decide([at(0.2, m({ overshootPct: 5 }))]).kind).toBe("accept");
35+
});
36+
it("increases D while overshoot is high", () => {
37+
const d = D_STRATEGY.decide([at(0, m({ overshootPct: 25 }))]);
38+
expect(d.kind).toBe("set");
39+
if (d.kind === "set") expect(d.value).toBeGreaterThan(0);
40+
});
41+
it("backs off when D causes ringing", () => {
42+
const d = D_STRATEGY.decide([at(0.2, m({ overshootPct: 20, oscillations: 2 })), at(0.3, m({ overshootPct: 20, oscillations: 16 }))]);
43+
expect(d.kind).toBe("accept");
44+
if (d.kind === "accept") expect(d.value).toBe(0.2);
45+
});
46+
});
47+
48+
describe("I strategy", () => {
49+
it("accepts when steady-state error is small", () => {
50+
expect(I_STRATEGY.decide([at(1000, m({ steadyStateError: 0.05 }))]).kind).toBe("accept");
51+
});
52+
it("starts at 1000 from zero when error remains", () => {
53+
const d = I_STRATEGY.decide([at(0, m({ steadyStateError: 0.5 }))]);
54+
expect(d.kind).toBe("set");
55+
if (d.kind === "set") expect(d.value).toBe(1000);
56+
});
57+
it("backs off when I oscillates", () => {
58+
const d = I_STRATEGY.decide([at(1000, m({ steadyStateError: 0.4, oscillations: 2 })), at(1500, m({ steadyStateError: 0.4, oscillations: 16 }))]);
59+
expect(d.kind).toBe("accept");
60+
if (d.kind === "accept") expect(d.value).toBe(1000);
61+
});
62+
});
63+
64+
describe("describeMetrics", () => {
65+
it("summarises a capture in one line", () => {
66+
expect(describeMetrics(m({ riseTime: 0.02, overshootPct: 10, steadyStateError: 0.1, oscillations: 3 }))).toContain("rise 20ms");
67+
});
68+
});

src/components/ClosedLoopTuning.vue

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,29 @@
117117
<template #item.4>
118118
<v-card flat>
119119
<div class="text-body-2 mb-3">
120-
Tune one term at a time using a step response. Work through them in order — each "Run step &amp; analyse"
121-
applies a sudden 4-step target change, measures the response, and tells you whether to raise, lower or
122-
keep the term. Set motor current to its final value before tuning.
123-
<HelpTip :href="DOCS.tuning" text="Order: P (fastest rise without oscillation) → D (remove overshoot) → I (remove steady-state error) → optional A/V feed-forward for moving loads. Click for the full guide with example plots." />
120+
Set the motor current to its final value, make sure the axis has room to move, then let
121+
<strong>Auto-tune</strong> do the work — or tune one term at a time manually below.
122+
<HelpTip :href="DOCS.tuning" text="Order: P (fastest rise without oscillation) → D (remove overshoot) → I (remove steady-state error). Auto-tune cycles each term automatically, running a step capture between every change and backing off on oscillation. Click for the full guide." />
124123
</div>
125124

125+
<!-- Auto-tune -->
126+
<v-card variant="outlined" class="mb-3" :color="autoRunning ? 'primary' : undefined">
127+
<v-card-text>
128+
<div class="d-flex align-center flex-wrap ga-2">
129+
<v-btn color="primary" :disabled="!selectedDriver || autoRunning || recording" :loading="autoRunning" prepend-icon="mdi-auto-fix" @click="startAutoTune">
130+
Auto-tune (P → D → I)
131+
</v-btn>
132+
<v-btn v-if="autoRunning" color="error" variant="tonal" prepend-icon="mdi-stop" @click="abortAutoTune">Abort</v-btn>
133+
<HelpTip :href="DOCS.tuning" text="Fully automatic: cycles P then D then I, running a step capture after each change and converging when the response stops improving. Bounded and backs off on oscillation. Keep an emergency stop handy the first time." />
134+
<v-spacer />
135+
<span class="text-caption text-medium-emphasis">{{ autoStatus }}</span>
136+
</div>
137+
<div v-if="autoLog.length" class="cl-autolog mt-2">
138+
<div v-for="(line, idx) in autoLog" :key="idx">{{ line }}</div>
139+
</div>
140+
</v-card-text>
141+
</v-card>
142+
126143
<v-row dense>
127144
<v-col cols="12" md="5">
128145
<v-card variant="outlined">
@@ -135,7 +152,7 @@
135152
<div class="text-caption mb-1"><strong>Goal:</strong> {{ wizardStep.goal }}</div>
136153
<div class="text-caption text-medium-emphasis mb-2">{{ wizardStep.instructions }}</div>
137154
<div class="d-flex ga-2 align-center mb-2">
138-
<v-btn size="small" color="info" :disabled="!selectedDriver || recording" :loading="recording" @click="runWizardCapture">
155+
<v-btn size="small" color="info" :disabled="!selectedDriver || recording || autoRunning" :loading="recording" @click="runWizardCapture">
139156
<v-icon class="mr-1">mdi-record</v-icon> Run step &amp; analyse
140157
</v-btn>
141158
<v-btn v-if="wizardStep.term && wizardStep.defaultStart !== undefined" size="small" variant="text"
@@ -198,7 +215,7 @@
198215
<div class="d-flex flex-wrap mb-1">
199216
<v-checkbox v-for="v in captureVariables" :key="v.key" v-model="recordKeys" :value="v.key" :label="v.header" density="compact" hide-details class="cl-var" />
200217
</div>
201-
<v-btn size="small" color="info" :disabled="!canRecord || recording" :loading="recording" @click="record()"><v-icon class="mr-1">mdi-record</v-icon> Record</v-btn>
218+
<v-btn size="small" color="info" :disabled="!canRecord || recording || autoRunning" :loading="recording" @click="record()"><v-icon class="mr-1">mdi-record</v-icon> Record</v-btn>
202219
<div v-if="selectedDriver" class="text-caption text-medium-emphasis mt-1"><code>{{ capturePreview }}</code></div>
203220
</v-expansion-panel-text>
204221
</v-expansion-panel>
@@ -321,6 +338,7 @@ import {
321338
import { parseCapture, type ParsedCapture } from "../model/csv";
322339
import { analyzeCapture, type StepMetrics } from "../model/analysis";
323340
import { WIZARD_STEPS, type Recommendation } from "../model/wizard";
341+
import { AUTOTUNE_SEQUENCE, describeMetrics, type Attempt, type TermStrategy } from "../model/autotune";
324342
import { applying, applyUpdateNow, dismissCurrentUpdate, pendingReload, updateState } from "../model/updateCheck";
325343
326344
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -363,6 +381,12 @@ const viewKeys = ref<Array<string>>(["measuredMotorSteps", "targetMotorSteps", "
363381
const wizardIndex = ref(0);
364382
const recommendation = ref<Recommendation | null>(null);
365383
384+
// --- Auto-tune ---
385+
const autoRunning = ref(false);
386+
const autoCancel = ref(false);
387+
const autoStatus = ref("");
388+
const autoLog = ref<Array<string>>([]);
389+
366390
const confirmOpen = ref(false);
367391
const confirmCommand = ref("");
368392
let confirmAction: (() => Promise<void>) | null = null;
@@ -540,6 +564,103 @@ function applySuggestion(): void {
540564
}
541565
}
542566
567+
// --- Auto-tune: run a step capture and resolve when its analysis is ready ---
568+
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
569+
570+
/** Wait for the board's closed-loop run counter to advance (capture finished), or time out. */
571+
async function waitForRuns(startRuns: number, timeoutMs: number): Promise<boolean> {
572+
const t0 = Date.now();
573+
while (Date.now() - t0 < timeoutMs) {
574+
if (autoCancel.value) { return false; }
575+
const r = selectedBoard.value?.closedLoop?.runs;
576+
if (r != null && r !== startRuns) { return true; }
577+
await delay(200);
578+
}
579+
return false;
580+
}
581+
582+
/** Fire one step-manoeuvre capture and return its analysis metrics (null on failure/timeout). */
583+
async function captureStep(): Promise<StepMetrics | null> {
584+
moveMode.value = "step";
585+
recordKeys.value = ["measuredMotorSteps", "targetMotorSteps", "currentError", "pidPTerm"];
586+
const startRuns = selectedBoard.value?.closedLoop?.runs ?? -1;
587+
const reply = await machineStore.sendCode(buildCaptureCommand(captureOptions()), false, false);
588+
if (reply && reply.startsWith("Error:")) {
589+
uiStore.makeNotification(LogLevel.error, "Closed Loop Tuning", reply);
590+
return null;
591+
}
592+
const captureMs = sampleRate.value > 0 ? (samples.value / sampleRate.value) * 1000 : 4000;
593+
if (!(await waitForRuns(startRuns, captureMs + 8000))) { return null; }
594+
await delay(300); // let the CSV finish writing
595+
await loadLatestCapture();
596+
return metrics.value;
597+
}
598+
599+
function log(line: string): void { autoLog.value = [...autoLog.value, line].slice(-40); }
600+
601+
/** Drive one term to convergence using its strategy. Returns false to abort the whole run. */
602+
async function autoTuneTerm(strategy: TermStrategy): Promise<boolean> {
603+
let value = strategy.start;
604+
const attempts: Array<Attempt> = [];
605+
for (let k = 0; k <= strategy.maxAttempts; k++) {
606+
if (autoCancel.value) { return false; }
607+
(pid as any)[strategy.term] = value;
608+
await applyPid();
609+
await delay(400); // settle
610+
autoStatus.value = `${strategy.label}: testing ${strategy.term.toUpperCase()}=${value}…`;
611+
const m = await captureStep();
612+
if (!m) { log(`${strategy.label}: capture failed — aborting.`); return false; }
613+
attempts.push({ value, metrics: m });
614+
log(`${strategy.label}: ${strategy.term.toUpperCase()}=${value} → ${describeMetrics(m)}`);
615+
const d = strategy.decide(attempts);
616+
if (d.kind === "fail") { log(`${strategy.label}: ${d.reason}`); return false; }
617+
if (d.kind === "accept") {
618+
(pid as any)[strategy.term] = d.value;
619+
await applyPid();
620+
log(`${strategy.label}: ✓ ${d.note}`);
621+
return true;
622+
}
623+
value = d.value;
624+
}
625+
return true;
626+
}
627+
628+
function startAutoTune(): void {
629+
if (!selectedDriver.value) { return; }
630+
askConfirm("Auto-tune will repeatedly move the driver to measure its step response", runAutoTune);
631+
}
632+
633+
async function runAutoTune(): Promise<void> {
634+
autoRunning.value = true;
635+
autoCancel.value = false;
636+
autoLog.value = [];
637+
wizardIndex.value = 0;
638+
try {
639+
// P: zero the other terms first so P is measured alone.
640+
pid.i = 0; pid.d = 0; pid.v = 0; pid.a = 0;
641+
await applyPid();
642+
for (const strategy of AUTOTUNE_SEQUENCE) {
643+
wizardIndex.value = AUTOTUNE_SEQUENCE.indexOf(strategy);
644+
if (autoCancel.value) { break; }
645+
const ok = await autoTuneTerm(strategy);
646+
if (!ok) { break; }
647+
}
648+
if (autoCancel.value) {
649+
autoStatus.value = "Auto-tune aborted.";
650+
} else {
651+
autoStatus.value = `Auto-tune complete — P=${pid.p} D=${pid.d} I=${pid.i}.`;
652+
uiStore.makeNotification(LogLevel.success, "Closed Loop Tuning", autoStatus.value + " Review the plot, then save to config.g.");
653+
}
654+
} catch (e) {
655+
console.warn("[ClosedLoopTuning] auto-tune failed", e);
656+
autoStatus.value = "Auto-tune stopped (see console).";
657+
} finally {
658+
autoRunning.value = false;
659+
}
660+
}
661+
662+
function abortAutoTune(): void { autoCancel.value = true; autoStatus.value = "Stopping after this capture…"; }
663+
543664
// --- Test & save ---
544665
async function runTestMove(): Promise<void> {
545666
moveMode.value = "custom";
@@ -614,4 +735,14 @@ watch(selectedDriver, (d) => { if (d) { void loadPid(); } });
614735
outline: 2px solid rgb(var(--v-theme-primary));
615736
border-radius: 4px;
616737
}
738+
.cl-autolog {
739+
max-height: 140px;
740+
overflow-y: auto;
741+
font-family: monospace;
742+
font-size: 0.74rem;
743+
line-height: 1.35;
744+
background: rgba(var(--v-theme-on-surface), 0.05);
745+
padding: 6px 8px;
746+
border-radius: 4px;
747+
}
617748
</style>

0 commit comments

Comments
 (0)