Skip to content

Add ASI LS50 Z-only focus stage (USE_ASI_Z_STAGE)#591

Open
Alpaca233 wants to merge 16 commits into
masterfrom
feat/ls50-asi-z-stage
Open

Add ASI LS50 Z-only focus stage (USE_ASI_Z_STAGE)#591
Alpaca233 wants to merge 16 commits into
masterfrom
feat/ls50-asi-z-stage

Conversation

@Alpaca233

Copy link
Copy Markdown
Collaborator

Adds the ASI LS50 Z-only linear stage (own MS-2000-family controller, own USB serial port) as the microscope's Z axis, following the PR #569 pattern: a Z-only AbstractStage adapter composed with the configured XY stage via the (reused, unchanged) CombinedStage.

Stacked on #569 (feat/v308-pi-focus-stage) — needs CombinedStage from that branch. Merge #569 first; this PR's diff is the last 4 commits.

What's in here

  • squid/stage/asi.py (new):
    • MS2000Serial — the shared CR-terminated ASI command transport (locked write/read, :N error acks, test-injectable serial object). Deliberately placed at the same path as dragonfly-andor's XYZ ASIStage, so merging that branch raises an add/add conflict whose resolution is porting ASIStage onto this core (no silently-duplicated ASI serial code).
    • LS50Controller — the driver: M Z=/W Z in 0.1 µm units, / busy polling as the settle signal, SL/SU soft limits (mm), fence clamping with pass-through while unfenced, relative moves resolved to absolute so jogs clamp, HALT tolerating its :N-21 ack. Bring-up performs no motion and writes no parameters.
    • _SimulatedLS50 + ASIZStage (adapter mirroring PIFocusStage: RLock, async-home _busy flag, close-race guard, XY warn-stubs, 0.1 µm z_mm_to_usteps grid) + connect_asi_z_stage factory.
  • Frame semantics (hardware-verified convention): native + is away from the sample, native 0 = the retracted position, no referencing routine exists or is needed. ASI_Z_INVERT defaults True: squid shows −native, so squid 0 = retracted and squid Z+ is toward the sample (Cephla convention). home() = retract to native 0. The only possible bring-up motion is the doubly-opt-in ASI_Z_HOME_ON_STARTUP.
  • uses_external_z_stage() in _def.py — the four external-Z policy sites (home_xyz Z-homing skip + retract-before-XY, cached-Z restore, move_z_axis_to_safety_position) now ask this one predicate instead of OR-ing vendor flags. It is a function, not a derived constant, because the machine-config loader overrides flag globals after definition.
  • Mutual exclusion: USE_PI_FOCUS_STAGE + USE_ASI_Z_STAGE together raises at config load AND at Microscope construction (prevents silently nesting one CombinedStage inside the other).
  • Shared port-by-SN resolver extracted to squid.stage.utils.resolve_serial_port_by_sn; the PI module delegates to it.

Config

[GENERAL]
use_asi_z_stage = True
asi_z_stage_sn = <controller USB serial>   ; or asi_z_serial_port = /dev/ttyUSB0
asi_z_travel_mm = 50                        ; coarse ± sanity fence around power-on zero

Tests

29 new tests in tests/squid/test_stage.py mirroring the PI suite (all written red-first): sim clamp/unfenced-passthrough, sign-flip mapping, protocol framing/parsing against a scripted serial, / wait-idle + timeout, builder no-motion guarantees, predicate call-time reads, Microscope wrap/mutual-exclusion/home_xyz retract, close chain.

  • tests/squid: 132 passed
  • tests/control/test_HighContentScreeningGui.py: 3 passed
  • full-tree collection (1487 tests) clean — all imports work against the new flags
  • simulated end-to-end: CombinedStage wrap → squid +0.5 = native −0.5 → retract to 0 → clean close

Hardware bring-up

Power the controller on with the stage retracted (native 0 = retracted), set asi_z_stage_sn + asi_z_travel_mm = 50, jog to confirm squid + moves toward the sample (else flip asi_z_invert), then consider asi_z_home_on_startup. Known caveat (documented in the flag comments): the frame survives power cycles only if the stage is powered off retracted; LS50Controller.zero_here() (H Z=0) exists unwired for a future re-zeroing flow.

🤖 Generated with Claude Code

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

Adds support for using an ASI LS50 Z-only focus stage (MS-2000-family controller over USB serial) as the microscope’s Z axis, composed with the configured XY stage via the existing CombinedStage adapter (from the PI focus stage implementation).

Changes:

  • Introduces squid.stage.asi with an MS-2000 serial transport, LS50 controller backend, simulator, and AbstractStage Z-only adapter + connection factory.
  • Centralizes “external Z stage” policy via control._def.uses_external_z_stage() and applies it across homing / cached-position restore / safety-Z behavior.
  • Extracts shared “resolve serial port by USB serial number” helper into squid.stage.utils.resolve_serial_port_by_sn and reuses it from the PI module; adds comprehensive tests for the ASI stage path.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
