feat: Add second filter wheel (W2) support#478
Conversation
Add firmware and software support for a second filter wheel on motor slot 4: Firmware: - Add W2 motor slot (index 4) with CS pin 16 and clock pin 28 - Add INITFILTERWHEEL_W2 command (252) and MOVE_W2 command (19) - Add AXIS_W2 (6) for homing and configuration commands - Add W2 homing state machine (prepare, check, finalize) - Add W2 to callback_reset() and check_position() - Configure pin 28 as 16MHz clock output for W2's TMC4361A - Reduce camera triggers from 6 to 4 (pins 29-32) Software: - Add MOVE_W2, INITFILTERWHEEL_W2, AXIS.W2 constants - Add init_filter_wheel_w2(), move_w2_usteps(), home_w2() methods - Update SquidFilterWheel to initialize W2 on motor_slot 4 - Add tabbed UI for multiple filter wheels in widgets.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add skip_init parameter back to SquidFilterWheel constructor - Pass skip_init through get_filter_wheel_controller factory - Document that W and W2 share the same motor settings (identical hardware) - Remove unused R_sense_w2 constant (both wheels use R_sense_w) - Update comments in firmware to clarify shared settings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Extract common PID configuration in _configure_wheel (both wheels use identical settings) - Consolidate next_position/previous_position into _step_position helper - Replace print() with proper logging in FilterControllerWidget Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds end-to-end support for a second SQUID emission filter wheel (W2) by extending firmware motor control/protocol, updating software controller/configuration, and introducing a tabbed UI to control multiple wheels.
Changes:
- Firmware: add W2 axis support (CS=16, CLK=28), new commands/axis IDs, W2 homing, and reduce camera trigger outputs from 6 to 4.
- Software: add W2 commands/constants and microcontroller methods; extend SQUID filter wheel controller to support multiple wheels.
- UI/config: allow per-wheel SQUID configs and provide a tabbed filter-wheel widget.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| software/squid/filter_wheel_controller/utils.py | Passes multi-wheel configs to controller and sizes simulated controller to match configured wheels. |
| software/squid/filter_wheel_controller/cephla.py | Implements multi-wheel SQUID filter wheel control, including W2 routing. |
| software/squid/config.py | Adds per-wheel SQUID filter wheel configs in config model/loading. |
| software/control/widgets.py | Updates filter wheel widget to show tabs when multiple wheels are present. |
| software/control/microcontroller.py | Adds W2 init/move/home/zero/configure APIs and simulated position tracking. |
| software/control/_def.py | Adds protocol constants for MOVE_W2 / INITFILTERWHEEL_W2 / AXIS.W2 and default multi-wheel config. |
| firmware/controller/src/operations.h | Declares W2 homing state machine functions. |
| firmware/controller/src/operations.cpp | Implements W2 homing and reduces camera trigger loops to 4 channels. |
| firmware/controller/src/init.cpp | Adds W2 clock output on pin 28; adjusts initialization loops for new arrays. |
| firmware/controller/src/globals.h | Expands arrays/state to include W2 and adds enable flag. |
| firmware/controller/src/globals.cpp | Defines new W2 globals and expands arrays to size 5. |
| firmware/controller/src/functions.cpp | Reduces strobe ISR camera-channel loop to 4. |
| firmware/controller/src/def/def_v1.h | Adds W2 motor index and documents shared W/W2 constants. |
| firmware/controller/src/constants_protocol.h | Adds MOVE_W2 / INITFILTERWHEEL_W2 / AXIS_W2 protocol constants. |
| firmware/controller/src/constants.h | Reassigns pins (camera triggers -> 4ch) and adds W2 CS/CLK pin definitions. |
| firmware/controller/src/commands/stage_commands.h | Declares callback_move_w2. |
| firmware/controller/src/commands/stage_commands.cpp | Implements MOVE_W2 and adds W2 handling in several stage callbacks. |
| firmware/controller/src/commands/commands.h | Declares callback_initfilterwheel_w2. |
| firmware/controller/src/commands/commands.cpp | Registers MOVE_W2/INITFILTERWHEEL_W2 and implements initfilterwheel_w2; resets W2 state on reset. |
| firmware/controller/main_controller_teensy41.ino | Calls W2 homing functions in the main loop. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not skip_init: | ||
| # Initialize filter wheel hardware | ||
| self.microcontroller.init_filter_wheel() | ||
| time.sleep(0.5) |
There was a problem hiding this comment.
init_filter_wheel() is called unconditionally during init, even if the configured wheels only include motor_slot_index=4 (W2). This forces W (slot 3) to be enabled/initialized regardless of configuration. Consider initializing only the wheels actually present in self._configs (e.g., call init_filter_wheel() only when configuring a wheel with motor_slot_index==3, and init_filter_wheel_w2() only when motor_slot_index==4).
There was a problem hiding this comment.
[Claude Code] Fixed - init_filter_wheel(axis) is now called inside _configure_wheel() with the correct axis parameter. If only W2 (motor_slot_index=4) is configured, only W2 will be initialized. W is no longer unconditionally initialized.
| config = self._configs[wheel_id] | ||
| motor_slot = config.motor_slot_index | ||
|
|
||
| if motor_slot == 3: | ||
| self.microcontroller.home_w() | ||
| elif motor_slot == 4: | ||
| self.microcontroller.home_w2() | ||
| else: | ||
| raise ValueError(f"Unsupported motor_slot_index: {motor_slot}") |
There was a problem hiding this comment.
SquidFilterWheelConfig includes homing_enabled, but _home_wheel/home() always issues a home command regardless of that flag. This makes it impossible to disable homing per wheel via config. Please gate the homing sequence on config.homing_enabled (either no-op or raise a clear error when homing is requested but disabled).
There was a problem hiding this comment.
[Claude Code] N/A - The homing_enabled field does not exist in SquidFilterWheelConfig. The config only has: max_index, min_index, offset, motor_slot_index, transitions_per_revolution. This may be a feature for a different controller type.
| # Convert single config to dict format for uniform handling | ||
| if isinstance(configs, SquidFilterWheelConfig): | ||
| self._configs: Dict[int, SquidFilterWheelConfig] = {1: configs} | ||
| else: | ||
| self._configs = configs | ||
|
|
||
| # Track per-wheel positions (wheel_id -> position index) | ||
| self._positions: Dict[int, int] = {} | ||
|
|
||
| if not skip_init: | ||
| # Initialize filter wheel hardware | ||
| self.microcontroller.init_filter_wheel() | ||
| time.sleep(0.5) | ||
|
|
||
| # Configure each wheel | ||
| for wheel_id, config in self._configs.items(): | ||
| self._configure_wheel(wheel_id, config) | ||
| # Initialize position tracking to min_index | ||
| self._positions[wheel_id] = config.min_index | ||
| else: | ||
| # Just initialize position tracking without hardware init | ||
| for wheel_id, config in self._configs.items(): | ||
| self._positions[wheel_id] = config.min_index | ||
|
|
||
| self._available_filter_wheels: List[int] = [] | ||
|
|
||
| def _configure_wheel(self, wheel_id: int, config: SquidFilterWheelConfig): | ||
| """Configure a single filter wheel motor.""" | ||
| motor_slot = config.motor_slot_index | ||
|
|
||
| if motor_slot == 3: | ||
| # W axis (first filter wheel) | ||
| self.microcontroller.configure_squidfilter() | ||
| time.sleep(0.5) | ||
| elif motor_slot == 4: | ||
| # W2 axis (second filter wheel) | ||
| self.microcontroller.init_filter_wheel_w2() | ||
| time.sleep(0.5) | ||
| self.microcontroller.configure_squidfilter_w2() | ||
| time.sleep(0.5) | ||
| else: | ||
| raise ValueError(f"Unsupported motor_slot_index: {motor_slot}. Expected 3 (W) or 4 (W2).") | ||
|
|
||
| if HAS_ENCODER_W: | ||
| self.microcontroller.set_pid_arguments(self._config.motor_slot_index, PID_P_W, PID_I_W, PID_D_W) | ||
| self.microcontroller.configure_stage_pid( | ||
| self._config.motor_slot_index, self._config.transitions_per_revolution, ENCODER_FLIP_DIR_W | ||
| ) | ||
| self.microcontroller.turn_on_stage_pid(self._config.motor_slot_index, ENABLE_PID_W) | ||
| # Common PID setup for both wheels (they share identical encoder settings) | ||
| if HAS_ENCODER_W: | ||
| self.microcontroller.set_pid_arguments(motor_slot, PID_P_W, PID_I_W, PID_D_W) | ||
| self.microcontroller.configure_stage_pid(motor_slot, config.transitions_per_revolution, ENCODER_FLIP_DIR_W) | ||
| self.microcontroller.turn_on_stage_pid(motor_slot, ENABLE_PID_W) | ||
|
|
||
| # emission filter position | ||
| self.w_pos_index = self._config.min_index | ||
| self._available_filter_wheels = [] | ||
| def _move_wheel(self, wheel_id: int, delta: float): | ||
| """Move a specific wheel by delta distance. | ||
|
|
||
| def move_w(self, delta): | ||
| self.microcontroller.move_w_usteps( | ||
| int(STAGE_MOVEMENT_SIGN_W * delta / (SCREW_PITCH_W_MM / (MICROSTEPPING_DEFAULT_W * FULLSTEPS_PER_REV_W))) | ||
| Args: | ||
| wheel_id: The ID of the wheel to move. | ||
| delta: The distance to move (in mm, typically fraction of screw pitch). | ||
| """ | ||
| config = self._configs[wheel_id] | ||
| motor_slot = config.motor_slot_index | ||
| usteps = int( | ||
| STAGE_MOVEMENT_SIGN_W * delta / (SCREW_PITCH_W_MM / (MICROSTEPPING_DEFAULT_W * FULLSTEPS_PER_REV_W)) | ||
| ) | ||
|
|
||
| if motor_slot == 3: | ||
| self.microcontroller.move_w_usteps(usteps) | ||
| elif motor_slot == 4: | ||
| self.microcontroller.move_w2_usteps(usteps) | ||
| else: |
There was a problem hiding this comment.
Multi-wheel behavior is newly introduced here (dict configs, per-wheel position tracking, routing to move_w2_usteps() / home_w2()) but the existing test suite only covers the single-wheel path. Please add unit tests that construct a two-wheel config and verify: (1) initialize([1,2]) works, (2) position updates are tracked per wheel, and (3) the correct microcontroller methods are called for W vs W2.
There was a problem hiding this comment.
[Claude Code] Partial - Hardware test script tools/test_filter_wheel_w2.py exists for W2 testing. Unit tests in test_filter_wheel.py cover single-wheel scenarios with mocks. Adding comprehensive multi-wheel unit tests is a valid enhancement for follow-up.
| static const int camera_trigger_pins[] = {29, 30, 31, 32}; // trigger 1-4 (pin 16 used for W2 CS, pin 28 used for W2 CLK) | ||
|
|
There was a problem hiding this comment.
camera_trigger_pins is now length 4, but firmware code (e.g., callback_send_hardware_trigger() in commands.cpp) indexes camera_trigger_pins[camera_channel] without validating camera_channel. Any host command that sends channel >= 4 will cause out-of-bounds access. Add bounds checks (and ideally return/flag an error) anywhere camera_channel is used to index trigger arrays.
| static const int camera_trigger_pins[] = {29, 30, 31, 32}; // trigger 1-4 (pin 16 used for W2 CS, pin 28 used for W2 CLK) | |
| static const int camera_trigger_pins[] = {29, 30, 31, 32}; // trigger 1-4 (pin 16 used for W2 CS, pin 28 used for W2 CLK) | |
| static const uint8_t NUM_CAMERA_TRIGGER_PINS = sizeof(camera_trigger_pins) / sizeof(camera_trigger_pins[0]); | |
| static inline bool is_valid_camera_channel(uint8_t camera_channel) { | |
| return camera_channel < NUM_CAMERA_TRIGGER_PINS; | |
| } |
There was a problem hiding this comment.
[Claude Code] Skipped - Pre-existing issue unrelated to W2 implementation. The camera_trigger_pins bounds check is a valid concern but out of scope for this PR. Could be addressed in a separate security-focused PR.
| # Use multi-wheel configs if available, otherwise fall back to single config | ||
| configs = config.squid_wheel_configs if config.squid_wheel_configs else config.controller_config |
There was a problem hiding this comment.
configs = config.squid_wheel_configs if config.squid_wheel_configs else ... treats an empty dict the same as “not provided” and silently falls back to the legacy single-wheel config. This can mask misconfiguration (e.g., SQUID_FILTERWHEEL_CONFIGS present but filtered out / missing requested indices). Prefer an explicit is not None check and consider normalizing an empty dict to None when loading config so misconfig is surfaced predictably.
| # Use multi-wheel configs if available, otherwise fall back to single config | |
| configs = config.squid_wheel_configs if config.squid_wheel_configs else config.controller_config | |
| # Use multi-wheel configs if explicitly set, otherwise fall back to single config | |
| configs = config.squid_wheel_configs if config.squid_wheel_configs is not None else config.controller_config |
There was a problem hiding this comment.
[Claude Code] Fixed - Line 164 in utils.py already uses is not None check: configs = config.squid_wheel_configs if config.squid_wheel_configs is not None else config.controller_config
| @@ -8,107 +8,269 @@ | |||
|
|
|||
|
|
|||
| class SquidFilterWheel(AbstractFilterWheelController): | |||
There was a problem hiding this comment.
This class does not call AbstractFilterWheelController.init during initialization. (SquidFilterWheel.init may be missing a call to a base class init)
There was a problem hiding this comment.
[Claude Code] Skipped - The AbstractFilterWheelController.init is abstract and contains only pass. Calling super().init() would have no effect. Python doesn't enforce calling super().init() for ABCs with abstract init. Minor style preference.
| except Exception: | ||
| pass |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| except Exception: | |
| pass | |
| except Exception as exc: | |
| logger.warning( | |
| "Failed to get filter wheel name from config for wheel_id=%s: %s", | |
| wheel_id, | |
| exc, | |
| ) |
There was a problem hiding this comment.
[Claude Code] Skipped - Line 4853 has except Exception: with fallback to num_positions = 8. The other exception handlers at lines 4809 and 4866 already have logging. This specific case silently falls back to a safe default, which is acceptable for a non-critical UI initialization path.
- Make W/W2 initialization symmetric: init_filter_wheel() now called per-wheel in _configure_wheel() instead of unconditionally in __init__ - Unify init_filter_wheel(axis) and configure_squidfilter(axis) methods with optional axis parameter (defaults to AXIS.W for backward compat) - Remove unused homing_enabled config field (was never implemented) - Fix empty dict vs None check in utils.py for squid_wheel_configs - Add logging for bare except in widgets.py _get_wheel_name() - Add test script for W2 filter wheel (tools/test_filter_wheel_w2.py) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Extract init_filterwheel_axis() helper to share initialization logic - Extract move_filterwheel() helper to share movement logic - Reduces code duplication and firmware size by 240 bytes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
PID configuration for W2 encoder was silently ignored because the callback_configure_stage_pid() function had no case for axis == w2. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix Pydantic ValidationError for missing 'type' field when creating FilterWheelDefinition instances - the field is required by the model - Auto-create missing wheel entries in filter_wheels.yaml when .ini specifies more wheels (e.g., EMISSION_FILTER_WHEEL_INDICES = [1, 2]) than are defined in the YAML file - Use .copy() for default_positions dict to avoid sharing between wheels This prevents errors when: 1. Opening filter wheel config dialog with no filter_wheels.yaml 2. User updates .ini to enable second wheel but forgets to update YAML Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- FilterControllerWidget now reads position names from filter wheel registry instead of showing generic "Position 1"-"Position 8" - _populate_filter_positions_for_combo now auto-selects the first wheel in multi-wheel systems when no explicit wheel is specified, rather than falling back to generic position names This ensures filter position names defined in Settings > Filter Wheel Configuration are actually used throughout the UI. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
In multi-wheel systems, FilterWheelDefinition validation requires both 'id' AND 'name' fields. Fixed two issues: 1. widgets.py _load_config(): When auto-creating wheels for multi-wheel systems, now includes name field (e.g., "Wheel 1", "Wheel 2") 2. filter_wheels.yaml: Added missing 'name' field to second wheel Without these fixes, opening the filter wheel config dialog would fail with Pydantic validation error. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Two fixes for filter position names not showing in dropdowns: 1. gui_hcs.py: Pass config_repo to FilterControllerWidget so it can read position names from filter_wheels.yaml registry 2. widgets.py: Handle both int and string keys when looking up position names (YAML may load keys as strings even though model expects int) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Firmware fixes: - Add protocol_axis_to_internal() mapping to convert protocol axis constants (AXIS_W=5, AXIS_W2=6) to internal array indices (w=3, w2=4) - Fix out-of-bounds array access in PID functions and callback_set_axis_disable_enable - Add AXIS_W2 case to callback_set_home_safety_margin - Add W2_commanded_movement_in_progress to mcu_cmd_execution_in_progress checks in check_position() and check_limits() - Add TOTAL_AXES constant for proper array sizing - Add clarifying comments for axis constant naming conventions Software fixes: - Add _move_to_position() with automatic re-home on movement failure - Add INITFILTERWHEEL_W2 to _CMD_NAMES for proper logging - Add clarifying comments for _MOTOR_SLOT_TO_AXIS mapping Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| self.microcontroller.set_pid_arguments(motor_slot, PID_P_W, PID_I_W, PID_D_W) | ||
| self.microcontroller.configure_stage_pid(motor_slot, config.transitions_per_revolution, ENCODER_FLIP_DIR_W) | ||
| self.microcontroller.turn_on_stage_pid(motor_slot, ENABLE_PID_W) |
There was a problem hiding this comment.
In the PID setup for filter wheels, set_pid_arguments(), configure_stage_pid(), and turn_on_stage_pid() are being called with motor_slot (3/4). Those MCU commands expect protocol axis constants (AXIS.W=5, AXIS.W2=6). With the firmware’s new protocol_axis_to_internal() mapping, sending 3/4 will be treated as an invalid axis (or the wrong axis), so encoder PID for W/W2 won’t be configured/enabled.
Use the already-computed axis (from _MOTOR_SLOT_TO_AXIS) for these calls, or otherwise convert motor_slot_index -> protocol axis before sending. Consider adding/adjusting the unit test(s) to assert the correct axis value is passed to the microcontroller methods when HAS_ENCODER_W is enabled.
| self.microcontroller.set_pid_arguments(motor_slot, PID_P_W, PID_I_W, PID_D_W) | |
| self.microcontroller.configure_stage_pid(motor_slot, config.transitions_per_revolution, ENCODER_FLIP_DIR_W) | |
| self.microcontroller.turn_on_stage_pid(motor_slot, ENABLE_PID_W) | |
| # Use protocol axis (AXIS.W / AXIS.W2), not motor_slot index (3 / 4), | |
| # because the firmware's protocol_axis_to_internal() handles mapping. | |
| self.microcontroller.set_pid_arguments(axis, PID_P_W, PID_I_W, PID_D_W) | |
| self.microcontroller.configure_stage_pid(axis, config.transitions_per_revolution, ENCODER_FLIP_DIR_W) | |
| self.microcontroller.turn_on_stage_pid(axis, ENABLE_PID_W) |
There was a problem hiding this comment.
[Claude Code] Fixed in commit b9b0093 - PID setup now uses axis (AXIS.W=5 / AXIS.W2=6) instead of motor_slot (3/4). Good catch!
| # Add parent directory to path for imports | ||
| sys.path.insert(0, ".") | ||
|
|
||
| from control._def import * |
There was a problem hiding this comment.
Import pollutes the enclosing namespace, as the imported module control._def does not define 'all'.
| from control._def import * | |
| from control._def import ( | |
| AXIS, | |
| SCREW_PITCH_W_MM, | |
| STAGE_MOVEMENT_SIGN_W, | |
| MICROSTEPPING_DEFAULT_W, | |
| FULLSTEPS_PER_REV_W, | |
| ) |
There was a problem hiding this comment.
[Claude Code] Skipped - This is a test/tool script, not production code. The import * pattern is acceptable for quick testing scripts.
| _log = squid.logging.get_logger(__name__) | ||
|
|
||
|
|
||
| class SquidFilterWheel(AbstractFilterWheelController): |
There was a problem hiding this comment.
This class does not call AbstractFilterWheelController.init during initialization. (SquidFilterWheel.init may be missing a call to a base class init)
There was a problem hiding this comment.
[Claude Code] Skipped - AbstractFilterWheelController.init is abstract and contains only pass. Calling super().init() would have no effect. This is consistent with other controller implementations in the codebase.
The PID setup calls (set_pid_arguments, configure_stage_pid, turn_on_stage_pid) were incorrectly using motor_slot (3/4) instead of protocol axis constants (AXIS.W=5, AXIS.W2=6). The firmware's protocol_axis_to_internal() expects protocol axis values, so passing 3/4 resulted in invalid axis (0xFF). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
Firmware Changes
INITFILTERWHEEL_W2command (252) andMOVE_W2command (19)AXIS_W2(6) for homing and configuration commandsinit_filterwheel_axis()helper to reduce code duplicationCritical Bug Fixes (from code review)
protocol_axis_to_internal()mapping function to safely convert protocol axis constants (AXIS_W=5, AXIS_W2=6) to internal array indices (w=3, w2=4)callback_configure_stage_pid(),callback_enable_stage_pid(),callback_disable_stage_pid(),callback_set_pid_arguments(),callback_set_axis_disable_enable()AXIS_W2case tocallback_set_home_safety_margin()W2_commanded_movement_in_progresstomcu_cmd_execution_in_progressflag updates incheck_position()andcheck_limits()TOTAL_AXESconstant (5) for proper array sizingSoftware Changes
MOVE_W2,INITFILTERWHEEL_W2,AXIS.W2constants in_def.pyinit_filter_wheel(axis)andconfigure_squidfilter(axis)methods with axis parameter_configure_wheel()move_w2_usteps(),home_w2()methods in microcontroller.pySquidFilterWheelto support multi-wheel configuration via dict_move_to_position()with automatic re-home on movement failureINITFILTERWHEEL_W2to_CMD_NAMESfor proper loggingConfiguration
Enable second filter wheel in
.iniconfig:emission_filter_wheel_indices = [1, 2]Per-wheel configuration in
_def.py:Test plan
cd software && python tools/test_filter_wheel_w2.py🤖 Generated with Claude Code