|
117 | 117 | <template #item.4> |
118 | 118 | <v-card flat> |
119 | 119 | <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 & 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." /> |
124 | 123 | </div> |
125 | 124 |
|
| 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 | + |
126 | 143 | <v-row dense> |
127 | 144 | <v-col cols="12" md="5"> |
128 | 145 | <v-card variant="outlined"> |
|
135 | 152 | <div class="text-caption mb-1"><strong>Goal:</strong> {{ wizardStep.goal }}</div> |
136 | 153 | <div class="text-caption text-medium-emphasis mb-2">{{ wizardStep.instructions }}</div> |
137 | 154 | <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"> |
139 | 156 | <v-icon class="mr-1">mdi-record</v-icon> Run step & analyse |
140 | 157 | </v-btn> |
141 | 158 | <v-btn v-if="wizardStep.term && wizardStep.defaultStart !== undefined" size="small" variant="text" |
|
198 | 215 | <div class="d-flex flex-wrap mb-1"> |
199 | 216 | <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" /> |
200 | 217 | </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> |
202 | 219 | <div v-if="selectedDriver" class="text-caption text-medium-emphasis mt-1"><code>{{ capturePreview }}</code></div> |
203 | 220 | </v-expansion-panel-text> |
204 | 221 | </v-expansion-panel> |
@@ -321,6 +338,7 @@ import { |
321 | 338 | import { parseCapture, type ParsedCapture } from "../model/csv"; |
322 | 339 | import { analyzeCapture, type StepMetrics } from "../model/analysis"; |
323 | 340 | import { WIZARD_STEPS, type Recommendation } from "../model/wizard"; |
| 341 | +import { AUTOTUNE_SEQUENCE, describeMetrics, type Attempt, type TermStrategy } from "../model/autotune"; |
324 | 342 | import { applying, applyUpdateNow, dismissCurrentUpdate, pendingReload, updateState } from "../model/updateCheck"; |
325 | 343 |
|
326 | 344 | /* eslint-disable @typescript-eslint/no-explicit-any */ |
@@ -363,6 +381,12 @@ const viewKeys = ref<Array<string>>(["measuredMotorSteps", "targetMotorSteps", " |
363 | 381 | const wizardIndex = ref(0); |
364 | 382 | const recommendation = ref<Recommendation | null>(null); |
365 | 383 |
|
| 384 | +// --- Auto-tune --- |
| 385 | +const autoRunning = ref(false); |
| 386 | +const autoCancel = ref(false); |
| 387 | +const autoStatus = ref(""); |
| 388 | +const autoLog = ref<Array<string>>([]); |
| 389 | +
|
366 | 390 | const confirmOpen = ref(false); |
367 | 391 | const confirmCommand = ref(""); |
368 | 392 | let confirmAction: (() => Promise<void>) | null = null; |
@@ -540,6 +564,103 @@ function applySuggestion(): void { |
540 | 564 | } |
541 | 565 | } |
542 | 566 |
|
| 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 | +
|
543 | 664 | // --- Test & save --- |
544 | 665 | async function runTestMove(): Promise<void> { |
545 | 666 | moveMode.value = "custom"; |
@@ -614,4 +735,14 @@ watch(selectedDriver, (d) => { if (d) { void loadPid(); } }); |
614 | 735 | outline: 2px solid rgb(var(--v-theme-primary)); |
615 | 736 | border-radius: 4px; |
616 | 737 | } |
| 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 | +} |
617 | 748 | </style> |
0 commit comments