software/tests/squid/test_stage.py Adds a full test suite for ASI LS50 Z-stage behavior, transport framing, controller units/status polling, builder behavior, and integration with CombinedStage/Microscope.
software/squid/stage/utils.py Switches safety-Z logic to the shared external-Z predicate; adds shared serial-port-by-SN resolver.
software/squid/stage/pi.py Refactors PI port resolution to call the shared resolver in squid.stage.utils.
software/squid/stage/asi.py New ASI LS50 implementation: serial transport, controller backend, simulator, Z-only AbstractStage adapter, and connection factory.
software/control/microscope.py Adds ASI Z-stage wiring (mutual exclusion with PI), and updates homing semantics to use the shared external-Z predicate.
software/control/gui_hcs.py Imports ASI stage when enabled and updates cached-Z restore logic to use the shared external-Z predicate.
software/control/_def.py Adds ASI configuration flags, the uses_external_z_stage() predicate, and mutual-exclusion validation.

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

Comment on lines +161 to 165
if _def.uses_external_z_stage():
# External Z focus stages have no Z_HOME_SAFETY_POINT concept; near-retracted is the safe Z.
stage.move_z_to(_def.OBJECTIVE_RETRACTED_POS_MM)
else:
stage.move_z_to(int(_def.Z_HOME_SAFETY_POINT) / 1000.0)
@Alpaca233 Alpaca233 changed the base branch from feat/v308-pi-focus-stage to master July 8, 2026 20:10
Alpaca233 and others added 5 commits July 8, 2026 13:12
…kend

MS2000Serial is the shared CR-terminated ASI command transport (locked
write/read, ':N' error acks, test-injectable serial object) that the
dragonfly-andor XYZ ASIStage should adopt on merge. _SimulatedLS50 mirrors
the LS50 contract: power-on zero, unfenced-passthrough clamping, zero_here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ome)

Mirrors PIFocusStage: RLock over the backend, _busy flag for async home,
close-race guard, XY warn-stubs, 0.1 um z_mm_to_usteps grid. Inversion is a
pure sign flip (squid_z = -native_z) since the LS50 has no absolute travel
to offset against; home() retracts to the configured squid-frame target
(native 0 by convention) and is a warn no-op when unset. Reuses
pi.CombinedStage unchanged for the XY pairing (covered by tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red port-by-SN resolver

LS50Controller mirrors C414FocusStage's shape over MS2000Serial: W Z position
parse, '/' busy polling as the only settle signal, fence clamp with warning
(pass-through while unfenced -- limits are unknowable at power-on), relative
moves resolved to absolute so jogs clamp, HALT tolerating its ':N-21' ack.
Bring-up performs no motion and writes no parameters; home_on_startup is the
one doubly-opt-in exception. The generic port-by-SN resolver moves to
squid.stage.utils.resolve_serial_port_by_sn; pi._resolve_port_by_sn delegates,
keeping its FTDI-bind-rule hint and existing tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e; convert policy sites

- _def.py: ASI_Z_* flag family (ASI_Z_INVERT defaults True: native + is away
  from the sample, squid shows -native; ASI_Z_HOME_MM defaults 0.0 = the
  retracted end). uses_external_z_stage() is a function, not a derived
  constant, because the machine-config loader overrides flag globals after
  definition. _validate_external_z_stage_flags rejects PI+ASI together at
  both config-load sites.
- microscope.py: elif construction block wrapping the XY stage in
  CombinedStage with the LS50, plus a runtime mutual-exclusion guard so
  programmatic/test configs can't silently double-wrap.
- The four external-Z policy sites (home_xyz Z-homing skip + retract-first,
  gui_hcs cached-Z restore, move_z_axis_to_safety_position) now ask
  uses_external_z_stage() instead of OR-ing vendor flags.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ior change)

Lint: black reformat of asi.py and test_stage.py (CI failure).

Simplification/dedup from a 4-angle review:
- drop dead PI-referencing vestiges: backend is_referenced() methods (the
  adapter answers True directly), unused _SimulatedLS50.stop/_halt_count
- unify the factory's simulated/real tail (one initialize/fence/wrap path)
- one unit constant: z_mm_to_usteps uses STEPS_PER_MM; _Z_RESOLUTION_MM
  (same 0.1 um fact) removed
- store the home target in the squid frame and flip at use (no pre-derived
  native state)
- microscope.py: mutual-exclusion delegates to the shared _def validator;
  _config_sn_to_str helper replaces the copy-pasted SN normalization; single
  CombinedStage wrap after the vendor if/elif; fixed a comment that
  contradicted the shared retract flows
- pi.py: CombinedStage docstring/comments no longer claim it is V-308-only
- gui_hcs.py: drop the dead ASI import (the import block has no consumers)
- _def.py: note that the power-on-retracted convention is load-bearing for
  the shared retract flows, not just home()
- tests: _sim_combined_stage takes a z_stage, _ls50_ctrl helper, drop a
  redundant assertion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Alpaca233 Alpaca233 force-pushed the feat/ls50-asi-z-stage branch from 587260f to 98b2dbe Compare July 8, 2026 20:13
