Skip to content

feat: enable encoder closed-loop PID for filter wheel W/W2#542

Open
hongquanli wants to merge 2 commits into
fix/filter-wheel-absolute-movefrom
feat/filter-wheel-encoder-support
Open

feat: enable encoder closed-loop PID for filter wheel W/W2#542
hongquanli wants to merge 2 commits into
fix/filter-wheel-absolute-movefrom
feat/filter-wheel-encoder-support

Conversation

@hongquanli

@hongquanli hongquanli commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Enables the existing-but-dormant W/W2 encoder closed-loop path so the filter wheel physically reaches its commanded slot every move, with no error accumulation over many moves. Stacks on top of #540.

Why

The Cephla filter wheel has no detent — the motor itself must hold each slot position. With open-loop control and no encoder feedback (current default), two failure modes are not covered by #540:

  1. In-motion step loss: motor commanded but ramp completed with some pulses unrealized. XACTUAL still reads "at target", host trusts it, the wheel is off by one slot. fix: firmware reports failed moves (CMD_EXECUTION_ERROR); filter wheel uses absolute MOVETO #540's CMD_EXECUTION_ERROR doesn't catch this because the callback executed fine.
  2. Idle drift between moves: with no detent and motor de-energized after the ramp, external forces (vibration, optical coupling friction) can rotate the wheel between commanded moves. Next move's absolute target is now physically wrong.

Enabling the TMC4361A's hardware PID loop (already wired in firmware for X/Y/Z) closes both gaps. PID continuously drives ENC_POS → X_TARGET during the move and holds position at rest. Position error cannot accumulate because every commanded position is referenced to encoder counts, not to the previous (potentially drifted) XACTUAL.

What changed

Firmware

  • commands.cpp:135,139 — Widen W/W2 target_tolerance / pid_tolerance from 2 → 20 microsteps. Tolerance is in microstep units (XACTUAL vs ENC_POS, after the chip scales encoder counts to match). With 200 fullsteps/rev × 64 microstepping = 12,800 microsteps/rev, 20 microsteps ≈ 0.56° physical angle — well inside the 45°-per-slot accuracy needed for filter selection. The previous value of 2 (≈0.056°) was overkill for slot-level positioning and risked PID dither at rest.

Host

  • cephla.py:120-124Bug fix. Microcontroller.turn_on_stage_pid(self, axis) takes a single positional arg, but the call site passed (axis, ENABLE_PID_W). This was a latent TypeError that would fire the instant HAS_ENCODER_W=True. Fixed by dropping the extra arg and gating the call on if ENABLE_PID_W: — so HAS_ENCODER_W=True, ENABLE_PID_W=False is now a valid configuration (configure encoder for diagnostics, but leave PID disengaged).

Tests

  • test_filter_wheel.py — Patch ENABLE_PID_W=True in the existing PID-enable test so it still exercises the call, and assert assert_called_once_with(expected_axis) to guard against the old (axis, ENABLE_PID_W) signature regression. Add a new test for the HAS_ENCODER_W=True / ENABLE_PID_W=False cell. Both tests are parametrized over W (motor_slot=3) and W2 (motor_slot=4), matching the AXIS_PARAMS pattern used by the adjacent TestSquidFilterWheelAbsoluteMove class.

Bench validation (out of PR scope, machine-side)

Enabling on a real machine requires three machine-config / verification steps that don't belong in this PR:

  1. Set has_encoder_w = True and enable_pid_w = True under [GENERAL] in the machine .ini. The existing override loader in control/_def.py picks these up.
  2. Confirm transitions_per_revolution = 4000 (_def.py:1080,1087) matches the actual encoder CPR. If not, override per wheel in the multi-wheel YAML.
  3. Power up with the changes, command a small MOVE_W, read ENC_POS via SPI/tools. If encoder direction is opposite to commanded delta, flip ENCODER_FLIP_DIR_W = True in _def.py:695.

Relationship to #540

Complementary, not overlapping:

