Add PI V-308 / C-414 voice-coil focus stage (USE_PI_FOCUS_STAGE) - #569
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ilder Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add config flags in _def.py, import branch in gui_hcs.py, and a CombinedStage wrap in microscope.build_from_global_config so the V-308 becomes the main Z on top of the configured XY stage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…safety) - close(): PIFocusStage/CombinedStage define close(); Microscope.close() releases the stage; connect_pi_focus_stage closes the GCSDevice on any connect/init failure. - CombinedStage delegates x/y/z_mm_to_usteps to the XY stage so NavigationWidget no longer crashes at startup. - Guard moves against an unreferenced axis (sim + real); add a serial RLock so a non-blocking home() cannot race GUI position polling on the shared GCS handle. - _resolve_port_by_sn compares serials as strings (numeric-SN coercion footgun); set_limits warns on a one-sided Z bound. - PI_FOCUS_VELOCITY_MM_S config (optional); setup_22.04.sh installs the C-414 udev bind + latency rules and reloads. - Tests for close, usteps delegation, the referenced guard, numeric SN, no-port error. Deferred (needs design): coordinate-frame mismatch -- V-308 range/limits/step grid still reuse the Cephla Z config (SOFTWARE_POS_LIMIT.Z, Z_AXIS, GUI scroll guard). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…per (#6) CombinedStage.z_mm_to_usteps delegates to the V-308 (continuous, ~1 nm) instead of the Cephla stepper grid, so GUI Z deltas / Z-stack slice spacing are not snapped to a coarse microstep grid. The only consumer is the GUI step-grid (widgets.py set_deltaZ). No Z offset: the C-414 reports its frame as 0..7 mm (TMN?=0, TMX?=7, queried live), which already matches Squid's SOFTWARE_POS_LIMIT.Z, so pure pass-through is correct and the 'half the range fenced off' review finding (premised on a +/-3.5 frame) does not apply. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
home_xyz() now drives Z to PI_FOCUS_SAFE_Z_MM (default 0.0 = retracted) before the XY homing sweep when USE_PI_FOCUS_STAGE, so the un-self-locking voice coil cannot drag the objective through the sample. Gated on is_referenced() (FRF?/qFRF, exposed via PIFocusStage/CombinedStage) -- skips with a warning rather than commanding a move on an unreferenced axis. Tests cover both the retract and the unreferenced-skip paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion - PIFocusStage.home(z=True) references only if not already referenced (qFRF -- no redundant FRF sweep), then moves Z to the objective-clear home position (home_mm, wired from PI_FOCUS_SAFE_Z_MM). The GUI Home button always drives Z home, without re-sweeping a referenced axis. - home_xyz() calls home(z) before the XY sweep, so Z is referenced and retracted to the objective-clear position first -- the un-self-locking coil cannot drag the objective. - Sim initialize() made idempotent; sim tracks a reference count so tests can assert that an already-referenced home() does not re-sweep. - Tests: home moves-home-without-resweep when referenced; references-then-moves when not; home_xyz references + retracts even when starting unreferenced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tness) - set_travel_limits: clamp the requested range to the controller travel (qTMN/qTMX) and wrap CCL(1)/SPA/CCL(0) in try/finally so the C-414 never stays in advanced command level if an SPA write raises. - PIFocusStage: guard the non-blocking home() against a use-after-close race (skip if closed); a _busy flag lets get_state() report busy without blocking on the lock during an async home. - CombinedStage: initialise _scanning_position_z_mm (read by the loading/scanning flow). - microscope: gate the HOMING_ENABLED_Z home with 'not USE_PI_FOCUS_STAGE' so home_xyz homes the V-308 once; normalise PI_FOCUS_STAGE_SN so a numeric serial 0 is not collapsed to None. - tests: home-after-close no-op, CombinedStage _scanning attr, and scope.close() on the three microscope-level tests (exercises the V-308 close path). Deferred to the frame/safe-Z design: V-308-correct CombinedStage.get_config() Z grid (autofocus /multipoint step snapping) and routing safety/retract/loading Z through PI_FOCUS_SAFE_Z_MM. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…home Drop the PI-specific PI_FOCUS_SAFE_Z_MM and reuse Squid's existing OBJECTIVE_RETRACTED_POS_MM (already the loading-position retract value) as the single source of truth for the V-308's retracted/home Z. home(z) and the pre-XY-homing retract now move there, consistent with move_to_loading_position. One configurable value, no duplicate 'safe Z'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…complete) CombinedStage.get_config() now returns a Z_AXIS at the Z stage's continuous ~10 nm resolution, so the AutoFocus + multipoint Z-step widgets (which snap via get_config().Z_AXIS, not z_mm_to_usteps) reflect the V-308 grid instead of the coarse Cephla stepper grid -- closing the path the earlier #6 fix missed. z_mm_to_usteps rounds to an integer ustep for consistency. Note: Z_HOME_SAFETY_POINT (the GUI 'Home Z'/startup safe-Z height) stays config-driven -- a V-308 scope's .ini should set it (and OBJECTIVE_RETRACTED_POS_MM) for the V-308's frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d state - The V-308 has no Z_HOME_SAFETY_POINT concept (its retracted position is the safe Z, and the safety-floor direction doesn't map to the voice coil). move_z_axis_to_safety_position and the startup cached-Z restore now use OBJECTIVE_RETRACTED_POS_MM / restore the cached Z directly when USE_PI_FOCUS_STAGE, instead of the 1.2 mm floor. - C414FocusStage caches the qFRF referenced state (refreshed by is_referenced()/reference()), so move_to/move_relative no longer issue a qFRF round-trip per move -- matters for fast Z-stacking. ([14]) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ perms Addresses findings from V-308 (C-414) bring-up testing on Linux: - connect_serial/connect_tcpip now build the GCSDevice on pipython's pure-Python PISerial/PISocket gateway instead of gcs.ConnectRS232(), which on Linux routes through the proprietary libpi_pi_gcs2.so that `pip install pipython` does not provide. Defer GCSDevice creation to connect time so __init__ no longer makes a DLL-backed device (whose stale connection callback crashed against the missing DLL). connect_usb keeps the DLL path (USB requires it). Guard close()/stop() for the not-yet-connected case. Verified on hardware (C-414.13030): connect + moves. - setup_22.04.sh: `udevadm trigger --action=add` so the ACTION=="add" bind rule applies to an already-connected C-414 (plain trigger defaults to change and no-ops). - 99-pi-ftdi-latency.rules: add a tty-subsystem MODE="0666" rule so the /dev/ttyUSB* node is usable without dialout membership / re-login (the latency ATTR sits on the usb-serial device, not the tty node). - _SimulatedC414 travel -3.5..3.5 -> 0.0..7.0 mm to match the V-308's reported qTMN/qTMX. Existing simulated unit-test assertions still hold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardware test evidence — V-308 / C-414 Z control verified ✅Tested on a Toupcam + Cephla-XY microscope with the target hardware attached: PI C-414.13030 (S/N Z control works end-to-endExercised the PR's own driver + adapter against the real controller (referenced with the objective clear):
Hardware reports Findings addressed in
|
…pright) For an upright system the V-308's native 0 is DOWN (toward the sample); the PR's pass-through Z and retract-to-~0 suit an inverted scope but drive the objective into the sample on an upright one. Add configurable inversion + upright homing: - PIFocusStage(invert_z): squid_z = (native positive limit) - native, so Squid Z 0 is fully retracted and Z increases toward the sample (Z+ = focus down). Applied consistently to get_pos/move_z/move_z_to/set_limits. - PIFocusStage(home_to_positive_limit): home()/retract drives Z to the native positive travel limit (furthest from the sample), narrowed to the fenced upper end by set_limits, instead of OBJECTIVE_RETRACTED_POS_MM. - reset_range_limit(): on the C-414 qTMN/qTMX ARE the Position Range Limit params, so set_travel_limits() shrinks them irreversibly and the inversion offset/fencing would drift smaller each software restart. connect_pi_focus_stage(z_travel_mm=...) restores [0, travel] at connect so the offset is stable. - close(): release the PISerial/PISocket gateway via its own close(); GCSDevice. CloseConnection() raises AttributeError on a gateway (previously suppressed, so the port was not released cleanly). - New config: PI_FOCUS_INVERT_Z, PI_FOCUS_HOME_TO_POSITIVE_LIMIT, PI_FOCUS_Z_TRAVEL_MM (all default off/0 -> existing behaviour unchanged). Wired through microscope.py. - Tests for inverted mapping, upright home, range-limit reset, offset stability. Verified on hardware (C-414.13030): offset restored to 7.0, home retracts UP to the positive limit, Z+ moves toward the sample, gateway close releases the port. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… GCSError Jogging Z past the fenced range in the GUI raised an uncaught `GCSError: Position out of limits` (the C-414 rejects an out-of-range MOV/MVR), logged as an "Uncaught Exception". Clamp instead: - C414FocusStage caches the Position Range Limit ([lo, hi], refreshed at connect and on set_travel_limits/reset_range_limit) and clamps every move target to it via a module-logger warning. move_relative now resolves to an absolute target and MOVs the clamped value (rather than MVR) so a relative jog past the limit also stops cleanly. - No extra qTMN/qTMX query per move (uses the cache). Verified on hardware: move_z_to past the top limit warns and clamps (no exception); in-range moves unchanged. Adds a unit test for the clamp helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds support for using a PI V-308 (via a C-414 controller) as Squid’s main Z/focus axis, composing it with the existing XY stage via a wrapper stage, along with simulation support, configuration flags, deployment helpers, and tests.
Changes:
- Introduces a new PI stage driver/module (
squid.stage.pi) including a simulated backend, a Z-only focus stage adapter, and an XY+Z combining stage. - Wires the PI Z stage into microscope construction, homing behavior, GUI imports, and shutdown/close handling.
- Adds extensive simulated tests plus udev rules and setup-script updates for Linux deployment.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| software/squid/stage/pi.py | New PI C-414/V-308 driver, simulator, and stage adapters (PIFocusStage, CombinedStage). |
| software/control/_def.py | Adds global configuration knobs for enabling/simulating the PI focus stage and its connection/behavior. |
| software/control/microscope.py | Wraps the configured XY stage with a PI-backed Z stage when enabled; adjusts homing and adds stage close on shutdown. |
| software/control/gui_hcs.py | Adds conditional import and alters cached-position Z restore logic for PI focus stage. |
| software/squid/stage/utils.py | Updates “move Z to safety” to use the objective retracted position when PI focus stage is enabled. |
| software/tests/squid/test_stage.py | Adds a large suite of simulated tests covering PI stage behavior, integration, and edge cases. |
| software/setup_22.04.sh | Installs optional PI dependency and deploys PI udev rules/latency tuning during setup. |
| software/drivers and libraries/pi/udev/98-pi-c414-bind.rules | New udev rule to bind PI’s custom-VID FTDI to ftdi_sio so a tty device appears. |
| software/drivers and libraries/pi/udev/99-pi-ftdi-latency.rules | New udev rules to reduce FTDI latency timer and set permissions on the created tty node. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if USE_PI_FOCUS_STAGE: | ||
| # V-308: no Z_HOME_SAFETY_POINT floor; restore the cached absolute Z directly. | ||
| self.stage.move_z_to(cached_pos.z_mm) | ||
| elif (int(Z_HOME_SAFETY_POINT) / 1000.0) < cached_pos.z_mm: |
There was a problem hiding this comment.
[Claude Code] Already guarded one layer down: Microscope._prepare_for_use calls stage.set_limits() with StageConfig.Z_AXIS MIN/MAX during Microscope.__init__, i.e. before this GUI restore runs, and the PI driver clamps every move_to to that range (_C414._clamp_target, with a warning log when it clamps). Even before set_limits, moves are clamped to the controller's physical travel (qTMN/qTMX) seeded at connect. Added a comment at this call site documenting that in aca702f.
| # Optional: PI V-308 / C-414 focus stage (USE_PI_FOCUS_STAGE). Safe to skip if unused; | ||
| # squid.stage.pi imports it lazily and only needs it to connect to real hardware. | ||
| pip3 install pipython |
There was a problem hiding this comment.
[Claude Code] Fixed in aca702f - pip3 install pipython is now non-fatal (|| echo WARNING...), so a pip/network failure can no longer abort setup under set -e on systems that don't use the PI stage.
| # in the dialout group (or having re-logged in after setup adds them). The latency ATTR above | ||
| # lives on the usb-serial device; the /dev/ttyUSB* node itself is a "tty" device, so its MODE | ||
| # must be set by a tty-subsystem rule. | ||
| ACTION=="add", SUBSYSTEM=="tty", ATTRS{idVendor}=="1a72", ATTRS{idProduct}=="102b", MODE="0666" |
There was a problem hiding this comment.
[Claude Code] Fixed in aca702f - replaced MODE="0666" with GROUP="dialout", MODE="0660", TAG+="uaccess". The uaccess ACL preserves the original goal (Squid works immediately after setup, before the user re-logs in to pick up dialout membership) without world-writable perms. The rules file is renamed to 70-pi-c414-serial.rules because the uaccess tag must be set before systemd's 73-seat-late.rules processes it; setup now also removes a stale pre-rename 99- file that would re-apply 0666.
There was a problem hiding this comment.
[Claude Code] Correction: the earlier fix was reverted in 9a7362a per maintainer decision. MODE="0666" is intentional so Squid can open the port immediately after setup, without the user needing dialout membership or a re-login.
…n install - udev: replace world-writable MODE="0666" on the C-414 tty with GROUP="dialout", MODE="0660", TAG+="uaccess"; rename the rules file to 70-pi-c414-serial.rules so the uaccess tag sorts before systemd's 73-seat-late.rules. The seated user still gets access immediately after setup (no re-login) via the logind ACL. - setup: make `pip3 install pipython` non-fatal so an optional-dependency install failure can't abort setup under set -e on systems without the PI stage; remove a stale pre-rename 99-pi-ftdi-latency.rules that would re-apply 0666. - gui_hcs: document why the cached-Z restore needs no guard (driver clamps to StageConfig.Z_AXIS MIN/MAX applied via set_limits before the restore runs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Undo the dialout/uaccess udev change and file rename from aca702f; the world-writable node is intentional so Squid works without re-login after setup. Keeps the non-fatal pipython install and the gui_hcs Z-restore comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…I shutdown - CombinedStage.home() now homes Z before XY (and blocks on the Z leg whenever XY homing follows, even for a non-blocking call): the V-308 voice coil is not self-locking, so the objective must be retracted before any XY sweep. The old order was latent-unsafe for any future caller passing both axis groups. - _cleanup_common() now closes the stage (getattr-guarded, mirroring Microscope.close()) before the microcontroller, so the C-414 FTDI handle is released on shutdown/restart instead of only at process death — the in-place restart flow needs the port free for the new process. Both changes are pinned by new tests (ordering + close sequencing against the real simulated GUI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oilerplate Cleanup pass over the recent review fixes (no behavior change): - AbstractStage gains a default no-op close(), so the three duck-typed getattr-probe call sites (Microscope.close, gui_hcs._cleanup_common, CombinedStage.close) collapse to plain close() calls — the "does this stage own a transport?" knowledge now lives in the stage contract, not at every call site. - tests: extract confirm_exit_yes fixture (was copied verbatim in 3 GUI tests) and _sim_combined_stage() helper (CombinedStage construction was copied in 5 stage tests); replace a closure factory with two lambdas. - trim comments that restated code or cross-referenced call chains in other modules; setup script warning goes to stderr. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict: tests/control/test_HighContentScreeningGui.py — master added two GUI tests where this branch introduced the confirm_exit_yes fixture. Kept both: master's tests adapted to the fixture (their inline confirm_exit blocks were the duplication the fixture replaced). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Adds support for the PI V-308 voice-coil PIFOC focus drive (on a C-414 PIMag
controller) as a selectable main-Z stage, alongside the existing Cephla and Prior stages.
The V-308 gives fast, high-resolution closed-loop Z for focus / Z-stacking.
Enable with
USE_PI_FOCUS_STAGE = True. The V-308 becomes the Z axis, wrapping whatever XYstage is configured (Cephla µC or Prior) — so it composes with the existing stage, it doesn't
replace XY.
What's included
software/squid/stage/pi.py(new, self-contained likeprior.py/cephla.py):C414FocusStage— GCS-2.0 driver over serial,pipythonimported lazily (module importsfine without it; CI and simulated builds never need it).
_SimulatedC414— pure-Python backend for hardware-free / CI use.PIFocusStage(AbstractStage)— Z-only adapter, pure pass-through mm (no sign flip, neverreads
Z_AXIS.MOVEMENT_SIGN).CombinedStage(AbstractStage)— routes X/Y/θ to the existing stage, Z to the V-308; presents aV-308-correct Z axis (continuous ~10 nm grid) so GUI Z-step snapping isn't coarsened.
control/_def.py):USE_PI_FOCUS_STAGE,SIMULATE_PI_FOCUS_STAGE,PI_FOCUS_STAGE_SN,PI_FOCUS_SERIAL_PORT,PI_FOCUS_BAUDRATE,PI_FOCUS_AXIS,PI_FOCUS_REFERENCE_ON_STARTUP,PI_FOCUS_VELOCITY_MM_S.gui_hcs.py;CombinedStageconstruction inmicroscope.build_from_global_config; stageclose()on shutdown.drivers and libraries/pi/udev/{98-pi-c414-bind,99-pi-ftdi-latency}.rulesand anoptional
pip install pipythoninsetup_22.04.sh.tests/squid/test_stage.py(26 V-308 tests), simulated, no hardware.Hardware / connection (verified on a real unit)
The C-414 is a custom-VID FTDI (
1a72:102b, FT-X), serial1UETR6I!, controller frame0..7 mm(TMN?=0,TMX?=7) — not the spec sheet's mechanical "±3.5". Because the VID iscustom, the OS doesn't auto-create a COM port:
98-pi-c414-bind.rulesbinds it toftdi_sio(new_id) so/dev/ttyUSB*appears;99-pi-ftdi-latency.rulesdrops the FTDI latency timer to 1 ms.setup_22.04.shinstalls both.PI_FOCUS_STAGE_SN(the FTDI serial) resolves to the port.A V-308 scope's machine config should set
PI_FOCUS_STAGE_SN(orPI_FOCUS_SERIAL_PORT).OBJECTIVE_RETRACTED_POS_MM— the V-308's retracted / objective-clear Z;home(z)and thepre-XY-homing retract drive here. (Reused as the single safe-Z source;
Z_HOME_SAFETY_POINTisbypassed for the V-308.)
SOFTWARE_POS_LIMIT.Z— the reachable Z range (the controller travel registers are clamped tothe hardware travel via
qTMN/qTMX).Behavior notes
0..7, matchesSOFTWARE_POS_LIMIT.Z); no offset.home(z)references only if not already referenced (no redundantFRFsweep), then moves tothe retracted position.
home_xyz()retracts Z before the XY sweep (the voice coil has noself-locking).
zero(z)is an inert warning (V-308 uses an absolute optical reference).Safety
The voice coil has no self-locking: referencing and bring-up move the stage, and servo-off
lets a vertical stage drift. Run bring-up with the objective clear; fence Z via
SOFTWARE_POS_LIMIT.Z.autozero()(weight compensation) is implemented but not auto-wired.Testing
tests/squid/— 96 passed (26 new V-308 tests), simulated;blackclean.Status / follow-ups
port); end-to-end serial verification happens on the Linux scope. GCS protocol confirmed over
libusb (
*IDN?→C-414.13030, fw1.003).if reversed, fix at the controller/mounting level, not in
pi.py.PI_FOCUS_REFERENCE_ON_STARTUP=Falseto avoid the bring-up referencemove; analog / wave-generator V-308 modes; the V-308 as a separate fast-fine-Z on top of a coarse
Z (this PR makes it the main Z).
🤖 Generated with Claude Code