Skip to content

Add PI V-308 / C-414 voice-coil focus stage (USE_PI_FOCUS_STAGE)#569

Open
hongquanli wants to merge 17 commits into
masterfrom
feat/v308-pi-focus-stage
Open

Add PI V-308 / C-414 voice-coil focus stage (USE_PI_FOCUS_STAGE)#569
hongquanli wants to merge 17 commits into
masterfrom
feat/v308-pi-focus-stage

Conversation

@hongquanli

Copy link
Copy Markdown
Contributor

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 XY
stage 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 like prior.py/cephla.py):
    • C414FocusStage — GCS-2.0 driver over serial, pipython imported lazily (module imports
      fine 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, never
      reads Z_AXIS.MOVEMENT_SIGN).
    • CombinedStage(AbstractStage) — routes X/Y/θ to the existing stage, Z to the V-308; presents a
      V-308-correct Z axis (continuous ~10 nm grid) so GUI Z-step snapping isn't coarsened.
  • Config (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.
  • Wiring: import branch in gui_hcs.py; CombinedStage construction in
    microscope.build_from_global_config; stage close() on shutdown.
  • Deployment: drivers and libraries/pi/udev/{98-pi-c414-bind,99-pi-ftdi-latency}.rules and an
    optional pip install pipython in setup_22.04.sh.
  • Tests: 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), serial 1UETR6I!, controller frame
0..7 mm (TMN?=0, TMX?=7) — not the spec sheet's mechanical "±3.5". Because the VID is
custom, the OS doesn't auto-create a COM port:

  • Linux: 98-pi-c414-bind.rules binds it to ftdi_sio (new_id) so /dev/ttyUSB* appears;
    99-pi-ftdi-latency.rules drops the FTDI latency timer to 1 ms. setup_22.04.sh installs both.
  • Windows: PI's bundled FTDI driver creates the COM port.
  • Default transport is serial; PI_FOCUS_STAGE_SN (the FTDI serial) resolves to the port.

A V-308 scope's machine config should set

  • PI_FOCUS_STAGE_SN (or PI_FOCUS_SERIAL_PORT).
  • OBJECTIVE_RETRACTED_POS_MM — the V-308's retracted / objective-clear Z; home(z) and the
    pre-XY-homing retract drive here. (Reused as the single safe-Z source; Z_HOME_SAFETY_POINT is
    bypassed for the V-308.)
  • SOFTWARE_POS_LIMIT.Z — the reachable Z range (the controller travel registers are clamped to
    the hardware travel via qTMN/qTMX).

Behavior notes

  • Pass-through mm — the controller's native mm is Squid's Z mm (frame is 0..7, matches
    SOFTWARE_POS_LIMIT.Z); no offset.
  • home(z) references only if not already referenced (no redundant FRF sweep), then moves to
    the retracted position. home_xyz() retracts Z before the XY sweep (the voice coil has no
    self-locking).
  • zero(z) is an inert warning (V-308 uses an absolute optical reference).
  • Continuous closed-loop drive (~nm encoder), so Z-step quantization is effectively a no-op.

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; black clean.

Status / follow-ups

  • Simulated-tested only. The serial path can't run on macOS dev (custom-VID FTDI has no COM
    port); end-to-end serial verification happens on the Linux scope. GCS protocol confirmed over
    libusb (*IDN?C-414.13030, fw 1.003).
  • Open hardware item: confirm which travel end is "up" (objective-clear) on the physical mount;
    if reversed, fix at the controller/mounting level, not in pi.py.
  • Deferred (non-blocking): PI_FOCUS_REFERENCE_ON_STARTUP=False to avoid the bring-up reference
    move; 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

cephlainc and others added 15 commits June 29, 2026 02:22
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>
@hongquanli

Copy link
Copy Markdown
Contributor Author

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 425001492, fw 1.003) driving a V-308 voice-coil focus, connected via FTDI VCP.

Z control works end-to-end

Exercised the PR's own driver + adapter against the real controller (referenced with the objective clear):

Path Test Result
C414FocusStage Absolute moves 1→3→5→3→1 mm 9/9 in tol, max err 71 nm
C414FocusStage Relative ±10 µm, ±100 µm within 70 nm
PIFocusStage (Squid API) move_z_to / move_z / get_pos 6/6, max err 38 nm
PIFocusStage home(z) → retract, no re-reference
Referencing FRF sweep; skipped when already referenced ✅ (cache logic)
GUI Z-step grid z_mm_to_usteps(1.0) 10000010 nm

Hardware reports qTMN/qTMX = 0..7 mm travel; references near ~0.3 mm.

Findings addressed in 42479325

  1. connect_serial() failed on Linux (blocker). It used gcs.ConnectRS232(), which routes through PI's proprietary libpi_pi_gcs2.sonot provided by pip install pipython (OSError: libpi_pi_gcs2.so not found). Switched connect_serial/connect_tcpip to pipython's pure-Python PISerial/PISocket gateway (no DLL). Also deferred GCSDevice creation out of __init__ (the DLL-backed device registered a connection callback that later crashed against the missing DLL). connect_usb keeps the DLL path (USB requires it). Verified on hardware via the PR's own connect_serial().
  2. udev bind didn't apply on install. 98-pi-c414-bind.rules matches ACTION=="add", but udevadm trigger defaults to action=change, so an already-connected C-414 got no /dev/ttyUSB*. Changed the setup step to udevadm trigger --action=add.
  3. Serial port permission. The /dev/ttyUSB* node came up 0660 root:dialout; the latency ATTR sits on the usb-serial device, not the tty node. Added a SUBSYSTEM=="tty" … MODE="0666" rule so it's usable without dialout membership / re-login.
  4. Sim fidelity (cosmetic). _SimulatedC414 travel ±3.50.0–7.0 mm to match the V-308's reported range. Existing simulated unit-test assertions still hold.

Machine .ini (gitignored) enabled the stage via the stable FTDI serial number, resolved to a port by the bind rule.

Verified with Claude Code.

hongquanli and others added 2 commits July 2, 2026 12:08
…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>
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