Skip to content

Commit 1aebed9

Browse files
committed
feat: scale-aware step-jump capture, live stage-status timeline, richer session report
Finishes the auto-tune overhaul with the smaller adaptivity/UX items: - src/model/scale.ts: the manual/wizard step-jump capture's distance and feedrate are now derived from the axis's actual steps-per-mm and microstepping (a "step jump" means the same physical thing — a handful of whole motor steps, over roughly the same amount of time — on a coarse leadscrew axis and a fine belt axis alike), replacing the fixed "16 full steps" formula's hardcoded F18000 feed. - Live stage-status timeline: a chip row (Preflight → P → D → I → A → V → Verify) shows each stage's live pending/running/done/failed state during an auto-tune run, via a new onStage callback on TuneEffects. - Richer session report: TuneSession now carries the Ku/Tu found during seeding, the calibration moves preflight actually ran, and whether the run's PID was restored from a snapshot — so a downloaded report explains what auto-tune did, not just what it ended up with.
1 parent 1f3bffe commit 1aebed9

5 files changed

Lines changed: 213 additions & 8 deletions

File tree

src/__tests__/autorun.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,50 @@ describe("runAutoTune — final verification", () => {
432432
expect(result.evaluation).toBeUndefined();
433433
});
434434
});
435+
436+
describe("runAutoTune — stage-status callbacks", () => {
437+
it("reports preflight, every term, and verify as running then done on a clean axis run", async () => {
438+
const stages: Array<string> = [];
439+
const onStage = vi.fn((stage: string, state: string) => stages.push(`${stage}:${state}`));
440+
const { effects } = fakeEffects({ onStage });
441+
const result = await runAutoTune(effects, basePid(), { cycles: 1, hasAxis: true });
442+
expect(result.ok).toBe(true);
443+
for (const stage of ["preflight", "p", "d", "i", "a", "v", "verify"]) {
444+
expect(stages).toContain(`${stage}:running`);
445+
expect(stages).toContain(`${stage}:done`);
446+
}
447+
// Every stage must report "running" before it reports "done".
448+
for (const stage of ["preflight", "p", "d", "i", "a", "v", "verify"]) {
449+
expect(stages.indexOf(`${stage}:running`)).toBeLessThan(stages.indexOf(`${stage}:done`));
450+
}
451+
});
452+
453+
it("marks the failing stage as failed, and never starts a later stage in the same run", async () => {
454+
const stages: Array<string> = [];
455+
const onStage = vi.fn((stage: string, state: string) => stages.push(`${stage}:${state}`));
456+
let preflightDone = false;
457+
const captureSignal = vi.fn(async () => {
458+
if (!preflightDone) { preflightDone = true; return GOOD_SIGNAL; } // let preflight's probe succeed
459+
return null; // everything afterwards (seeding + the P ramp) fails outright
460+
});
461+
const { effects } = fakeEffects({ onStage, captureSignal });
462+
const result = await runAutoTune(effects, basePid(), { cycles: 1, hasAxis: true });
463+
expect(result.ok).toBe(false);
464+
expect(stages).toContain("preflight:done");
465+
const failedStages = stages.filter((s) => s.endsWith(":failed"));
466+
expect(failedStages.length).toBe(1); // exactly one stage fails, and the run stops there
467+
expect(stages).not.toContain("i:running");
468+
expect(stages).not.toContain("a:running");
469+
expect(stages).not.toContain("v:running");
470+
expect(stages).not.toContain("verify:running");
471+
});
472+
473+
it("reports preflight as failed when the driver never starts tracking", async () => {
474+
const stages: Array<string> = [];
475+
const onStage = vi.fn((stage: string, state: string) => stages.push(`${stage}:${state}`));
476+
const { effects } = fakeEffects({ onStage, captureSignal: vi.fn(async () => sig({ stats: { movePeak: 500 } })) });
477+
const result = await runAutoTune(effects, basePid(), { cycles: 1, hasAxis: true, calibrationMoveIds: [] });
478+
expect(result.ok).toBe(false);
479+
expect(stages).toEqual(["preflight:running", "preflight:failed"]);
480+
});
481+
});