Failure mode Caught by #540 Caught by this PR
Move callback never executed (silent ack) CMD_EXECUTION_ERROR
Motor moved, but lost steps mid-ramp ❌ (XACTUAL still reads target) ✅ PID auto-corrects via ENC_POS
Wheel drifted while idle (no detent) ✅ PID holds position at rest
Relative-move chaining drift ✅ Absolute MOVETO ✅ (also via encoder anchoring)

#540's design is "containment via absolute addressing + report errors honestly". This PR adds "active correction via encoder", which is what a detent-less filter wheel actually needs.

Test plan

  • pytest tests/squid/test_filter_wheel.py — 28 passed (24 existing + 4 parametrized: PID-on × {W, W2} and PID-off × {W, W2})
  • black --config pyproject.toml --check clean on edited Python files
  • Bench: flash firmware to a test Teensy, enable flags in machine .ini, verify transitions_per_revolution and ENCODER_FLIP_DIR_W empirically
  • Bench: 100-cycle drift test (home → cycle through all 8 slots × 100 → verify encoder ends within tolerance of expected) to confirm error doesn't accumulate
  • Bench: listen for dither at rest after a move settles; if audible, loosen target_tolerance further before changing PID gains

🤖 Generated with Claude Code

Fixes a latent bug and tunes the existing-but-dormant W/W2 closed-loop
path so it can actually be enabled via per-machine INI flags.

- cephla.py: turn_on_stage_pid() takes axis only, but the call site
  passed (axis, ENABLE_PID_W). This was a TypeError waiting to fire the
  moment HAS_ENCODER_W=True. Gate the call on ENABLE_PID_W instead and
  drop the extra arg.
- commands.cpp: widen W/W2 PID target_tolerance from 2 → 20 microsteps
  (≈0.9° physical with 4000 transitions/rev, 8 slots). 2 microsteps is
  overkill for slot-level filter selection and risks dither at rest.
- test_filter_wheel.py: patch ENABLE_PID_W=True in the existing PID test
  and add a new test for the HAS_ENCODER_W=True / ENABLE_PID_W=False
  case (encoder configured, PID disabled). Assert the exact call
  signature to guard against the regression.

Enabling the closed loop on a machine still requires setting
has_encoder_w = True and enable_pid_w = True under [GENERAL] in the
machine .ini, plus bench-verifying transitions_per_revolution and
ENCODER_FLIP_DIR_W.

Stacks on top of #540 (firmware reports failed moves; absolute MOVETO
for filter wheel). #540 catches callback-level silent ack failures;
this PR adds in-motion step-loss correction and idle-drift holding via
the encoder, addressing the detent-less filter wheel's no-error-
accumulation requirement.
Both encoder/PID configuration tests previously ran only against the
default motor_slot=3 (W). Parametrize them over W and W2 so the W2
init path is also pinned against the turn_on_stage_pid signature
regression.

Mirrors the AXIS_PARAMS pattern used by the adjacent
TestSquidFilterWheelAbsoluteMove class.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Enables the previously-dormant W/W2 encoder closed-loop PID path on the filter wheel and fixes a latent host-side bug where turn_on_stage_pid was called with too many arguments. Firmware tolerance values are widened from 2→20 microsteps to avoid PID dither at rest while remaining well within slot-selection accuracy.

Changes:

  • Firmware: widen W/W2 target_tolerance/pid_tolerance from 2 to 20 microsteps in tmc4361A_init_PID.
  • Host: drop extra ENABLE_PID_W arg from turn_on_stage_pid call and gate the call on ENABLE_PID_W, allowing encoder-only configurations.
  • Tests: parametrize the PID-enable test over W/W2 with assert_called_once_with(expected_axis), plus a new test for HAS_ENCODER_W=True / ENABLE_PID_W=False.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
firmware/controller/src/commands/commands.cpp Widen W/W2 PID tolerances 2→20 microsteps.
software/squid/filter_wheel_controller/cephla.py Fix turn_on_stage_pid arity bug; gate enabling on ENABLE_PID_W.
software/tests/squid/test_filter_wheel.py Parametrize PID test over W/W2; add encoder-without-PID test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants