Skip to content

Commit f8a408f

Browse files
authored
Add KINESIS stage + new galvo waveforms and tests (#1176)
* Add KINESIS stage + new galvo waveforms and tests Add support for Thorlabs Kinesis (serial) stages: new pylablib-backed API (pykinesis_controller), a KINESISStage implementation in the Thorlabs stage module, and configuration DB entry. Add quadratic and centered_cubic galvo waveforms and wire them into GalvoBase. Make MS2000 serial buffer sizing platform-safe (only attempt set_buffer_size on Windows). Update docs to list new waveform options and KINESIS stage config, and add/adjust unit tests for MS2000, waveforms, galvo base, and device startup flow (including a test ensuring the KINESIS start path uses the factory). * Address Kinesis review diagnostics * Document Kinesis optional dependency
1 parent 07b14e2 commit f8a408f

16 files changed

Lines changed: 701 additions & 22 deletions

File tree

‎docs/source/02_user_guide/01_supported_hardware/galvo.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ National Instruments
1414

1515
Multiple types of galvanometers have been used, including Cambridge Technologies/Novanta, Thorlabs, and ScannerMAX. Each of these devices are externally controlled via analog signals delivered from an NI-based data acquisition card.
1616

17+
For NI and synthetic galvos, the software supports the following waveform options:
18+
``sawtooth``, ``sine``, ``halfsaw``, ``quadratic``, and ``centered_cubic``.
19+
1720
.. collapse:: Configuration File
1821

1922
.. code-block:: yaml

‎docs/source/02_user_guide/01_supported_hardware/stage.rst‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ MFC2000
154154
MS2000
155155
~~~~~~~
156156

157+
.. note::
158+
159+
On Windows, **navigate** configures serial buffer sizes for MS2000
160+
communication. On Linux/macOS this tuning step is skipped for compatibility.
157161

158162
.. collapse:: Configuration File
159163

@@ -439,7 +443,7 @@ positioning.
439443
stage:
440444
hardware:
441445
-
442-
type: Thorlabs
446+
type: KIM001
443447
serial_number: 74000375
444448
axes: [f]
445449
axes_mapping: [1]
@@ -528,6 +532,69 @@ KST101
528532
529533
|
530534
535+
-----------------
536+
537+
KINESIS (Serial)
538+
~~~~~~~~~~~~~~~~
539+
540+
This mode supports Thorlabs Kinesis stepper communication through
541+
``pylablib`` using a serial device path (for example ``/dev/ttyUSB1`` on Linux).
542+
543+
Install the optional KINESIS dependency extra before using this stage:
544+
545+
.. code-block:: bash
546+
547+
pip install "navigate-micro[kinesis]"
548+
549+
.. note::
550+
551+
``steps_per_um`` is the preferred scale parameter for KINESIS stages.
552+
If omitted, **navigate** falls back to ``device_units_per_mm``.
553+
554+
.. note::
555+
556+
The current KINESIS implementation assumes a single Kinesis stage connected
557+
through one serial device path.
558+
559+
.. collapse:: Configuration File
560+
561+
.. code-block:: yaml
562+
563+
microscopes:
564+
microscope_name:
565+
stage:
566+
hardware:
567+
-
568+
type: KINESIS
569+
serial_number: /dev/ttyUSB1
570+
axes: [f]
571+
axes_mapping: [1]
572+
steps_per_um: 2008.623
573+
min: 0
574+
max: 25
575+
joystick_axes: [f]
576+
x_min: -10000.0
577+
x_max: 10000.0
578+
y_min: -10000.0
579+
y_max: 10000.0
580+
z_min: -10000.0
581+
z_max: 10000.0
582+
theta_min: 0.0
583+
theta_max: 360.0
584+
f_min: -10000.0
585+
f_max: 10000.0
586+
x_offset: 0.0
587+
y_offset: 0.0
588+
z_offset: 0.0
589+
theta_offset: 0.0
590+
f_offset: 0.0
591+
flip_x: False
592+
flip_y: False
593+
flip_z: False
594+
flip_f: False
595+
596+
|
597+
531598
--------------
532599

533600
.. _galvo_stage:

‎pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ photometrics = [
5151
"PyVCAM"
5252
]
5353

54+
kinesis = [
55+
"pylablib"
56+
]
57+
5458
dev = [
5559
"pytest",
5660
"pytest-xvfb",

‎src/navigate/config/configuration_database.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@
182182
"Sutter Instruments": ("MP285", "sutter"),
183183
"ThorLabs KCube Inertial Device KIM001": ("KIM001", "thorlabs"),
184184
"ThorLabs KCube Inertial Device KST101": ("KST101", "thorlabs"),
185+
"ThorLabs Kinesis Stepper Motor (Serial)": ("KINESIS", "thorlabs"),
185186
"Virtual Device": ("Synthetic", "synthetic"),
186187
}
187188

‎src/navigate/model/devices/APIs/asi/asi_MS2000_controller.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232

3333
# Standard Imports
34+
import platform
3435
import time
3536
import logging
3637

@@ -104,8 +105,12 @@ def connect_to_serial(
104105
self.serial.write_timeout = write_timeout
105106
self.serial.timeout = read_timeout
106107

107-
# set the size of the rx and tx buffers before calling open
108-
self.serial.set_buffer_size(rx_size, tx_size)
108+
# set_buffer_size is only available/reliable on some platforms
109+
if platform.system() == "Windows" and hasattr(self.serial, "set_buffer_size"):
110+
try:
111+
self.serial.set_buffer_size(rx_size, tx_size)
112+
except Exception as e:
113+
logger.debug(f"Unable to set serial buffer size on Windows: {e}")
109114
try:
110115
self.serial.open()
111116
except SerialException:
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
2+
# All rights reserved.
3+
#
4+
# Redistribution and use in source and binary forms, with or without
5+
# modification, are permitted for academic and research use only (subject to the
6+
# limitations in the disclaimer below) provided that the following conditions are met:
7+
#
8+
# * Redistributions of source code must retain the above copyright notice,
9+
# this list of conditions and the following disclaimer.
10+
#
11+
# * Redistributions in binary form must reproduce the above copyright
12+
# notice, this list of conditions and the following disclaimer in the
13+
# documentation and/or other materials provided with the distribution.
14+
#
15+
# * Neither the name of the copyright holders nor the names of its
16+
# contributors may be used to endorse or promote products derived from this
17+
# software without specific prior written permission.
18+
#
19+
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
20+
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
21+
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22+
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
23+
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
24+
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
25+
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
26+
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
27+
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
28+
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30+
# POSSIBILITY OF SUCH DAMAGE.
31+
32+
"""Thorlabs Kinesis controller wrapper backed by pylablib."""
33+
34+
import logging
35+
from time import sleep
36+
from typing import Any
37+
38+
# Logger Setup
39+
p = __name__.split(".")[1]
40+
logger = logging.getLogger(p)
41+
42+
SLEEP_AFTER_WAIT = 0.100
43+
44+
45+
class KinesisStage:
46+
"""Simple wrapper around pylablib's Kinesis motor API."""
47+
48+
def __init__(self, dev_path: str, verbose: bool = False):
49+
self.verbose = verbose
50+
self.dev_path = str(dev_path)
51+
self.stage = None
52+
self.move_params: dict[str, float] = {
53+
"min_velocity": 0.0,
54+
"max_velocity": 0.0,
55+
"acceleration": 0.0,
56+
}
57+
self.open()
58+
59+
@staticmethod
60+
def _load_thorlabs_backend() -> Any:
61+
"""Load pylablib Thorlabs backend lazily."""
62+
try:
63+
from pylablib.devices import Thorlabs
64+
except ImportError as e:
65+
raise ImportError("pylablib is required for KINESIS stage support.") from e
66+
return Thorlabs
67+
68+
def open(self) -> None:
69+
"""Open the device for communications."""
70+
thorlabs = self._load_thorlabs_backend()
71+
connection = {"port": self.dev_path, "baudrate": 115200, "rtscts": True}
72+
try:
73+
self.stage = thorlabs.KinesisMotor(("serial", connection), scale="step")
74+
except Exception as e:
75+
raise ConnectionError(f"KINESIS stage connection failed: {e}") from e
76+
77+
def close(self) -> None:
78+
"""Disconnect and close the device."""
79+
if self.stage is None:
80+
return
81+
try:
82+
self.stage.stop()
83+
except Exception as exc:
84+
logger.debug(
85+
"Failed to stop KINESIS stage cleanly during close: %s",
86+
exc,
87+
exc_info=True,
88+
)
89+
self.stage.close()
90+
91+
def move_to_position(
92+
self, position_um: float, steps_per_um: float, wait_till_done: bool
93+
) -> None:
94+
"""Move to absolute position in microns."""
95+
current_steps = self.stage.get_position(channel=1, scale=False)
96+
target_steps = int(round(float(position_um) * float(steps_per_um)))
97+
delta_steps = target_steps - int(current_steps)
98+
self.stage.move_by(delta_steps, channel=1, scale=False)
99+
if wait_till_done:
100+
self.stage.wait_move(channel=1)
101+
if SLEEP_AFTER_WAIT:
102+
sleep(SLEEP_AFTER_WAIT)
103+
104+
def get_current_position(self, steps_per_um: float) -> float:
105+
"""Get current position in microns."""
106+
current_steps = self.stage.get_position(channel=1, scale=False)
107+
position_um = float(current_steps) / float(steps_per_um)
108+
return round(position_um, 2)
109+
110+
def stop(self) -> None:
111+
"""Halt motion."""
112+
self.stage.stop()
113+
114+
def home_stage(self) -> None:
115+
"""Run homing sequence."""
116+
self.stage.home()
117+
118+
def set_velocity_params(
119+
self,
120+
min_velocity: float,
121+
max_velocity: float,
122+
acceleration: float,
123+
steps_per_um: float,
124+
) -> None:
125+
"""Set motion profile parameters."""
126+
min_velocity_steps = min_velocity * steps_per_um
127+
max_velocity_steps = max_velocity * steps_per_um
128+
acceleration_steps = acceleration * steps_per_um
129+
self.stage.set_move_params(
130+
min_velocity_steps, max_velocity_steps, acceleration_steps
131+
)
132+
self.move_params = {
133+
"min_velocity": min_velocity_steps,
134+
"max_velocity": max_velocity_steps,
135+
"acceleration": acceleration_steps,
136+
}

‎src/navigate/model/devices/galvo/base.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
# Third Party Imports
3939

4040
# Local Imports
41-
from navigate.model.waveforms import sawtooth, sine_wave
41+
from navigate.model.waveforms import centered_cubic, quadratic, sawtooth, sine_wave
4242
from navigate.tools.decorators import log_initialization
4343

4444
# # Logger Setup
@@ -220,6 +220,24 @@ def adjust(
220220
duty_cycle=galvo_rising_ramp,
221221
phase=self.camera_delay,
222222
)
223+
elif self.galvo_waveform == "centered_cubic":
224+
self.waveform_dict[channel_key] = centered_cubic(
225+
sample_rate=self.sample_rate,
226+
sweep_time=self.sweep_time,
227+
exposure=exposure_time,
228+
delay=self.camera_delay,
229+
amplitude=galvo_amplitude,
230+
offset=galvo_offset,
231+
)
232+
elif self.galvo_waveform == "quadratic":
233+
self.waveform_dict[channel_key] = quadratic(
234+
sample_rate=self.sample_rate,
235+
sweep_time=self.sweep_time,
236+
exposure=exposure_time,
237+
delay=self.camera_delay,
238+
amplitude=galvo_amplitude,
239+
offset=galvo_offset,
240+
)
223241
elif self.galvo_waveform == "sine":
224242
self.waveform_dict[channel_key] = sine_wave(
225243
sample_rate=self.sample_rate,

0 commit comments

Comments
 (0)