src/__tests__/scale.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { mmPerFullStep, stepJumpDistanceMm, stepJumpFeedMmPerMin } from "../model/scale";
4+
5+
describe("mmPerFullStep", () => {
6+
it("computes mm-per-full-step from steps-per-mm (incl. microstepping) and the microstepping factor", () => {
7+
// 1280 steps/mm at 16x microstepping = 80 full steps/mm = 0.0125 mm per full step.
8+
expect(mmPerFullStep({ stepsPerMm: 1280, microstepping: 16 })).toBeCloseTo(0.0125, 6);
9+
});
10+
it("falls back to a sane default when the object model hasn't reported values yet", () => {
11+
expect(mmPerFullStep({})).toBeCloseTo(16 / 80, 6);
12+
expect(mmPerFullStep({ stepsPerMm: 0, microstepping: undefined })).toBeCloseTo(16 / 80, 6);
13+
});
14+
it("ignores non-finite or non-positive readings and falls back", () => {
15+
expect(mmPerFullStep({ stepsPerMm: Number.NaN, microstepping: -5 })).toBeCloseTo(16 / 80, 6);
16+
});
17+
});
18+
19+
describe("stepJumpDistanceMm", () => {
20+
it("scales with the axis resolution — a coarser axis needs a bigger jump for the same step count", () => {
21+
const fine = stepJumpDistanceMm({ stepsPerMm: 1280, microstepping: 16 }); // 80 full steps/mm
22+
const coarse = stepJumpDistanceMm({ stepsPerMm: 160, microstepping: 16 }); // 10 full steps/mm
23+
expect(coarse).toBeGreaterThan(fine);
24+
expect(coarse).toBeCloseTo(fine * 8, 6); // 8x coarser pitch → 8x the distance for the same step count
25+
});
26+
it("never returns a distance below the floor, even at extreme resolution", () => {
27+
expect(stepJumpDistanceMm({ stepsPerMm: 100000, microstepping: 1 })).toBeGreaterThanOrEqual(0.1);
28+
});
29+
it("defaults to 16 full steps, but honours an explicit step count", () => {
30+
const scale = { stepsPerMm: 1280, microstepping: 16 };
31+
expect(stepJumpDistanceMm(scale, 32)).toBeCloseTo(stepJumpDistanceMm(scale, 16) * 2, 6);
32+
});
33+
});
34+
35+
describe("stepJumpFeedMmPerMin", () => {
36+
it("scales feed with distance so the move takes roughly the same time regardless of scale", () => {
37+
const small = stepJumpFeedMmPerMin(0.2);
38+
const large = stepJumpFeedMmPerMin(2.0);
39+
expect(large).toBeGreaterThan(small);
40+
});
41+
it("clamps to a sane floor for a tiny distance", () => {
42+
expect(stepJumpFeedMmPerMin(0.001)).toBeGreaterThanOrEqual(600);
43+
});
44+
it("clamps to a sane ceiling for a large distance", () => {
45+
expect(stepJumpFeedMmPerMin(1000)).toBeLessThanOrEqual(30000);
46+
});
47+
});

src/components/ClosedLoopTuning.vue

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@
159159
<v-spacer />
160160
<span class="text-caption text-medium-emphasis">{{ autoStatus }}</span>
161161
</div>
162+
<div v-if="autoRunning || tuneSession" class="d-flex flex-wrap ga-1 mt-2">
163+
<v-chip v-for="s in STAGE_ORDER" :key="s.id" size="small"
164+
:color="stageColor(stageStates[s.id])"
165+
:variant="stageStates[s.id] === 'pending' ? 'outlined' : 'flat'"
166+
:prepend-icon="stageIcon(stageStates[s.id])">{{ s.label }}</v-chip>
167+
</div>
162168
<div class="d-flex flex-wrap ga-1 mt-2">
163169
<v-chip v-for="t in pidSummary" :key="t.term" size="small"
164170
:color="autoRunning && wizardStep.term === t.term ? 'primary' : undefined"
@@ -436,6 +442,7 @@ import CaptureChart from "./CaptureChart.vue";
436442
import { evaluateTune, gradeColor, severityColor, severityIcon, type Term, type TuneEvaluation } from "../model/evaluate";
437443
import { CAPTURE_DIR, CONFIG_FILE, DOCS, LS_STATE, PLUGIN_ID } from "../model/constants";
438444
import { upsertTuneBlock } from "../model/config";
445+
import { stepJumpDistanceMm, stepJumpFeedMmPerMin } from "../model/scale";
439446
import {
440447
buildCalibrationCommand, buildCaptureCommand, buildModeCommand, buildPidCommand,
441448
CALIBRATION_MOVES, CAPTURE_VARIABLES, DEFAULT_MODE_D, ENCODER_TYPES, MODE_LABELS,
@@ -449,7 +456,7 @@ import {
449456
} from "../model/limits";
450457
import { WIZARD_STEPS, type Recommendation } from "../model/wizard";
451458
import { computeTuneSignal, type TuneSignal } from "../model/signal";
452-
import { runAutoTune as runAutoTuneCore, type TuneEffects } from "../model/autorun";
459+
import { runAutoTune as runAutoTuneCore, type AutoRunResult, type StageId, type StageState, type TuneEffects } from "../model/autorun";
453460
import { applying, applyUpdateNow, checking, dismissCurrentUpdate, pendingReload, runUpdateCheck, setUpdateChecksEnabled, updateChecksEnabled, updateState } from "../model/updateCheck";
454461
455462
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -529,6 +536,30 @@ const autoRunning = ref(false);
529536
const autoCancel = ref(false);
530537
const autoStatus = ref("");
531538
const autoLog = ref<Array<string>>([]);
539+
540+
// Stage-status timeline: preflight → P → D → I → A → V → verify (P–V repeat every cycle).
541+
const STAGE_ORDER: Array<{ id: StageId; label: string }> = [
542+
{ id: "preflight", label: "Preflight" }, { id: "p", label: "P" }, { id: "d", label: "D" },
543+
{ id: "i", label: "I" }, { id: "a", label: "A" }, { id: "v", label: "V" }, { id: "verify", label: "Verify" },
544+
];
545+
const stageStates = reactive<Record<StageId, StageState>>({ preflight: "pending", p: "pending", d: "pending", i: "pending", a: "pending", v: "pending", verify: "pending" });
546+
function resetStageStates(): void { for (const s of STAGE_ORDER) { stageStates[s.id] = "pending"; } }
547+
function stageColor(state: StageState): string | undefined {
548+
switch (state) {
549+
case "running": return "primary";
550+
case "done": return "success";
551+
case "failed": return "error";
552+
default: return undefined;
553+
}
554+
}
555+
function stageIcon(state: StageState): string {
556+
switch (state) {
557+
case "running": return "mdi-progress-clock";
558+
case "done": return "mdi-check-circle";
559+
case "failed": return "mdi-close-circle";
560+
default: return "mdi-circle-outline";
561+
}
562+
}
532563
const avDistance = ref(saved.avDistance ?? 50); // mm — A/V test move length
533564
const avFeed = ref(saved.avFeed ?? 6000); // mm/min — A/V test move feedrate
534565
const cycles = ref(saved.cycles ?? 3); // how many times to iterate P→D→I
@@ -578,6 +609,12 @@ interface TuneSession {
578609
startedAt: string; finishedAt?: string; driver: string | null; mode: LoopMode | null;
579610
encoderType: EncoderType; cycles: number; finalPid?: PidConfig; log: Array<string>;
580611
captures: Array<SessionCapture>; evaluation?: TuneEvaluation | null;
612+
/** Ultimate gain/period found during Ku/Tu seeding, when it succeeded. */
613+
ku?: number; tu?: number;
614+
/** Calibration moves (M569.6 V-ids) actually run during preflight. */
615+
preflightActions?: Array<string>;
616+
/** True if the run failed/was cancelled and the pre-run PID snapshot was restored. */
617+
restored?: boolean;
581618
}
582619
const tuneSession = ref<TuneSession | null>(null);
583620
function recordSessionCapture(phase: string, value: number | undefined, metrics: unknown): void {
@@ -893,9 +930,7 @@ async function captureStep(): Promise<StepMetrics | null> {
893930
const stepVars = varIds(["measuredMotorSteps", "targetMotorSteps", "currentError", "pidPTerm"]);
894931
let c: ParsedCapture | null;
895932
if (ax?.letter) {
896-
const stepsPerMm = Number(ax.stepsPerMm) || 80;
897-
const micro = Number(ax.microstepping?.value) || 16;
898-
const desired = Math.max(0.1, (micro / stepsPerMm) * 16); // ~16 full steps ≈ a step jump
933+
const desired = stepJumpDistanceMm({ stepsPerMm: Number(ax.stepsPerMm), microstepping: Number(ax.microstepping?.value) });
899934
let dist = desired;
900935
let sign: 1 | -1 = 1;
901936
if (limits) {
@@ -904,9 +939,10 @@ async function captureStep(): Promise<StepMetrics | null> {
904939
dist = plan.distance; sign = plan.sign;
905940
}
906941
const signedDist = sign * dist;
907-
const move = `G91 G1 H2 ${ax.letter}${signedDist.toFixed(3)} F18000 G90`;
942+
const feed = stepJumpFeedMmPerMin(dist).toFixed(0);
943+
const move = `G91 G1 H2 ${ax.letter}${signedDist.toFixed(3)} F${feed} G90`;
908944
c = await runCapture({ driver: selectedDriver.value ?? "", samples: samples.value, activate: 1, rate: sampleRate.value, variables: stepVars, manoeuvre: 0, move });
909-
try { await machineStore.sendCode(`G91 G1 H2 ${ax.letter}${(-signedDist).toFixed(3)} F18000 G90`, false, false); } catch { /* return move */ }
945+
try { await machineStore.sendCode(`G91 G1 H2 ${ax.letter}${(-signedDist).toFixed(3)} F${feed} G90`, false, false); } catch { /* return move */ }
910946
} else {
911947
c = await runCapture({ driver: selectedDriver.value ?? "", samples: samples.value, activate: 0, rate: sampleRate.value, variables: stepVars, manoeuvre: 64 });
912948
}
@@ -993,6 +1029,7 @@ function buildTuneEffects(): TuneEffects {
9931029
wizardIndex.value = WIZARD_STEPS.findIndex((s) => s.term === term);
9941030
recordSessionCapture(term, value, metric);
9951031
},
1032+
onStage: (stage, state) => { stageStates[stage] = state; },
9961033
isCancelled: () => autoCancel.value,
9971034
delay,
9981035
};
@@ -1017,15 +1054,17 @@ async function runAutoTune(): Promise<void> {
10171054
autoRunning.value = true;
10181055
autoCancel.value = false;
10191056
autoLog.value = [];
1057+
resetStageStates();
10201058
viewKeys.value = ["measuredMotorSteps", "targetMotorSteps", "currentError"];
10211059
const totalCycles = Math.max(1, Math.round(cycles.value || 1));
10221060
const hasAxis = !!axisLetterForDriver();
10231061
tuneSession.value = {
10241062
startedAt: new Date().toISOString(), driver: selectedDriver.value, mode: currentMode.value,
10251063
encoderType: encoderType.value, cycles: totalCycles, log: [], captures: [],
10261064
};
1065+
let result: AutoRunResult | undefined;
10271066
try {
1028-
const result = await runAutoTuneCore(buildTuneEffects(), { ...pid }, { cycles: totalCycles, hasAxis, calibrationMoveIds: requiredMoveIds.value });
1067+
result = await runAutoTuneCore(buildTuneEffects(), { ...pid }, { cycles: totalCycles, hasAxis, calibrationMoveIds: requiredMoveIds.value });
10291068
Object.assign(pid, result.pid);
10301069
if (result.ok) {
10311070
const gradeNote = result.evaluation ? ` Final grade: ${result.evaluation.grade} (${result.evaluation.score}/100).` : "";
@@ -1045,7 +1084,11 @@ async function runAutoTune(): Promise<void> {
10451084
tuneSession.value.finishedAt = new Date().toISOString();
10461085
tuneSession.value.finalPid = { ...pid };
10471086
tuneSession.value.log = [...autoLog.value];
1048-
tuneSession.value.evaluation = evaluation.value;
1087+
tuneSession.value.evaluation = result?.evaluation ?? evaluation.value;
1088+
tuneSession.value.ku = result?.ku;
1089+
tuneSession.value.tu = result?.tu;
1090+
tuneSession.value.preflightActions = result?.preflightActions;
1091+
tuneSession.value.restored = result?.restored;
10491092
}
10501093
}
10511094
}

src/model/autorun.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,16 @@ export interface TuneEffects {
6363
status(line: string): void;
6464
/** Notified after every capture that feeds a decision (session recording, wizard-step highlighting). */
6565
onAttempt?(term: PidTerm, value: number, metric: TuneSignal | StepMetrics): void;
66+
/** Notified on stage transitions (preflight → P → D → I → A → V → verify) — drives a progress UI. */
67+
onStage?(stage: StageId, state: StageState): void;
6668
isCancelled(): boolean;
6769
delay(ms: number): Promise<void>;
6870
}
6971

72+
/** The stages auto-tune moves through, in order (P–V repeat every cycle). */
73+
export type StageId = "preflight" | PidTerm | "verify";
74+
export type StageState = "pending" | "running" | "done" | "failed";
75+
7076
export type SeedRule = "tyreus-luyben" | "zn-classic" | "amigo";
7177

7278
export interface AutoRunOptions {
@@ -379,7 +385,9 @@ async function runAxisCycle(
379385
const startValue = cycle === 1
380386
? (isFeedForward ? strategy.start : (seeded[strategy.term] ?? strategy.start))
381387
: pid[strategy.term];
388+
effects.onStage?.(strategy.term, "running");
382389
const result = await runSignalTerm(effects, strategy, pid, medianOf, verifyRetries, startValue);
390+
effects.onStage?.(strategy.term, result.ok ? "done" : "failed");
383391
attempts.push(...result.attempts);
384392
if (!result.ok) { return { ok: false, reason: result.reason, attempts, ku, tu }; }
385393
if (result.finalSignal) { lastSignal = result.finalSignal; }
@@ -393,7 +401,9 @@ async function runExtruderCycle(effects: TuneEffects, pid: PidConfig, cycle: num
393401
for (const strategy of AUTOTUNE_SEQUENCE) {
394402
if (effects.isCancelled()) { return { ok: false, reason: "Cancelled.", attempts }; }
395403
const startValue = cycle === 1 ? strategy.start : pid[strategy.term];
404+
effects.onStage?.(strategy.term, "running");
396405
const result = await runStepTerm(effects, strategy, pid, startValue);
406+
effects.onStage?.(strategy.term, result.ok ? "done" : "failed");
397407
attempts.push(...result.attempts);
398408
if (!result.ok) { return { ok: false, reason: result.reason, attempts }; }
399409
}
@@ -547,7 +557,9 @@ export async function runAutoTune(effects: TuneEffects, startPid: PidConfig, opt
547557
let ok = true;
548558
let reason: string | undefined;
549559
try {
560+
effects.onStage?.("preflight", "running");
550561
const pre = await preflight(effects, opts.hasAxis, opts.calibrationMoveIds ?? []);
562+
effects.onStage?.("preflight", pre.ok ? "done" : "failed");
551563
preflightActions = pre.actions;
552564
if (!pre.ok) {
553565
// Preflight's own probe may already have applied a baseline PID to the firmware — fall through
@@ -588,11 +600,14 @@ export async function runAutoTune(effects: TuneEffects, startPid: PidConfig, opt
588600
}
589601

590602
let evaluation: TuneEvaluation | undefined;
603+
effects.onStage?.("verify", "running");
591604
try {
592605
const verification = await runFinalVerification(effects, pid);
593606
evaluation = verification.evaluation;
607+
effects.onStage?.("verify", "done");
594608
} catch (e) {
595609
effects.log(`Final verification skipped: ${e instanceof Error ? e.message : String(e)}`);
610+
effects.onStage?.("verify", "failed");
596611
}
597612
return { ok: true, pid, attempts, ku, tu, preflightActions, evaluation };
598613
}

src/model/scale.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Machine-scale-aware capture parameters — pure, unit-tested, so a "step jump" test move means the same
3+
* physical thing (a handful of whole motor steps, over roughly the same amount of time) on a coarse-pitch
4+
* leadscrew axis and a fine-pitch belt axis alike, instead of a fixed distance/feedrate tuned against
5+
* whatever machine the plugin happened to be developed on.
6+
*/
7+
8+
export interface AxisScaleInfo {
9+
/** Steps-per-mm INCLUDING microstepping (RRF `M92` value — the object model's `axis.stepsPerMm`). */
10+
stepsPerMm?: number;
11+
/** Microstepping factor (RRF `M350` value — the object model's `axis.microstepping.value`). */
12+
microstepping?: number;
13+
}
14+
15+
// Fallbacks for when the object model hasn't reported a value yet — a common, unremarkable 20-tooth
16+
// GT2/80-steps-per-mm-equivalent belt axis at 16x, so a missing reading degrades to a reasonable jump
17+
// rather than an error.
18+
const DEFAULT_STEPS_PER_MM = 80;
19+
const DEFAULT_MICROSTEPPING = 16;
20+
21+
function resolve(value: number | undefined, fallback: number): number {
22+
return value != null && Number.isFinite(value) && value > 0 ? value : fallback;
23+
}
24+
25+
/** mm covered by one full (whole) motor step, given the axis's configured resolution. */
26+
export function mmPerFullStep(scale: AxisScaleInfo): number {
27+
const stepsPerMm = resolve(scale.stepsPerMm, DEFAULT_STEPS_PER_MM);
28+
const microstepping = resolve(scale.microstepping, DEFAULT_MICROSTEPPING);
29+
return microstepping / stepsPerMm;
30+
}
31+
32+
const DEFAULT_STEP_JUMP_FULL_STEPS = 16;
33+
const MIN_STEP_JUMP_MM = 0.1;
34+
35+
/** Distance (mm) covered by `fullSteps` whole motor steps — the size of a genuine "step jump" test move. */
36+
export function stepJumpDistanceMm(scale: AxisScaleInfo, fullSteps = DEFAULT_STEP_JUMP_FULL_STEPS): number {
37+
return Math.max(MIN_STEP_JUMP_MM, mmPerFullStep(scale) * fullSteps);
38+
}
39+
40+
const STEP_JUMP_TIME_S = 0.05; // aim for the jump itself to take about this long, at any machine scale
41+
const STEP_JUMP_FEED_MIN = 600; // mm/min floor — a tiny jump still gets a sane, resolvable feedrate
42+
const STEP_JUMP_FEED_MAX = 30000; // mm/min ceiling — stay within what a typical machine can actually reach
43+
44+
/**
45+
* Feedrate (mm/min) for a step-jump test move, scaled so the move itself takes roughly the same amount
46+
* of time regardless of the axis's resolution — replacing a fixed feedrate that made a coarse-pitch
47+
* axis's "step" move too fast to resolve in the capture window, and a fine-pitch axis's too slow to
48+
* look anything like a step.
49+
*/
50+
export function stepJumpFeedMmPerMin(distanceMm: number): number {
51+
const feed = (distanceMm / STEP_JUMP_TIME_S) * 60;
52+
return Math.min(STEP_JUMP_FEED_MAX, Math.max(STEP_JUMP_FEED_MIN, feed));
53+
}

0 commit comments

Comments
 (0)