Alpaca233 and others added 11 commits July 8, 2026 13:54
Read-only by default (port discovery, W Z / status / build-info sanity);
--allow-motion gates a small away-from-sample jog with return-to-start
verification and an optional soft-limit fence clamp test. --simulate dry-runs
against the simulated backend. Reports native and squid-frame positions to
verify the asi_z_invert convention on hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- MS2000Serial.open flushes stale input (cable-plug line noise earns an
  ':N-1' from the controller that otherwise shifts every reply by one)
- initialize() failure now explains itself: baud (ASI RS-232 DIP default is
  9600), port identity, power -- instead of a bare parse error
- axis letter configurable (ASI_Z_AXIS_LETTER, default Z): single-axis
  MS-2000 builds may label their lone axis X
- bring-up script: --scan-bauds sweeps the four ASI rates; --axis override

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed port

A marginal RS-232 link or just-opened adapter can drop the first exchange
without being broken -- initialize() now flushes, waits 200ms, and retries
once before raising. connect_asi_z_stage logs the resolved port/baud/axis so
a GUI-vs-script port discrepancy (e.g. a stale asi_z_serial_port in the ini
overriding the SN) is visible in the log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…en closed

GUI position/status pollers can fire after shutdown closes the serial port
(observed on hardware: PortNotOpenError from widgets._update_position during
cleanup). ASIZStage and PIFocusStage now cache the Squid-frame Z on every
move/read and serve it once closed; get_state reports idle; moves warn and
no-op. Same close-race philosophy as the existing home() guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hardware reality: the LS50 frame is power-on-relative, and the stage is not
always powered on at the retracted end -- fixed squid-frame StageConfig
limits then fence arbitrary positions (observed: the [0.5, 6.0] fence blocked
reaching the true top). set_limits on the ASI Z is now a no-op unless
ASI_Z_APPLY_SOFTWARE_LIMITS is set; the frame-independent ASI_Z_TRAVEL_MM
fence still applies at connect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…zero at the away limit

Hardware bring-up showed native 0 is wherever the controller powered on (the
stage was observed mid-travel at Z=0), and stale controller-side SL/SU from
previous sessions silently clamp moves. Startup now: (1) ALWAYS clears the
controller soft limits (they would clamp the find-zero move itself), (2) with
ASI_Z_FIND_ZERO_ON_STARTUP (default on, skipped on in-place restart) drives
past full travel toward the away-from-sample limit switch — the safe
direction — and defines native 0 there ('H Z=0'), (3) applies the fresh
±travel fence in the new frame. Squid 0 is now always the true
farthest-from-sample position, matching the pre-serial convention; the
display negation (squid + toward sample) is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Runs the startup frame routine observably (clear stale limits, drive to the
away limit, zero there) with before/after positions and elapsed time, so the
limit-switch stop behavior can be verified once before the GUI relies on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…SL Z-)

The MS-2000 manual states SL/SU limits persist across power cycles
automatically -- so any fence from any previous session sticks until
overwritten, which explains limits surviving with the software closed. The
clear now uses the documented restore-defaults dash form ('SL Z-'), avoiding
the direction-dependent sign semantics of numeric limits; pre-~2013 firmware
that error-acks the dash form falls back to a wide numeric window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ricating limits

Per review feedback: the ±1000 fallback window and the 50.0 travel default
were arbitrary. clear_travel_limits now uses only the documented
restore-defaults dash form and raises actionably on firmware that lacks it
(only the controller knows its own defaults); find-zero requires the
configured physical travel (ASI_Z_TRAVEL_MM) for its overdrive; the bring-up
--find-zero mode requires --travel-mm explicitly; the find-zero timeout is a
named, documented constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ocity

The controller is an MFC-2000 (not MS-2000): its datasheet max Z velocity is
0.6 mm/s, so a full 50 mm find-zero drive takes ~80 s -- the previous 60 s
timeout would abort it mid-travel. The timeout now derives from the overdrive
distance at the datasheet velocity with a 2x margin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ead flag, stale prose

From a 4-angle review of the branch:
- MS2000Serial owns the controller-global concerns its clients duplicated:
  is_busy()/wait_idle() ('/' polling), command_with_settle_retry (first-
  contact dead-air retry), reset_input_buffer(); parse_ms2000_number shared;
  LS50Controller delegates instead of re-implementing.
- squid.stage.utils.resolve_port: the explicit-port/SN/raise ladder, folded
  from both the ASI and PI factories.
- ASI_Z_HOME_ON_STARTUP removed: redundant (find-zero ends at the home
  target; home_xyz retracts on every fresh boot) and its no-motion rationale
  contradicted the find-zero default three lines below.
- find-zero + travel pairing now validated at machine-config load, next to
  the other flag validators, instead of failing mid-bring-up.
- Stale prose from the pre-find-zero frame model corrected across asi.py
  docstrings, _def.py flag comments, microscope.py, and the bring-up tool
  ("power on with the stage retracted" is no longer an operator instruction).
- _SimulatedLS50: unexercised fence-shift block dropped from zero_here.
- tests/tools.py gains shared FakeSerialConn/FakeZStage; test_stage.py uses
  the shared fake.

Co-Authored-By: Claude Fable 5 <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