feat: init

This commit is contained in:
2025-12-04 16:07:30 +08:00
commit 262583a57f
681 changed files with 117578 additions and 0 deletions

View File

View File

@@ -0,0 +1,175 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only
# (subject to the limitations in the disclaimer below)
# provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Standard library imports
# Third party imports
import pytest
# Local application imports
@pytest.mark.hardware
@pytest.fixture(autouse=True, scope="class")
def open_camera():
from navigate.model.devices.APIs.hamamatsu.HamamatsuAPI import DCAM, camReg
# open camera
for i in range(10):
assert camReg.numCameras == 0
try:
camera = DCAM()
if camera.get_camera_handler() != 0:
break
camera.dev_close()
camera = None
except Exception:
continue
yield camera
if camera is not None:
assert camReg.numCameras == 1
camera.dev_close()
assert camReg.numCameras == 0
@pytest.mark.hardware
class TestHamamatsuAPI:
@pytest.fixture(autouse=True)
def _prepare_camera(self, open_camera):
self.camera = open_camera
assert self.camera is not None
def test_get_and_set_property_value(self):
# set property
configuration = {
"subarray_mode": 1,
"sensor_mode": 12, # 12 for progressive
"defect_correct_mode": 2.0,
"binning": 1.0,
"readout_speed": 1.0,
"trigger_active": 1.0,
"trigger_mode": 1.0, # external light-sheet mode
"trigger_polarity": 2.0, # positive pulse
"trigger_source": 3.0, # software
"exposure_time": 0.02,
"internal_line_interval": 0.000075,
}
for k in configuration:
assert self.camera.set_property_value(
k, configuration[k]
), f"can't set property{k} with value{configuration[k]}"
def is_in_range(value, target, precision=100):
target_min = target - target / precision
target_max = target + target / precision
return value > target_min and value < target_max
# get property
for k in configuration:
v = self.camera.get_property_value(k)
assert is_in_range(v, configuration[k]), f"The value of {k} isn't right!"
# set a non-exist property
assert (
self.camera.set_property_value("non-exist-property", 100) is False
), "can't handle non-exist property name"
def test_ROI(self):
import random
rects = [(0, 0, 2047, 2047), (512, 512, 1535, 1535), (768, 768, 1279, 1279)]
for i in range(10):
r = random.randint(0, len(rects) - 1)
rect = rects[r]
self.camera.set_ROI(*rect)
assert self.camera.get_property_value("image_width") == (
rect[2] - rect[0] + 1
), f"ROI Width: {(rect[2]-rect[0]+1)}"
assert self.camera.get_property_value("image_height") == (
rect[3] - rect[1] + 1
), f"ROI Height: {(rect[3]-rect[1]+1)}"
def test_acquisition(self):
import random
import time
from navigate.model.concurrency.concurrency_tools import SharedNDArray
configuration = {
"sensor_mode": 12, # 12 for progressive
"defect_correct_mode": 2.0,
"binning": 1.0,
"readout_speed": 1.0,
"trigger_active": 1.0,
"trigger_mode": 1.0, # external light-sheet mode
"trigger_polarity": 2.0, # positive pulse
"trigger_source": 3.0, # software
"exposure_time": 0.02,
"internal_line_interval": 0.000075,
}
for k in configuration:
self.camera.set_property_value(k, configuration[k])
number_of_frames = 100
data_buffer = [
SharedNDArray(shape=(2048, 2048), dtype="uint16")
for i in range(number_of_frames)
]
# attach a buffer without detach a buffer
r = self.camera.start_acquisition(data_buffer, number_of_frames)
assert r is True, "attach the buffer correctly!"
r = self.camera.start_acquisition(data_buffer, number_of_frames)
# Confirmed that we can't attach a new buffer before detaching one
assert r is False, "attach the buffer correctly!"
self.camera.start_acquisition(data_buffer, number_of_frames)
readout_time = self.camera.get_property_value("readout_time")
for i in range(10):
trigger_num = random.randint(0, 30)
for j in range(trigger_num):
self.camera.fire_software_trigger()
time.sleep(configuration["exposure_time"] + readout_time)
time.sleep(0.1)
frames = self.camera.get_frames()
assert len(frames) == trigger_num, "can not get all frames back!"
self.camera.stop_acquisition()
# detach a detached buffer
self.camera.stop_acquisition()

View File

View File

View File

View File

@@ -0,0 +1,80 @@
"""Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only
(subject to the limitations in the disclaimer below)
# provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#"""
# Third Party Imports
from navigate.model.devices.camera.synthetic import SyntheticCamera
def test_start_camera(dummy_model):
model = dummy_model
for microscope_name in model.configuration["configuration"]["microscopes"].keys():
camera = SyntheticCamera(microscope_name, None, model.configuration)
assert (
camera.camera_parameters["hardware"]["serial_number"]
== model.configuration["configuration"]["microscopes"][microscope_name][
"camera"
]["hardware"]["serial_number"]
), f"didn't load correct camera parameter for microscope {microscope_name}"
# non-exist microscope name
microscope_name = (
model.configuration["configuration"]["microscopes"].keys()[0] + "_random_error"
)
raised_error = False
try:
_ = SyntheticCamera(microscope_name, None, model.configuration)
except NameError:
raised_error = True
assert (
raised_error
), "should raise NameError when the microscope name doesn't exist!"
def test_camera_base_functions(dummy_model):
import random
model = dummy_model
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
camera = SyntheticCamera(microscope_name, None, model.configuration)
funcs = ["set_readout_direction", "calculate_light_sheet_exposure_time"]
args = [[random.random()], [random.random(), random.random()]]
for f, a in zip(funcs, args):
if a is not None:
getattr(camera, f)(*a)
else:
getattr(camera, f)()

View File

@@ -0,0 +1,193 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Third Party Imports
import pytest
import numpy as np
from navigate.model.devices.camera.synthetic import (
SyntheticCamera,
SyntheticCameraController,
)
@pytest.fixture(scope="class")
def synthetic_camera(dummy_model):
dummy_model = dummy_model
scc = SyntheticCameraController()
microscope_name = dummy_model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
synthetic_camera = SyntheticCamera(microscope_name, scc, dummy_model.configuration)
return synthetic_camera
class TestSyntheticCamera:
"""Unit Test for Camera Synthetic Class"""
@pytest.fixture(autouse=True)
def _prepare_camera(self, synthetic_camera):
self.synthetic_camera = synthetic_camera
def test_synthetic_camera_attributes(self):
desired_attributes = [
"x_pixels",
"y_pixels",
"is_acquiring",
"_mean_background_count",
"_noise_sigma",
"camera_controller",
"current_frame_idx",
"data_buffer",
"num_of_frame",
"pre_frame_idx",
]
for da in desired_attributes:
assert hasattr(self.synthetic_camera, da)
def test_synthetic_camera_wheel_attributes_type(self):
desired_attributes = {
"x_pixels": int,
"y_pixels": int,
"is_acquiring": bool,
"_mean_background_count": int,
"_noise_sigma": np.float64,
# 'current_frame_idx': None,
# 'data_buffer': None,
# 'num_of_frame': None,
# 'pre_frame_idx': None,
}
for key in desired_attributes:
attribute = getattr(self.synthetic_camera, key)
print(key, type(attribute), desired_attributes[key])
assert type(attribute) == desired_attributes[key]
def test_synthetic_camera_methods(self):
methods = [
"report_settings",
"close_camera",
"set_sensor_mode",
"set_exposure_time",
"set_line_interval",
"set_binning",
"initialize_image_series",
"close_image_series",
"generate_new_frame",
"get_new_frame",
"set_ROI",
]
for m in methods:
assert hasattr(self.synthetic_camera, m) and callable(
getattr(self.synthetic_camera, m)
)
def test_synthetic_camera_wheel_method_calls(self):
self.synthetic_camera.report_settings()
self.synthetic_camera.close_camera()
self.synthetic_camera.set_sensor_mode(mode="test")
self.synthetic_camera.set_exposure_time(exposure_time=0.2)
self.synthetic_camera.set_line_interval(line_interval_time=1)
self.synthetic_camera.set_binning(binning_string="2x2")
self.synthetic_camera.initialize_image_series()
self.synthetic_camera.close_image_series()
self.synthetic_camera.get_new_frame()
self.synthetic_camera.set_ROI()
def test_synthetic_camera_exposure(self):
exposure_time = 200
self.synthetic_camera.set_exposure_time(exposure_time=exposure_time / 1000)
assert (exposure_time / 1000) == self.synthetic_camera.camera_exposure_time
def test_synthetic_camera_binning(self):
x_pixels = self.synthetic_camera.x_pixels
self.synthetic_camera.set_binning(binning_string="2x2")
assert self.synthetic_camera.x_binning == 2
assert self.synthetic_camera.y_binning == 2
assert type(self.synthetic_camera.x_binning) == int
assert type(self.synthetic_camera.y_binning) == int
assert self.synthetic_camera.x_pixels == x_pixels / 2
def test_synthetic_camera_initialize_image_series(self):
self.synthetic_camera.initialize_image_series()
assert self.synthetic_camera.num_of_frame == 100
assert self.synthetic_camera.data_buffer is None
assert self.synthetic_camera.current_frame_idx == 0
assert self.synthetic_camera.pre_frame_idx == 0
assert self.synthetic_camera.is_acquiring is True
def test_synthetic_camera_close_image_series(self):
self.synthetic_camera.close_image_series()
assert self.synthetic_camera.pre_frame_idx == 0
assert self.synthetic_camera.current_frame_idx == 0
assert self.synthetic_camera.is_acquiring is False
def test_synthetic_camera_acquire_images(self):
import random
from navigate.model.concurrency.concurrency_tools import SharedNDArray
number_of_frames = 100
data_buffer = [
SharedNDArray(shape=(2048, 2048), dtype="uint16")
for i in range(number_of_frames)
]
self.synthetic_camera.initialize_image_series(data_buffer, number_of_frames)
assert self.synthetic_camera.is_acquiring is True, "should be acquring"
frame_idx = 0
for i in range(10):
frame_num = random.randint(1, 30)
for j in range(frame_num):
self.synthetic_camera.generate_new_frame()
frames = self.synthetic_camera.get_new_frame()
assert len(frames) == frame_num, "frame number isn't right!"
assert frames[0] == frame_idx, "frame idx isn't right!"
frame_idx = (frame_idx + frame_num) % number_of_frames
self.synthetic_camera.close_image_series()
assert (
self.synthetic_camera.is_acquiring is False
), "is_acquiring should be False"
def test_synthetic_camera_set_roi(self):
self.synthetic_camera.set_ROI()
assert self.synthetic_camera.x_pixels == 2048
assert self.synthetic_camera.y_pixels == 2048
self.synthetic_camera.set_ROI(roi_height=500, roi_width=700)
assert self.synthetic_camera.x_pixels == 700
assert self.synthetic_camera.y_pixels == 500

View File

@@ -0,0 +1,618 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Standard Library Imports
import pytest
from typing import Tuple
from unittest.mock import patch, MagicMock
import logging
import io
# Third Party Imports
import numpy as np
from numpy.testing import assert_array_equal
# Local Imports
from navigate.model.utils.exceptions import UserVisibleException
try:
import gxipy # noqa: F401
except:
@pytest.fixture(autouse=True)
def mock_daheng_module():
fake_gx = MagicMock()
with patch.dict("sys.modules", {"gxipy": fake_gx}):
yield fake_gx
@pytest.fixture
def mock_daheng_sdk():
"""Patch Daheng SDK (gxipy) and return mocked device + subsystems."""
with patch(
"navigate.model.devices.camera.daheng.gx.DeviceManager"
) as mock_device_manager:
device = _create_mock_device(mock_device_manager)
feature_control = _create_mock_feature_control()
data_stream, raw_image = _create_mock_image_pipeline()
_attach_mock_interfaces_to_device(device, feature_control, data_stream)
yield {
"device": device,
"feature_control": feature_control,
"data_stream": data_stream,
"raw_image": raw_image,
}
def _attach_mock_interfaces_to_device(device, feature_control, data_stream):
"""
Attach core SDK interfaces to a mock Daheng device.
In the gxipy SDK, once a device is opened, it provides two key subsystems:
- feature_control (via get_remote_device_feature_control()):
This is the interface for configuring camera hardware settings such as
exposure time, gain, trigger mode, binning, resolution, and ROI.
The SDK exposes these through feature "objects" with .get()/.set() methods.
- data_stream (accessed as a property):
This handles actual image acquisition. It provides methods to start/stop
streaming and to retrieve frames via .snap_image().
This function sets up mock versions of those subsystems to a MagicMock-based
device object, enabling testable interaction without requiring physical hardware.
Parameters
----------
device : MagicMock
The mocked gxipy.Device object to configure.
feature_control : MagicMock
Mocked feature control interface to simulate hardware parameters.
data_stream : MagicMock
Mocked data stream interface to simulate image capture.
"""
device.get_remote_device_feature_control.return_value = feature_control
device.data_stream = data_stream
def _create_mock_device(mock_device_manager) -> MagicMock:
"""
Create a fake Daheng device and configure DeviceManager return values.
This sets up:
- update_device_list() -> None
- get_device_list() -> list with one fake serial number
- open_device_by_index(i) -> the mock device
Returns
-------
MagicMock
A fake Daheng device object.
"""
mock_device = MagicMock(name="FakeDevice")
mock_device_manager.return_value.update_device_list.return_value = None
mock_device_manager.return_value.get_device_list.return_value = [{"sn": "1234"}]
mock_device_manager.return_value.open_device_by_index.return_value = mock_device
return mock_device
def _create_mock_feature_control() -> MagicMock:
"""
Create a fake FeatureControl interface that simulates Daheng camera settings.
Simulates:
- get_string_feature("DeviceSerialNumber").get() -> "1234"
- get_int_feature(...).get() -> 2048
- get_enum_feature(...).set(...) -> None
Returns
-------
MagicMock
A mock feature_control object.
"""
mock_feature_control = MagicMock(name="FakeFeatureControl")
mock_feature_control.get_string_feature.return_value.get.return_value = "1234"
mock_feature_control.get_int_feature.side_effect = lambda name: MagicMock(
get=MagicMock(return_value=2048)
)
mock_feature_control.get_enum_feature.return_value.set.return_value = None
return mock_feature_control
def _create_mock_image_pipeline() -> Tuple[MagicMock, MagicMock]:
"""
Create a mocked data stream and raw image pipeline.
Simulates:
- data_stream.snap_image() -> mock_raw_image
- raw_image.get_numpy_array() -> np.zeros((2048, 2048), dtype=np.uint16)
Returns
-------
Tuple[MagicMock, MagicMock]
(mock_data_stream, mock_raw_image)
"""
stream = MagicMock(name="FakeDataStream")
image = MagicMock(name="FakeRawImage")
stream.snap_image.return_value = image
image.get_numpy_array.return_value = np.zeros((2048, 2048), dtype=np.uint16)
return stream, image
@pytest.fixture
def camera(mock_daheng_sdk):
"""
Return a DahengCamera instance connected via mocked SDK.
The mock_daheng_sdk fixture is required to patch the SDK and simulate hardware.
It's not used directly in this function, but must be active when connect() is called.
"""
# Use the patched classmethod to simulate SDK connection.
# This is where mock_daheng_sdk enters.
from navigate.model.devices.camera.daheng import DahengCamera
# Minimal config object matching Navigate's expected schema
config = {
"configuration": {
"microscopes": {
"test_scope": {
"camera": {
"hardware": {
"serial_number": "1234",
}
}
}
}
}
}
camera = DahengCamera(
microscope_name="test_scope",
device_connection=mock_daheng_sdk["device"],
configuration=config,
)
# Initialize and return the test camera instance
return camera
@patch("navigate.model.devices.camera.daheng.gx.DeviceManager")
def test_connect_without_serial(mock_dm):
"""
Test that DahengCamera.connect() connects to the first camera if no serial number is provided.
This uses patching to replace the actual DeviceManager with a mock,
simulating a single connected camera with serial '1234'.
"""
mock_device = MagicMock()
# Simulate SDK returning one device with serial '1234'
mock_dm.return_value.get_device_list.return_value = [{"sn": "1234"}]
# Simulate opening that device returns our mock_device
mock_dm.return_value.open_device_by_index.return_value = mock_device
# Call connect without specifying serial number
from navigate.model.devices.camera.daheng import DahengCamera
device = DahengCamera.connect()
# Verify that we get the mocked device object
assert device == mock_device
@patch("navigate.model.devices.camera.daheng.gx.DeviceManager")
def test_connect_invalid_serial_raises(mock_dm):
"""
Test that DahengCamera.connect() raises a UserVisibleException if the
specified serial number does not match any connected camera.
This verifies the fallback else-block logic in the for-loop of connect().
"""
# Simulate one connected device with serial '1234'
mock_dm.return_value.get_device_list.return_value = [{"sn": "1234"}]
from navigate.model.devices.camera.daheng import DahengCamera
# Attempt to connect with a non-existent serial number
with pytest.raises(
UserVisibleException, match="Daheng camera with serial INVALID_SN not found."
):
DahengCamera.connect(serial_number="INVALID_SN")
def test_str(camera):
"""
Test the string representation of the DahengCamera object.
Ensures that the __str__ method includes the camera model name,
serial number, and connection status in the returned string.
"""
result = str(camera)
assert "MER2_1220_32U3C Camera" in result
assert "Serial: 1234" in result
assert "Connected" in result
def test_camera_connected(camera):
"""
Test that the camera object reports a connected state after setup.
This relies on the 'camera' fixture, which internally calls DahengCamera.connect()
and initializes the SDK state. Verifies that is_connected is True and the
device serial number is correctly cached.
"""
assert camera.is_connected
assert camera.device_serial_number == "1234"
def test_disconnect_clears_state(camera):
"""
Test that disconnect() resets internal state and marks camera as disconnected.
"""
camera.disconnect()
assert camera.device is None
assert camera.feature_control is None
assert camera.is_connected is False
assert camera.serial_number == "UNKNOWN"
def test_set_exposure_time(camera):
"""
Test that set_exposure_time() updates internal state and calls correct SDK feature.
"""
camera.set_exposure_time(0.1)
# Internal caching of exposure time (in seconds)
assert camera._exposure_time == 0.1
# Verifies that the SDK was told to get the 'ExposureTime' feature
camera.feature_control.get_float_feature.assert_called_with("ExposureTime")
def test_set_gain(camera):
"""
Ensure set_gain() calls the Gain feature with the expected float value.
"""
camera.set_gain(5.0)
camera.feature_control.get_float_feature.assert_called_with("Gain")
camera.feature_control.get_float_feature.return_value.set.assert_called_with(5.0)
def test_set_binning(camera):
"""
Test that set_binning() parses input string, updates binning values,
and accesses correct SDK features.
"""
result = camera.set_binning("2x2")
assert result is True
assert camera.x_binning == 2
assert camera.y_binning == 2
# Check that the SDK was asked for the correct feature names at least once
camera.feature_control.get_int_feature.assert_any_call("BinningHorizontal")
camera.feature_control.get_int_feature.assert_any_call("BinningVertical")
def test_set_invalid_ROI(camera):
"""
Test that set_ROI() returns False and logs a warning when given invalid dimensions.
"""
# Set invalid ROI parameters
roi_width = 9000
roi_height = 2048
center_x = 1000
center_y = 1000
logger = logging.getLogger("model")
logger.propagate = False # prevent sending logs to root CLI handler
stream = io.StringIO()
handler = logging.StreamHandler(stream)
logger.addHandler(handler)
camera.stop_acquisition()
handler.flush()
result = camera.set_ROI(
roi_width=roi_width, roi_height=roi_height, center_x=center_x, center_y=center_y
)
assert result is False
assert f"Invalid ROI dimensions: {roi_width}x{roi_height}" in stream.getvalue()
logger.removeHandler(handler)
def test_snap_image_returns_numpy_array(camera):
"""
Test that snap_image() calls the SDK and returns a NumPy array.
The camera fixture uses the mock_daheng_sdk fixture to simulate:
- A data stream whose snap_image() returns a fake image object
- An image object whose get_numpy_array() returns np.ndarray representing a fake image (zeros)
"""
result = camera.snap_image()
expected = np.zeros((2048, 2048), dtype=np.uint16)
assert_array_equal(result, expected)
camera.data_stream.snap_image.assert_called_once()
def test_snap_software_triggered_invalid_config(camera):
"""
Test that snap_software_triggered() raises if trigger config is invalid.
This mocks the 'TriggerMode' and 'TriggerSource' enum features to return
incorrect values ('OFF' and 'HARDWARE'), and verifies that the method
raises a UserVisibleException with a helpful message.
"""
# Override trigger mode/source with bad values
mock_enum_feature = MagicMock()
mock_enum_feature.get_current_entry.return_value.get_symbolic.side_effect = [
"OFF",
"HARDWARE",
]
camera.feature_control.get_enum_feature.return_value = mock_enum_feature
with pytest.raises(
UserVisibleException, match="TriggerMode='ON' and TriggerSource='SOFTWARE'"
):
camera.snap_software_triggered()
def test_send_software_trigger(camera):
"""
Test that send_software_trigger() calls the correct Daheng SDK command.
Verifies that the camera issues a 'TriggerSoftware' command via the
command feature interface and that send_command() is called exactly once.
"""
camera.send_software_trigger()
camera.feature_control.get_command_feature.assert_called_with("TriggerSoftware")
camera.feature_control.get_command_feature.return_value.send_command.assert_called_once()
def test_set_trigger_mode(camera):
"""
Test that set_trigger_mode() calls the correct enum feature and sets it to 'ON'.
"""
camera.set_trigger_mode("ON")
camera.feature_control.get_enum_feature.assert_called_with("TriggerMode")
camera.feature_control.get_enum_feature.return_value.set.assert_called_with("ON")
def test_set_trigger_source(camera):
"""
Test that set_trigger_source() selects the correct SDK enum feature and sets it to 'LINE1'.
'LINE1' refers to a physical input pin used for hardware triggering,
typically driven by a DAQ, microcontroller, or timing controller.
"""
camera.set_trigger_source("LINE1")
camera.feature_control.get_enum_feature.assert_called_with("TriggerSource")
camera.feature_control.get_enum_feature.return_value.set.assert_called_with("LINE1")
def test_initialize_and_start_acquisition(camera):
"""
Test that initialize_image_series and start_acquisition correctly
update internal state and interact with the SDK.
"""
# Create a fake image buffer with shape matching camera resolution
fake_buffer = [MagicMock(name=f"Frame{i}") for i in range(5)]
number_of_frames = 5
# Initialize acquisition
camera.initialize_image_series(
data_buffer=fake_buffer, number_of_frames=number_of_frames
)
# Assert acquisition is marked as started
assert camera.is_acquiring is True
assert camera._number_of_frames == number_of_frames
assert camera._frames_received == 0
assert camera._data_buffer == fake_buffer
# Start the acquisition and verify SDK interaction
camera.data_stream.start_stream.assert_called_once()
camera.feature_control.get_command_feature.assert_called_with("AcquisitionStart")
camera.feature_control.get_command_feature.return_value.send_command.assert_called_once()
def test_initialize_start_and_receive_image(camera):
"""
Test full acquisition flow:
- initialize_image_series()
- start_acquisition()
- get_new_frame() to simulate image reception
Verifies that the SDK methods are called, internal state is updated,
and image data is written to the circular buffer.
"""
fake_buffer = [MagicMock(name=f"Frame{i}") for i in range(3)]
number_of_frames = 3
camera.initialize_image_series(
data_buffer=fake_buffer, number_of_frames=number_of_frames
)
# Simulate receiving frames
for i in range(3):
frame_indices = camera.get_new_frame()
assert frame_indices == [i]
fake_buffer[i].__setitem__.assert_called() # Simulates [:, :] = image_data
# Circular buffer check
wraparound = camera.get_new_frame()
assert wraparound == [0]
def test_stop_acquisition(camera):
"""
Test that stop_acquisition() stops both the command and data stream,
clears acquisition state, and accesses the correct command feature.
"""
# Pretend acquisition is running
camera.is_acquiring = True
# Run method
camera.stop_acquisition()
# Ensure the correct SDK command was accessed and triggered
camera.feature_control.get_command_feature.assert_called_with("AcquisitionStop")
camera.feature_control.get_command_feature.return_value.send_command.assert_called_once()
# Ensure the data stream was stopped
camera.data_stream.stop_stream.assert_called_once()
# Verify internal state was updated
assert camera.is_acquiring is False
def test_stop_acquisition_when_disconnected(camera):
"""
Test that stop_acquisition() logs a warning and does not raise
when called on a disconnected camera.
"""
camera.is_connected = False
logger = logging.getLogger("model")
logger.propagate = False # prevent sending logs to root CLI handler
stream = io.StringIO()
handler = logging.StreamHandler(stream)
logger.addHandler(handler)
camera.stop_acquisition()
handler.flush()
assert "not connected" in stream.getvalue()
logger.removeHandler(handler)
def test_set_sensor_mode_logs(camera):
"""
Test that set_sensor_mode() logs a warning for unsupported modes.
If an invalid mode is specified, the camera will be set to Normal
mode (using global shutter).
"""
camera.set_sensor_mode("InvalidModeName")
camera.device.SensorShutterMode.set.assert_called_with(0)
assert camera._scan_mode == 0
def test_snap_software_triggered_success(camera):
"""
Test that snap_software_triggered() works when trigger config is correct.
Mocks TriggerMode='ON' and TriggerSource='SOFTWARE', verifies that the
method sends a software trigger, captures an image, and returns the result.
Uses side_effect to return two enum values from a shared enum feature mock.
"""
# Patch enum feature to simulate correct trigger mode and source
mock_enum_feature = MagicMock()
# First call to get_symbolic() returns 'ON', second returns 'SOFTWARE'
mock_enum_feature.get_current_entry.return_value.get_symbolic.side_effect = [
"ON",
"SOFTWARE",
]
camera.feature_control.get_enum_feature.return_value = mock_enum_feature
# Snap image - behind the scenes, this calls data_stream.snap_image() which
# is mocked during setup to return a fake image whose get_numpy_array() method
# returns np.ndarray representing a fake image.
result = camera.snap_software_triggered()
expected = np.zeros((2048, 2048), dtype=np.uint16)
assert_array_equal(result, expected)
# Ensure the correct trigger command was issued via SDK
camera.feature_control.get_command_feature.return_value.send_command.assert_called_with()
def test_get_new_frame(camera):
"""
Test that get_new_frame() returns correct buffer index in sequence,
and wraps around when the number of received frames exceeds the buffer length.
This simulates a circular buffer behavior across multiple frames.
"""
number_of_images = 3
buffer = [MagicMock() for _ in range(number_of_images)]
# Initialize image acquisition
camera.initialize_image_series(
data_buffer=buffer, number_of_frames=number_of_images
)
camera._frames_received = 0
# First full loop through buffer
for i in range(number_of_images):
result = camera.get_new_frame()
assert result == [i]
# Wraparound: next result should start from 0 again
result = camera.get_new_frame()
assert result == [0]
result = camera.get_new_frame()
assert result == [1]
def test_close_image_series(camera):
"""
Test that close_image_series() stops acquisition and clears buffer state.
This ensures the SDK stream is stopped and internal flags like
is_acquiring and _data_buffer are reset properly.
The data_stream is mocked in the camera fixture (via mock_daheng_sdk).
"""
camera.is_acquiring = True
camera._data_buffer = [MagicMock(), MagicMock()] # Simulate buffered frames
camera.close_image_series()
# Acquisition state should be cleared
assert camera.is_acquiring is False
assert camera._data_buffer == None
# SDK stream should be stopped
camera.data_stream.stop_stream.assert_called_once()

View File

@@ -0,0 +1,379 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Third Party Imports
import pytest
@pytest.mark.hardware
@pytest.fixture(scope="module")
def prepare_cameras(dummy_model):
from navigate.model.devices.APIs.hamamatsu.HamamatsuAPI import DCAM, camReg
from navigate.model.devices.camera.hamamatsu import HamamatsuOrca
def start_camera(idx=0):
# open camera
for i in range(10):
assert camReg.numCameras == idx
try:
camera = DCAM(idx)
if camera.get_camera_handler() != 0:
break
camera.dev_close()
except Exception:
continue
camera = None
return camera
model = dummy_model
temp = {}
for microscope_name in model.configuration["configuration"]["microscopes"].keys():
serial_number = model.configuration["configuration"]["microscopes"][
microscope_name
]["camera"]["hardware"]["serial_number"]
temp[str(serial_number)] = microscope_name
camera_connections = {}
camera = start_camera()
for i in range(camReg.maxCameras):
if i > 0:
camera = start_camera(i)
if str(camera._serial_number) in temp:
microscope_name = temp[str(camera._serial_number)]
camera = HamamatsuOrca(microscope_name, camera, model.configuration)
camera_connections[microscope_name] = camera
yield camera_connections
# close all the cameras
for k in camera_connections:
camera_connections[k].camera_controller.dev_close()
@pytest.mark.hardware
class TestHamamatsuOrca:
"""Unit Test for HamamamatsuOrca Class"""
model = None
@pytest.fixture(autouse=True)
def _prepare_test(self, dummy_model, prepare_cameras):
self.num_of_tests = 10
self.model = dummy_model
self.cameras = prepare_cameras
self.microscope_name = self.model.configuration["experiment"][
"MicroscopeState"
]["microscope_name"]
self.camera = self.cameras[self.microscope_name]
def is_in_range(self, value, target, precision=100):
target_min = target - target / precision
target_max = target + target / precision
return value > target_min and value < target_max
def test_hamamatsu_camera_attributes(self):
from navigate.model.devices.camera.hamamatsu import HamamatsuOrca
attributes = dir(HamamatsuOrca)
desired_attributes = [
"serial_number",
"report_settings",
"close_camera",
"set_sensor_mode",
"set_readout_direction",
"calculate_light_sheet_exposure_time",
"calculate_readout_time",
"set_exposure_time",
"set_line_interval",
"set_binning",
"set_ROI",
"initialize_image_series",
"close_image_series",
"get_new_frame",
]
for da in desired_attributes:
assert da in attributes
def test_init_camera(self):
for microscope_name in self.model.configuration["configuration"][
"microscopes"
].keys():
camera = self.cameras[microscope_name]
assert camera is not None, f"Should start the camera {microscope_name}"
camera_controller = camera.camera_controller
camera_configs = self.model.configuration["configuration"]["microscopes"][
microscope_name
]["camera"]
# serial number
assert str(camera_controller._serial_number) == str(
camera_configs["hardware"]["serial_number"]
), f"the camera serial number isn't right for {microscope_name}!"
assert str(camera.serial_number) == str(
camera_configs["hardware"]["serial_number"]
), f"the camera serial number isn't right for {microscope_name}!"
# verify camera is initialized with the attributes from configuration.yaml
parameters = [
"defect_correct_mode",
"readout_speed",
"trigger_active",
"trigger_mode",
"trigger_polarity",
"trigger_source",
]
for parameter in parameters:
value = camera_controller.get_property_value(parameter)
assert value == camera_configs[parameter]
# sensor mode
sensor_mode = camera_controller.get_property_value("sensor_mode")
expected_value = 1 if camera_configs["sensor_mode"] == "Normal" else 12
assert sensor_mode == expected_value, "Sensor mode isn't right!"
# exposure time
exposure_time = camera_controller.get_property_value("exposure_time")
assert self.is_in_range(
exposure_time, camera_configs["exposure_time"] / 1000, 10
), "Exposure time isn't right!"
# binning
binning = camera_controller.get_property_value("binning")
assert int(binning) == int(
camera_configs["binning"][0]
), "Binning isn't right!"
# image width and height
width = camera_controller.get_property_value("image_width")
assert width == camera_configs["x_pixels"], "image width isn't right"
height = camera_controller.get_property_value("image_height")
assert height == camera_configs["y_pixels"], "image height isn't right"
def test_set_sensor_mode(self):
modes = {"Normal": 1, "Light-Sheet": 12, "RandomMode": None}
for mode in modes:
pre_value = self.camera.camera_controller.get_property_value("sensor_mode")
self.camera.set_sensor_mode(mode)
value = self.camera.camera_controller.get_property_value("sensor_mode")
if modes[mode] is not None:
assert value == modes[mode], f"sensor mode {mode} isn't right!"
else:
assert value == pre_value, "sensor mode shouldn't be set!"
def test_set_readout_direction(self):
readout_directions = {"Top-to-Bottom": 1, "Bottom-to-Top": 2}
for direction in readout_directions:
self.camera.set_readout_direction(direction)
value = self.camera.camera_controller.get_property_value(
"readout_direction"
)
assert (
value == readout_directions[direction]
), f"readout direction setting isn't right for {direction}"
# def test_calculate_readout_time(self):
# pass
def test_set_exposure_time(self):
import random
modes_dict = {
"Normal": 10000,
"Light-Sheet": 20,
}
for mode in modes_dict:
self.camera.set_sensor_mode(mode)
for i in range(self.num_of_tests):
exposure_time = random.randint(1, modes_dict[mode])
self.camera.set_exposure_time(exposure_time / 1000)
value = self.camera.camera_controller.get_property_value(
"exposure_time"
)
assert self.is_in_range(
value, exposure_time / 1000, 10
), f"exposure time({exposure_time}) isn't right!"
self.camera.set_sensor_mode("Normal")
def test_set_line_interval(self):
import random
self.camera.set_sensor_mode("Light-Sheet")
for i in range(self.num_of_tests):
line_interval = random.random() / 10.0
r = self.camera.set_line_interval(line_interval)
if r is True:
value = self.camera.camera_controller.get_property_value(
"internal_line_interval"
)
assert self.is_in_range(
value, line_interval
), f"line interval {line_interval} isn't right! {value}"
self.camera.set_sensor_mode("Normal")
def test_set_binning(self):
import random
binning_dict = {
"1x1": 1,
"2x2": 2,
"4x4": 4,
# '8x8': 8,
# '16x16': 16,
# '1x2': 102,
# '2x4': 204
}
for binning_string in binning_dict:
self.camera.set_binning(binning_string)
value = self.camera.camera_controller.get_property_value("binning")
assert (
int(value) == binning_dict[binning_string]
), f"binning {binning_string} isn't right!"
for i in range(self.num_of_tests):
x = random.randint(1, 20)
y = random.randint(1, 20)
binning_string = f"{x}x{y}"
assert self.camera.set_binning(binning_string) == (
binning_string in binning_dict
)
def test_set_ROI(self):
import random
self.camera.set_binning("1x1")
width = self.camera.camera_parameters["x_pixels"]
height = self.camera.camera_parameters["x_pixels"]
w = self.camera.camera_controller.get_property_value("image_width")
h = self.camera.camera_controller.get_property_value("image_height")
assert width == w, f"maximum width should be the same {width} - {w}"
assert height == h, f"maximum height should be the same {height} -{h}"
for i in range(self.num_of_tests):
pre_x, pre_y = self.camera.x_pixels, self.camera.y_pixels
x = random.randint(1, self.camera.camera_parameters["x_pixels"])
y = random.randint(1, self.camera.camera_parameters["y_pixels"])
r = self.camera.set_ROI(y, x)
if x % 2 == 1 or y % 2 == 1:
assert r is False
assert self.camera.x_pixels == pre_x, "width shouldn't be chaged!"
assert self.camera.y_pixels == pre_y, "height shouldn't be changed!"
else:
top = (height - y) / 2
bottom = top + y - 1
if top % 2 == 1 or bottom % 2 == 0:
assert r is False
else:
assert r is True, (
f"try to set{x}x{y}, but get "
f"{self.camera.x_pixels}x{self.camera.y_pixels}"
)
assert (
self.camera.x_pixels == x
), f"trying to set {x}x{y}. width should be changed to {x}"
assert self.camera.y_pixels == y, f"height should be chagned to {y}"
self.camera.set_ROI(512, 512)
assert self.camera.x_pixels == 512
assert self.camera.y_pixels == 512
self.camera.set_ROI(
self.camera.camera_parameters["x_pixels"],
self.camera.camera_parameters["y_pixels"],
)
assert self.camera.x_pixels == self.camera.camera_parameters["x_pixels"]
assert self.camera.y_pixels == self.camera.camera_parameters["y_pixels"]
self.camera.set_ROI(
self.camera.camera_parameters["x_pixels"] + 100,
self.camera.camera_parameters["y_pixels"] + 100,
)
assert self.camera.x_pixels == self.camera.camera_parameters["x_pixels"]
assert self.camera.y_pixels == self.camera.camera_parameters["y_pixels"]
def test_acquire_image(self):
import random
import time
from navigate.model.concurrency.concurrency_tools import SharedNDArray
# set software trigger
self.camera.camera_controller.set_property_value("trigger_source", 3)
assert self.camera.is_acquiring is False
number_of_frames = 100
data_buffer = [
SharedNDArray(shape=(2048, 2048), dtype="uint16")
for i in range(number_of_frames)
]
# initialize without release/close the camera
self.camera.initialize_image_series(data_buffer, number_of_frames)
assert self.camera.is_acquiring is True
self.camera.initialize_image_series(data_buffer, number_of_frames)
assert self.camera.is_acquiring is True
exposure_time = self.camera.camera_controller.get_property_value(
"exposure_time"
)
readout_time = self.camera.camera_controller.get_property_value("readout_time")
for i in range(self.num_of_tests):
triggers = random.randint(1, 100)
for j in range(triggers):
self.camera.camera_controller.fire_software_trigger()
time.sleep(exposure_time + readout_time)
time.sleep(0.01)
frames = self.camera.get_new_frame()
assert len(frames) == triggers
self.camera.close_image_series()
assert self.camera.is_acquiring is False
for i in range(self.num_of_tests):
self.camera.initialize_image_series(data_buffer, number_of_frames)
assert self.camera.is_acquiring is True
self.camera.close_image_series()
assert self.camera.is_acquiring is False
# close a closed camera
self.camera.close_image_series()
self.camera.close_image_series()
assert self.camera.is_acquiring is False

View File

@@ -0,0 +1,34 @@
from navigate.model.devices.daq.synthetic import SyntheticDAQ
from test.model.dummy import DummyModel
import numpy as np
def test_initialize_daq():
model = DummyModel()
SyntheticDAQ(model.configuration)
def test_calculate_all_waveforms():
model = DummyModel()
daq = SyntheticDAQ(model.configuration)
microscope_state = model.configuration["experiment"]["MicroscopeState"]
microscope_name = microscope_state["microscope_name"]
exposure_times = {
k: v["camera_exposure_time"] / 1000
for k, v in microscope_state["channels"].items()
}
sweep_times = {
k: 2 * v["camera_exposure_time"] / 1000
for k, v in microscope_state["channels"].items()
}
waveform_dict = daq.calculate_all_waveforms(
microscope_name, exposure_times, sweep_times
)
for k, v in waveform_dict.items():
channel = microscope_state["channels"][k]
if not channel["is_selected"]:
continue
exposure_time = channel["camera_exposure_time"] / 1000
print(k, channel["is_selected"], np.sum(v > 0), exposure_time)
assert np.sum(v > 0) == daq.sample_rate * exposure_time

View File

@@ -0,0 +1,78 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Standard Library Imports
# Third Party Imports
import pytest
# Local Imports
@pytest.mark.hardware
def test_initialize_daq_ni():
from navigate.model.devices.daq.ni import NIDAQ
from test.model.dummy import DummyModel
model = DummyModel()
daq = NIDAQ(model.configuration)
daq.camera_trigger_task = None
@pytest.mark.hardware
def test_daq_ni_functions():
from navigate.model.devices.daq.ni import NIDAQ
from test.model.dummy import DummyModel
model = DummyModel()
daq = NIDAQ(model.configuration)
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
funcs = [
"enable_microscope",
"prepare_acquisition",
"run_acquisition",
"stop_acquisition",
]
args = [
[microscope_name],
[list(daq.waveform_dict.keys())[0]],
None,
None,
]
for f, a in zip(funcs, args):
if a is not None:
getattr(daq, f)(*a)
else:
getattr(daq, f)()

View File

@@ -0,0 +1,40 @@
def test_initialize_daq_synthetic():
from navigate.model.devices.daq.synthetic import SyntheticDAQ
from test.model.dummy import DummyModel
model = DummyModel()
_ = SyntheticDAQ(model.configuration)
def test_synthetic_daq_functions():
import random
from navigate.model.devices.daq.synthetic import SyntheticDAQ
from test.model.dummy import DummyModel
model = DummyModel()
daq = SyntheticDAQ(model.configuration)
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
funcs = [
"add_camera",
"prepare_acquisition",
"run_acquisition",
"stop_acquisition",
"wait_acquisition_done",
]
args = [
[microscope_name, model.camera[microscope_name]],
[f"channel_{random.randint(1, 5)}"],
None,
None,
None,
]
for f, a in zip(funcs, args):
if a is not None:
getattr(daq, f)(*a)
else:
getattr(daq, f)()

View File

@@ -0,0 +1,141 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Standard Library Imports
import unittest
from unittest.mock import Mock
import time
# Third Party Imports
# Local Imports
from navigate.model.devices.filter_wheel.asi import ASIFilterWheel
class TestASIFilterWheel(unittest.TestCase):
def setUp(self):
self.speed = 2
self.number_of_filter_wheels = 2
self.filter_wheel_delay = 0.5
self.microscope_name = "mock_filter_wheel"
self.mock_configuration = {
"configuration": {
"microscopes": {
"mock_filter_wheel": {
"filter_wheel": [
{
"filter_wheel_delay": self.filter_wheel_delay,
"hardware": {
"wheel_number": self.number_of_filter_wheels
},
"available_filters": {
"filter1": 0,
"filter2": 1,
"filter3": 2,
"filter4": 3,
"filter5": 4,
"filter6": 5,
},
}
]
}
}
}
}
# Mock Device Connection
self.mock_device_connection = Mock()
self.mock_device_connection.select_filter_wheel()
self.mock_device_connection.move_filter_wheel()
self.mock_device_connection.move_filter_wheel_to_home()
self.mock_device_connection.disconnect_from_serial()
self.mock_device_connection.is_open()
self.mock_device_connection.is_open.return_value = True
self.filter_wheel = ASIFilterWheel(
microscope_name=self.microscope_name,
device_connection=self.mock_device_connection,
configuration=self.mock_configuration,
device_id=0,
)
def test_init(self):
self.assertEqual(self.filter_wheel.filter_wheel, self.mock_device_connection)
self.assertEqual(
self.filter_wheel.filter_wheel_number, self.number_of_filter_wheels
)
self.assertEqual(
self.filter_wheel.wait_until_done_delay, self.filter_wheel_delay
)
self.assertEqual(self.filter_wheel.filter_wheel_position, 0)
def test_init_sends_filter_wheels_to_zeroth_position(self):
self.mock_device_connection.select_filter_wheel.assert_called()
self.assertEqual(self.filter_wheel.wheel_position, 0)
def test_filter_change_delay(self):
# Current position
self.filter_wheel.filter_wheel_position = 0
# Position to move to
filter_to_move_to = "filter4"
self.filter_wheel.filter_change_delay(filter_to_move_to)
self.assertEqual(self.filter_wheel.wait_until_done_delay, (3 * 0.04))
def test_set_filter_does_not_exist(self):
self.mock_device_connection.reset_mock()
with self.assertRaises(ValueError):
self.filter_wheel.set_filter("magic")
def test_set_filter_without_waiting(self):
self.mock_device_connection.reset_mock()
delta = 4
self.filter_wheel.set_filter(
list(self.filter_wheel.filter_dictionary.keys())[0]
)
start_time = time.time()
self.filter_wheel.set_filter(
list(self.filter_wheel.filter_dictionary.keys())[delta],
wait_until_done=False,
)
actual_duration = time.time() - start_time
if_wait_duration = (delta - 1) * 0.04
self.assertGreater(if_wait_duration, actual_duration)
def test_close(self):
self.mock_device_connection.reset_mock()
self.filter_wheel.close()
self.filter_wheel.filter_wheel.move_filter_wheel_to_home.assert_called()
self.filter_wheel.filter_wheel.is_open.assert_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,28 @@
from navigate.model.devices.filter_wheel.synthetic import SyntheticFilterWheel
from test.model.dummy import DummyModel
def test_filter_wheel_base_functions():
model = DummyModel()
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
fw = SyntheticFilterWheel(
microscope_name=microscope_name,
device_connection=None,
configuration=model.configuration,
device_id=0,
)
filter_dict = model.configuration["configuration"]["microscopes"][microscope_name][
"filter_wheel"
][0]["available_filters"]
assert fw.check_if_filter_in_filter_dictionary(list(filter_dict.keys())[0])
try:
fw.check_if_filter_in_filter_dictionary("not a filter")
except ValueError:
assert True
return
assert False

View File

@@ -0,0 +1,55 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only
# (subject to the limitations in the disclaimer below)
# provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
def test_synthetic_filter_wheel_functions():
from navigate.model.devices.filter_wheel.synthetic import (
SyntheticFilterWheel,
)
from test.model.dummy import DummyModel
model = DummyModel()
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
fw = SyntheticFilterWheel(microscope_name, None, model.configuration, 0)
funcs = ["set_filter", "close"]
args = [["channel_dummy"], None]
for f, a in zip(funcs, args):
if a is not None:
getattr(fw, f)(*a)
else:
getattr(fw, f)()

View File

@@ -0,0 +1,189 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Standard Library Imports
import unittest
from unittest.mock import Mock
import time
# Third Party Imports
# Local Imports
from navigate.model.devices.filter_wheel.sutter import SutterFilterWheel
class TestSutterFilterWheel(unittest.TestCase):
def setUp(self):
self.mock_device_connection = Mock()
self.mock_device_connection.read.return_value = b"00"
self.mock_device_connection.inWaiting.return_value = 2
self.mock_device_connection.write.return_value = None
self.mock_device_connection.set_filter()
self.mock_device_connection.close()
self.speed = 2
self.number_of_filter_wheels = 2
self.microscope_name = "mock_filter_wheel"
self.mock_configuration = {
"configuration": {
"microscopes": {
"mock_filter_wheel": {
"filter_wheel": [
{
"hardware": {
"wheel_number": self.number_of_filter_wheels
},
"available_filters": {
"filter1": 0,
"filter2": 1,
"filter3": 2,
"filter4": 3,
"filter5": 4,
"filter6": 5,
},
}
]
}
}
}
}
self.filter_wheel = SutterFilterWheel(
microscope_name=self.microscope_name,
device_connection=self.mock_device_connection,
configuration=self.mock_configuration,
device_id=0,
)
def test_init(self):
self.assertEqual(self.filter_wheel.serial, self.mock_device_connection)
self.assertEqual(
self.filter_wheel.filter_wheel_number, self.number_of_filter_wheels
)
self.assertEqual(self.filter_wheel.wait_until_done, True)
self.assertEqual(self.filter_wheel.read_on_init, True)
self.assertEqual(self.filter_wheel.speed, self.speed)
def test_init_sends_filter_wheels_to_zeroth_position(self):
self.mock_device_connection.write.assert_called()
self.mock_device_connection.set_filter.assert_called()
self.assertEqual(self.filter_wheel.wheel_position, 0)
def test_filter_wheel_delay(self):
for delta in range(6):
self.filter_wheel.set_filter(
list(self.filter_wheel.filter_dictionary.keys())[0]
)
self.filter_wheel.set_filter(
list(self.filter_wheel.filter_dictionary.keys())[delta]
)
self.assertEqual(
self.filter_wheel.wait_until_done_delay,
self.filter_wheel.delay_matrix[self.speed, delta],
)
def test_set_filter_does_not_exist(self):
self.mock_device_connection.reset_mock()
with self.assertRaises(ValueError):
self.filter_wheel.set_filter("magic")
def test_set_filter_init_not_finished(self):
self.mock_device_connection.reset_mock()
self.filter_wheel.init_finished = False
self.filter_wheel.set_filter(
list(self.filter_wheel.filter_dictionary.keys())[2]
)
self.mock_device_connection.read.assert_called()
self.filter_wheel.init_finished = True
def test_set_filter_init_finished(self):
for wait_flag, read_num in [(True, 2), (False, 1)]:
self.mock_device_connection.reset_mock()
self.filter_wheel.init_finished = True
read_count = 0
for i in range(6):
self.filter_wheel.set_filter(
list(self.filter_wheel.filter_dictionary.keys())[i],
wait_until_done=wait_flag,
)
self.mock_device_connection.write.assert_called()
self.mock_device_connection.read.assert_called()
read_count += read_num
assert self.mock_device_connection.read.call_count == read_count
def test_set_filter_without_waiting(self):
self.mock_device_connection.reset_mock()
delta = 4
self.filter_wheel.set_filter(
list(self.filter_wheel.filter_dictionary.keys())[0]
)
start_time = time.time()
self.filter_wheel.set_filter(
list(self.filter_wheel.filter_dictionary.keys())[delta],
wait_until_done=False,
)
actual_duration = time.time() - start_time
if_wait_duration = self.filter_wheel.delay_matrix[self.speed, delta]
self.assertGreater(if_wait_duration, actual_duration)
def test_read_wrong_number_bytes_returned(self):
self.mock_device_connection.reset_mock()
# fewer response bytes than expected
with self.assertRaises(UserWarning):
# in_waiting() returns an integer.
self.mock_device_connection.inWaiting.return_value = 1
self.filter_wheel.read(num_bytes=10)
# more response bytes than expected
self.mock_device_connection.inWaiting.return_value = 12
self.filter_wheel.read(num_bytes=10)
def test_read_correct_number_bytes_returned(self):
# Mocked device connection expected to return 2 bytes
self.mock_device_connection.reset_mock()
number_bytes = 2
self.mock_device_connection.reset_mock()
self.mock_device_connection.inWaiting.return_value = number_bytes
returned_bytes = self.filter_wheel.read(num_bytes=number_bytes)
self.assertEqual(len(returned_bytes), number_bytes)
def test_close(self):
self.mock_device_connection.reset_mock()
self.filter_wheel.close()
self.mock_device_connection.close.assert_called()
def test_exit(self):
self.mock_device_connection.reset_mock()
del self.filter_wheel
self.mock_device_connection.close.assert_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,145 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import unittest
from unittest.mock import MagicMock
from navigate.model.devices.galvo.synthetic import SyntheticGalvo
from navigate.config import (
load_configs,
get_configuration_paths,
verify_configuration,
verify_waveform_constants,
)
from multiprocessing import Manager
import numpy as np
class TestGalvoBase(unittest.TestCase):
def setUp(self) -> None:
"""Set up the configuration, experiment, etc."""
self.manager = Manager()
self.parent_dict = {}
(
configuration_path,
experiment_path,
waveform_constants_path,
rest_api_path,
waveform_templates_path,
gui_configuration_path,
multi_positions_path,
) = get_configuration_paths()
self.configuration = load_configs(
self.manager,
configuration=configuration_path,
experiment=experiment_path,
waveform_constants=waveform_constants_path,
rest_api_config=rest_api_path,
waveform_templates=waveform_templates_path,
gui_configuration_path=gui_configuration_path,
)
verify_configuration(self.manager, self.configuration)
verify_waveform_constants(self.manager, self.configuration)
self.microscope_name = "Mesoscale"
self.device_connection = MagicMock()
galvo_id = 0
self.galvo = SyntheticGalvo(
microscope_name=self.microscope_name,
device_connection=self.device_connection,
configuration=self.configuration,
device_id=galvo_id,
)
self.exposure_times = {"channel_1": 0.11, "channel_2": 0.2, "channel_3": 0.3}
self.sweep_times = {"channel_1": 0.115, "channel_2": 0.2, "channel_3": 0.3}
def tearDown(self):
"""Tear down the multiprocessing manager."""
self.manager.shutdown()
def test_galvo_base_initialization(self):
# Parent Class Super Init
assert self.galvo.microscope_name == "Mesoscale"
assert self.galvo.galvo_name == "Galvo 0"
assert self.galvo.sample_rate == 100000
assert (
self.galvo.camera_delay
== self.configuration["configuration"]["microscopes"][self.microscope_name][
"camera"
]["delay"]
/ 1000
)
assert self.galvo.galvo_max_voltage == 5
assert self.galvo.galvo_min_voltage == -5
assert self.galvo.galvo_waveform == "sawtooth" or "sine"
assert self.galvo.waveform_dict == {}
def test_adjust_with_valid_input(self):
# Test the method with valid input data
for waveform in ["sawtooth", "sine"]:
self.galvo.galvo_waveform = waveform
result = self.galvo.adjust(self.exposure_times, self.sweep_times)
# Assert that the result is a dictionary
self.assertIsInstance(result, dict)
# Assert that the keys in the result dictionary are the same as in the input
# dictionaries
self.assertSetEqual(set(result.keys()), set(self.exposure_times.keys()))
# Assert that the values in the result dictionary are not None
for value in result.values():
self.assertIsNotNone(value)
def test_adjust_with_invalid_input(self):
# Test the method with invalid input data
invalid_exposure_times = {"channel_1": 0.1} # Missing channel 2 and 3 keys
invalid_sweep_times = {"channel_1": 0.1} # Missing channel 2 and 3 keys
# Test if the method raises an exception or returns None with invalid input
with self.assertRaises(KeyError):
_ = self.galvo.adjust(invalid_exposure_times, invalid_sweep_times)
def test_with_improper_waveform(self):
self.galvo.galvo_waveform = "banana"
result = self.galvo.adjust(self.exposure_times, self.sweep_times)
assert result == self.galvo.waveform_dict
def test_waveform_clipping(self):
self.galvo.galvo_waveform = "sawtooth"
result = self.galvo.adjust(self.exposure_times, self.sweep_times)
for channel in "channel_1", "channel_2", "channel_3":
assert np.all(result[channel] <= self.galvo.galvo_max_voltage)
assert np.all(result[channel] >= self.galvo.galvo_min_voltage)

View File

@@ -0,0 +1,133 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import unittest
from unittest.mock import MagicMock
from navigate.model.devices.galvo.ni import NIGalvo
from navigate.config import (
load_configs,
get_configuration_paths,
verify_configuration,
verify_waveform_constants,
)
from multiprocessing import Manager
class TestNIGalvo(unittest.TestCase):
"""Unit tests for the Galvo NI Device."""
def setUp(self) -> None:
"""Set up the configuration, experiment, etc."""
self.manager = Manager()
self.parent_dict = {}
(
configuration_path,
experiment_path,
waveform_constants_path,
rest_api_path,
waveform_templates_path,
gui_configuration_path,
multi_positions_path,
) = get_configuration_paths()
self.configuration = load_configs(
self.manager,
configuration=configuration_path,
experiment=experiment_path,
waveform_constants=waveform_constants_path,
rest_api_config=rest_api_path,
waveform_templates=waveform_templates_path,
gui_configuration_path=gui_configuration_path,
)
verify_configuration(self.manager, self.configuration)
verify_waveform_constants(self.manager, self.configuration)
self.microscope_name = "Mesoscale"
self.device_connection = MagicMock()
galvo_id = 0
self.galvo = NIGalvo(
microscope_name=self.microscope_name,
device_connection=self.device_connection,
configuration=self.configuration,
device_id=galvo_id,
)
def tearDown(self):
"""Tear down the multiprocessing manager."""
self.manager.shutdown()
def test_galvo_ni_initialization(self):
# Parent Class Super Init
assert self.galvo.microscope_name == "Mesoscale"
assert self.galvo.galvo_name == "Galvo 0"
assert self.galvo.sample_rate == 100000
assert (
self.galvo.camera_delay
== self.configuration["configuration"]["microscopes"][self.microscope_name][
"camera"
]["delay"]
/ 1000
)
assert self.galvo.galvo_max_voltage == 5
assert self.galvo.galvo_min_voltage == -5
assert self.galvo.galvo_waveform == "sawtooth" or "sine"
assert self.galvo.waveform_dict == {}
# NIGalvo Init
assert self.galvo.trigger_source == "/PXI6259/PFI0"
assert hasattr(self.galvo, "daq")
def test_adjust(self):
sweep_times = {"channel_1": 0.3, "channel_2": 0.4, "channel_3": 0.5}
exposure_times = {"channel_1": 0.25, "channel_2": 0.35, "channel_3": 0.45}
waveforms = self.galvo.adjust(
exposure_times=exposure_times, sweep_times=sweep_times
)
assert type(waveforms) == dict
self.device_connection.assert_not_called()
for channel_key, channel_setting in self.configuration["experiment"][
"MicroscopeState"
]["channels"].items():
if channel_setting["is_selected"]:
assert channel_key in waveforms.keys()
self.device_connection.analog_outputs.__setitem__.assert_called_with(
self.galvo.device_config["hardware"]["channel"],
{
"trigger_source": self.galvo.trigger_source,
"waveform": waveforms,
},
)

View File

@@ -0,0 +1,86 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import unittest
from unittest.mock import MagicMock
from navigate.model.devices.galvo.synthetic import SyntheticGalvo
from navigate.config import (
load_configs,
get_configuration_paths,
verify_configuration,
verify_waveform_constants,
)
from multiprocessing import Manager
class TestGalvoSynthetic(unittest.TestCase):
def setUp(self) -> None:
self.manager = Manager()
self.parent_dict = {}
(
configuration_path,
experiment_path,
waveform_constants_path,
rest_api_path,
waveform_templates_path,
gui_configuration_path,
multi_positions_path,
) = get_configuration_paths()
self.configuration = load_configs(
self.manager,
configuration=configuration_path,
experiment=experiment_path,
waveform_constants=waveform_constants_path,
rest_api_config=rest_api_path,
waveform_templates=waveform_templates_path,
gui_configuration=gui_configuration_path,
)
verify_configuration(self.manager, self.configuration)
verify_waveform_constants(self.manager, self.configuration)
self.microscope_name = "Mesoscale"
self.device_connection = MagicMock()
galvo_id = 0
self.galvo = SyntheticGalvo(
microscope_name=self.microscope_name,
device_connection=self.device_connection,
configuration=self.configuration,
device_id=galvo_id,
)
def tearDown(self) -> None:
self.manager.shutdown()
def test_dunder_del(self):
"""Test the __del__ method"""
self.galvo.__del__()

View File

@@ -0,0 +1,21 @@
from navigate.model.devices.laser.synthetic import SyntheticLaser
from test.model.dummy import DummyModel
import random
def test_laser_base_functions():
model = DummyModel()
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
laser = SyntheticLaser(microscope_name, None, model.configuration, 0)
funcs = ["set_power", "turn_on", "turn_off", "close"]
args = [[random.random()], None, None, None]
for f, a in zip(funcs, args):
if a is not None:
getattr(laser, f)(*a)
else:
getattr(laser, f)()

View File

@@ -0,0 +1,97 @@
from multiprocessing import Manager
import random
import unittest
from unittest.mock import patch
from navigate.config import load_configs, get_configuration_paths
from navigate.model.devices.laser.ni import NILaser
class TestLaserNI(unittest.TestCase):
"""Unit tests for the Laser NI Device."""
def setUp(self) -> None:
"""Set up the configuration, experiment, etc."""
self.manager = Manager()
self.parent_dict = {}
(
configuration_path,
experiment_path,
waveform_constants_path,
rest_api_path,
waveform_templates_path,
gui_configuration_path,
multi_positions_path,
) = get_configuration_paths()
self.configuration = load_configs(
self.manager,
configuration=configuration_path,
experiment=experiment_path,
waveform_constants=waveform_constants_path,
rest_api_config=rest_api_path,
waveform_templates=waveform_templates_path,
gui_configuration=gui_configuration_path,
)
self.microscope_name = self.configuration["configuration"][
"microscopes"
].keys()[0]
self.device_connection = None
laser_id = 0
with patch("nidaqmx.Task") as self.mock_task:
# self.mock_task_instance = MagicMock()
# self.mock_task.return_value = self.mock_task_instance
self.laser = NILaser(
microscope_name=self.microscope_name,
device_connection=self.device_connection,
configuration=self.configuration,
device_id=laser_id,
)
def tearDown(self):
"""Tear down the multiprocessing manager."""
self.manager.shutdown()
def test_set_power(self):
self.current_intensity = random.randint(1, 100)
scaled_intensity = (int(self.current_intensity) / 100) * self.laser.laser_max_ao
self.laser.set_power(self.current_intensity)
self.laser.laser_ao_task.write.assert_called_once_with(
scaled_intensity, auto_start=True
)
assert self.laser._current_intensity == self.current_intensity
def test_turn_on(self):
self.laser.digital_port_type = "digital"
self.laser.turn_on()
self.laser.laser_do_task.write.assert_called_with(True, auto_start=True)
self.laser.digital_port_type = "analog"
self.laser.turn_on()
self.laser.laser_do_task.write.assert_called_with(
self.laser.laser_max_do, auto_start=True
)
def test_turn_off(self):
self.current_intensity = random.randint(1, 100)
self.laser._current_intensity = self.current_intensity
self.laser.digital_port_type = "digital"
self.laser.turn_off()
self.laser.laser_do_task.write.assert_called_with(False, auto_start=True)
assert self.laser._current_intensity == self.current_intensity
self.laser.digital_port_type = "analog"
self.laser.turn_off()
self.laser.laser_do_task.write.assert_called_with(
self.laser.laser_min_do, auto_start=True
)
assert self.laser._current_intensity == self.current_intensity

View File

@@ -0,0 +1,321 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Standard Library Imports
import pytest
from unittest.mock import patch
# Third Party Imports
# Local Imports
from navigate.model.devices.pump.tecan import XCaliburPump
from navigate.model.utils.exceptions import UserVisibleException
class FakeSerial:
def __init__(self, port, baudrate, timeout):
self.commands = [] # Record of all sent commands (as bytes).
self.is_open = True # Pretend the serial port is open.
self.last_command = None # Stores the last command sent (as string, no \r).
self.command_responses = (
{}
) # Maps command strings (e.g., "S5") to fake byte responses.
self.port = port
self.baudrate = baudrate
self.timeout = timeout
def open(self):
self.is_open = True
def close(self):
self.is_open = False
def write(self, data: bytes):
"""
Simulate sending a command to the pump.
- Updates last_command with the stripped string version (used for read lookup).
- Appends the raw byte-formatted command to the commands list to keep track of which order the commands are sent.
"""
self.last_command = data.decode("ascii").strip()
self.commands.append(data)
def read(self, n: int) -> bytes:
"""
Simulate receiving a response from the pump.
If a response has been predefined for the last command (e.g., to simulate an error or custom reply),
that specific response is returned.
Otherwise, a default success response (b"/00") is returned to simulate normal operation.
"""
if self.last_command in self.command_responses:
return self.command_responses[self.last_command]
return b"/00" # If no command has been sent yet, return the "success" response as fallback.
@pytest.fixture
def fake_pump():
"""
Fixture that returns an XCaliburPump with a mocked serial connection.
"""
# Pick some speeds within the known bounds 0-40.
min_speed_code = 2
max_speed_code = 19
port = "FAKE"
baudrate = 9600
timeout = 0.5
fake_serial = FakeSerial(port=port, baudrate=baudrate, timeout=timeout)
config = {
"min_speed_code": min_speed_code,
"max_speed_code": max_speed_code,
"fine_positioning": False,
}
pump = XCaliburPump(
microscope_name="TestPump",
device_connection=fake_serial,
configuration=config,
)
return pump
def test_set_speed_command_rejected(fake_pump):
"""
Simulate a firmware-level rejection of a valid speed code.
This test configures the FakeSerial to return error code '/03' (Invalid Operand)
in response to a speed code that is within the allowed local range. This models a case
where the driver sends a syntactically valid command (e.g., 'S4'), but the pump
firmware rejects the operand value due to internal state or configuration.
The test verifies that the driver:
- Sends the command correctly.
- Parses the response.
- Raises a RuntimeError with an appropriate error message.
"""
valid_speed = fake_pump.max_speed_code - 1 # Within bounds.
fake_pump.serial.command_responses["S" + str(valid_speed)] = (
b"/03" # Simulate command-response.
)
# Make sure the pre-defined response raises the correct error.
with pytest.raises(
UserVisibleException,
match="Pump error /3: Invalid operand - bad parameter value",
):
fake_pump.set_speed(valid_speed)
@patch("navigate.model.devices.pump.tecan.Serial")
def test_connect_and_initialize_success(
mock_serial_class,
): # Argument passed automatically from patch (mocked version of Serial).
"""
Simulate a successful connection using FakeSerial via patching.
"""
# Create a custom FakeSerial instance to return instead of MagicMock.
fake_serial = FakeSerial(port="FAKE", baudrate=9600, timeout=0.5)
fake_serial.command_responses["ZR"] = b"/00" # Simulate valid response.
# Tell the mock object what to return instead of Serial.
mock_serial_class.return_value = fake_serial
# Simulate the connect call that is done when all device connections are set up.
# Will be the same as fake_serial if successful.
serial_connection = XCaliburPump.connect(port="FAKE", baudrate=9600, timeout=0.5)
mock_serial_class.assert_called_once_with(port="FAKE", baudrate=9600, timeout=0.5)
# Create the pump and call connect - now it will receive the FakeSerial.
pump = XCaliburPump(
microscope_name="TestPump",
device_connection=serial_connection,
configuration={},
)
pump.initialize_pump()
# Assertions
assert pump.serial == fake_serial
assert fake_serial.commands[-1] == b"ZR\r"
assert fake_serial.is_open
@patch("serial.Serial")
def test_initialization_error(
mock_serial_class,
): # Argument passed automatically from patch (mocked version of Serial).
"""
Simulate a pump that fails to initialize (command 'ZR', response '/01').
Verifies that:
- The 'ZR' command is sent.
- The driver raises RuntimeError when pump reports an init failure.
"""
# Create a custom FakeSerial instance to return instead of MagicMock.
fake_serial = FakeSerial(port="FAKE", baudrate=9600, timeout=0.5)
fake_serial.command_responses["ZR"] = b"/01" # Simulate "fail" response.
# Make sure Serial() returns this custom fake.
mock_serial_class.return_value = fake_serial
# Create the pump.
pump = XCaliburPump(
microscope_name="TestPump",
device_connection=fake_serial,
configuration={},
)
# Expect a RuntimeError due to /01 response.
with pytest.raises(
UserVisibleException, match="Pump error /1: Initialization error"
):
pump.initialize_pump()
# Check that the correct command was sent.
assert pump.serial.commands[-1] == b"ZR\r"
# NOTE: We do not wrap or handle exceptions in XCaliburPump.connect().
# Errors like Serial(port=...) failures are allowed to propagate.
# Therefore, no test is needed for connect() error handling.
def test_send_command_raises_if_serial_is_none():
"""
Verifies that send_command() raises if self.serial is None.
"""
fake_serial = FakeSerial(port="FAKE", baudrate=9600, timeout=0.5)
pump = XCaliburPump(
microscope_name="TestPump",
device_connection=fake_serial,
configuration={},
)
pump.serial = None # Simulate uninitialized or failed connection
with pytest.raises(UserVisibleException, match="Serial object is None"):
pump.send_command("ZR")
def test_move_absolute_success_standard_and_fine_modes(fake_pump):
"""
Test that move_absolute() sends the correct command and succeeds in both
standard and fine positioning modes, assuming valid position input.
Verifies that:
- The correct 'A{pos}' command is sent.
- The pump responds with success.
- No exception is raised.
"""
# --- Standard mode ---
fake_pump.fine_positioning = False
position_std = 3000 # Max allowed position in standard (non-fine) mode.
# Predefine the pump's response to this specific absolute move command.
fake_pump.serial.command_responses[f"A{position_std}"] = b"/00"
# Send the move_absolute command (which internally sends 'A{position}' + parses response).
fake_pump.move_absolute(position_std)
# Verify that the correct byte-encoded command was sent to the serial interface.
assert fake_pump.serial.commands[-1] == f"A{position_std}\r".encode()
# --- Fine positioning mode ---
fake_pump.fine_positioning = True
position_fine = 24000 # Max allowed position in fine mode.
fake_pump.serial.command_responses[f"A{position_fine}"] = b"/00"
fake_pump.move_absolute(position_fine)
assert fake_pump.serial.commands[-1] == f"A{position_fine}\r".encode()
def test_move_absolute_out_of_bounds_raises(fake_pump):
"""
Verify that move_absolute() raises UserVisibleException when given a position
outside the valid range for the current positioning mode.
"""
# Standard mode: max is 3000.
fake_pump.fine_positioning = False
with pytest.raises(UserVisibleException, match="out of bounds"):
fake_pump.move_absolute(3000 + 1)
# Fine mode: max is 24000.
fake_pump.fine_positioning = True
with pytest.raises(UserVisibleException, match="out of bounds"):
fake_pump.move_absolute(24000 + 1)
def test_set_fine_positioning_mode_toggle(fake_pump):
"""
Verify that set_fine_positioning_mode() sends the correct 'N' and 'R' commands,
handles responses properly, and updates the fine_positioning attribute.
"""
# Mock responses for enabling fine positioning.
# "N1" loads the fine mode into the buffer; "R" applies the change.
# Both return "/00" to simulate success.
fake_pump.serial.command_responses["N1"] = b"/00"
fake_pump.serial.command_responses["R"] = b"/00"
# Enable fine positioning mode.
fake_pump.set_fine_positioning_mode(True)
# Check that the internal state was updated.
assert fake_pump.fine_positioning is True
# Confirm that the correct commands were sent in the correct order
# inside set_fine_positioning_mode().
assert fake_pump.serial.commands[-2] == b"N1\r"
assert fake_pump.serial.commands[-1] == b"R\r"
# Now test disabling fine positioning mode.
# "N0" loads standard mode; "R" applies it. Again, simulate success.
fake_pump.serial.command_responses["N0"] = b"/00"
fake_pump.serial.command_responses["R"] = b"/00"
fake_pump.set_fine_positioning_mode(False)
assert fake_pump.fine_positioning is False
assert fake_pump.serial.commands[-2] == b"N0\r"
assert fake_pump.serial.commands[-1] == b"R\r"
# TODO: Once pump is integrated into Model/Controller, test that
# UserVisibleException raised by pump results in a warning event.

View File

@@ -0,0 +1,106 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Standard Library Imports
# Third Party Imports
import pytest
import numpy as np
# Local Imports
from navigate.model.devices.remote_focus.synthetic import SyntheticRemoteFocus
from test.model.dummy import DummyModel
def test_remote_focus_base_init():
model = DummyModel()
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
SyntheticRemoteFocus(microscope_name, None, model.configuration)
@pytest.mark.parametrize("smoothing", [0] + list(np.random.rand(5) * 100))
def test_remote_focus_base_adjust(smoothing):
from test.model.dummy import DummyModel
model = DummyModel()
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
microscope_state = model.configuration["experiment"]["MicroscopeState"]
waveform_constants = model.configuration["waveform_constants"]
imaging_mode = microscope_state["microscope_name"]
zoom = microscope_state["zoom"]
for channel_key in microscope_state["channels"].keys():
# channel includes 'is_selected', 'laser', 'filter', 'camera_exposure'...
channel = microscope_state["channels"][channel_key]
# Only proceed if it is enabled in the GUI
if channel["is_selected"] is True:
laser = channel["laser"]
waveform_constants["remote_focus_constants"][imaging_mode][zoom][laser][
"percent_smoothing"
] = smoothing
channel["camera_exposure_time"] = np.random.rand() * 150 + 50
rf = SyntheticRemoteFocus(microscope_name, None, model.configuration)
# exposure_times = {
# k: v["camera_exposure_time"] / 1000
# for k, v in microscope_state["channels"].items()
# }
# sweep_times = {
# k: 2 * v["camera_exposure_time"] / 1000
# for k, v in microscope_state["channels"].items()
# }
(
exposure_times,
sweep_times,
) = model.active_microscope.calculate_exposure_sweep_times()
waveform_dict = rf.adjust(exposure_times, sweep_times)
for k, v in waveform_dict.items():
try:
channel = microscope_state["channels"][k]
if not channel["is_selected"]:
continue
assert np.all(v <= rf.remote_focus_max_voltage)
assert np.all(v >= rf.remote_focus_min_voltage)
assert len(v) == int(sweep_times[k] * rf.sample_rate)
except KeyError:
# The channel doesn't exist. Points to an issue in how waveform dict
# is created.
continue

View File

@@ -0,0 +1,29 @@
import pytest
@pytest.mark.hardware
def test_remote_focus_ni_functions():
from navigate.model.devices.daq.ni import NIDAQ
from navigate.model.devices.remote_focus.ni import NIRemoteFocus
from test.model.dummy import DummyModel
model = DummyModel()
daq = NIDAQ(model.configuration)
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
rf = NIRemoteFocus(microscope_name, daq, model.configuration)
funcs = ["adjust"]
args = [
[
{"channel_1": 0.2, "channel_2": 0.1, "channel_3": 0.15},
{"channel_1": 0.3, "channel_2": 0.2, "channel_3": 0.25},
]
]
for f, a in zip(funcs, args):
if a is not None:
getattr(rf, f)(*a)
else:
getattr(rf, f)()

View File

@@ -0,0 +1,20 @@
def test_remote_focus_synthetic_functions():
from navigate.model.devices.remote_focus.synthetic import (
SyntheticRemoteFocus,
)
from test.model.dummy import DummyModel
model = DummyModel()
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
rf = SyntheticRemoteFocus(microscope_name, None, model.configuration)
funcs = ["move"]
args = [[0.1, None]]
for f, a in zip(funcs, args):
if a is not None:
getattr(rf, f)(*a)
else:
getattr(rf, f)()

View File

@@ -0,0 +1,59 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
import unittest
from navigate.model.devices.shutter.synthetic import SyntheticShutter
from test.model.dummy import DummyModel
class TestLaserBase(unittest.TestCase):
"""Unit Test for ShutterBase Class"""
dummy_model = DummyModel()
microscope_name = "Mesoscale"
def test_shutter_base_attributes(self):
shutter = SyntheticShutter(
self.microscope_name, None, self.dummy_model.configuration
)
# Methods
assert hasattr(shutter, "open_shutter") and callable(
getattr(shutter, "open_shutter")
)
assert hasattr(shutter, "close_shutter") and callable(
getattr(shutter, "close_shutter")
)
assert hasattr(shutter, "state")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,56 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import unittest
# Third Party Imports
# Local Imports
from navigate.model.devices.shutter.ni import NIShutter
class TestNIShutter(unittest.TestCase):
"""Unit Test for NIShutter Class"""
def test_shutter_ttl_attributes(self):
assert hasattr(NIShutter, "open_shutter") and callable(
getattr(NIShutter, "open_shutter")
)
assert hasattr(NIShutter, "close_shutter") and callable(
getattr(NIShutter, "close_shutter")
)
assert hasattr(NIShutter, "state")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,73 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import unittest
# Third Party Imports
# Local Imports
from navigate.model.devices.shutter.synthetic import SyntheticShutter
from test.model.dummy import DummyModel
class TestSyntheticShutter(unittest.TestCase):
"""Unit Test for SyntheticShutter Class"""
dummy_model = DummyModel()
microscope_name = "Mesoscale"
def test_synthetic_shutter_attributes(self):
shutter = SyntheticShutter(
self.microscope_name, None, self.dummy_model.configuration
)
# Attributes
# assert hasattr(shutter, 'configuration')
# assert hasattr(shutter, 'experiment')
# assert hasattr(shutter, 'shutter_right')
# assert hasattr(shutter, 'shutter_right_state')
# assert hasattr(shutter, 'shutter_left')
# assert hasattr(shutter, 'shutter_left_state')
# Methods
assert hasattr(shutter, "open_shutter") and callable(
getattr(shutter, "open_shutter")
)
assert hasattr(shutter, "close_shutter") and callable(
getattr(shutter, "close_shutter")
)
assert hasattr(shutter, "state")
if __name__ == "__main__":
unittest.main()

View File

View File

@@ -0,0 +1,160 @@
"""Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#"""
# Standard Library Imports
import random
# Third Party Imports
import pytest
@pytest.fixture(scope="module")
def stage_configuration():
return {
"stage": {
"hardware": {
"name": "stage",
"type": "",
"port": "COM10",
"baudrate": 115200,
"serial_number": 123456,
"axes": ["x", "y", "z", "f", "theta"],
},
"x_max": 100,
"x_min": -10,
"y_max": 200,
"y_min": -20,
"z_max": 300,
"z_min": -30,
"f_max": 400,
"f_min": -40,
"theta_max": 360,
"theta_min": 0,
}
}
@pytest.fixture
def random_single_axis_test(stage_configuration):
pos_sequence = []
for _ in range(10):
axis = random.choice(["x", "y", "z", "theta", "f"])
# random valid pos
axis_min = stage_configuration["stage"][f"{axis}_min"]
axis_max = stage_configuration["stage"][f"{axis}_max"]
pos = random.randrange(axis_min, axis_max)
pos_sequence.append((axis, pos))
for _ in range(10):
# valid and non-valid pos
axis = random.choice(["x", "y", "z", "theta", "f"])
pos = random.randrange(-100, 500)
pos_sequence.append((axis, pos))
def _verify_move_axis_absolute(stage):
axes_mapping = stage.axes_mapping
stage_pos = stage.report_position()
for axis, pos in pos_sequence:
stage.move_axis_absolute(axis, pos, True)
temp_pos = stage.report_position()
axis_min = stage_configuration["stage"][f"{axis}_min"]
axis_max = stage_configuration["stage"][f"{axis}_max"]
if axis in axes_mapping:
if not stage.stage_limits or (pos >= axis_min and pos <= axis_max):
stage_pos[f"{axis}_pos"] = pos
assert stage_pos == temp_pos
return _verify_move_axis_absolute
@pytest.fixture
def random_multiple_axes_test(stage_configuration):
pos_sequence = []
axes = ["x", "y", "z", "f", "theta"]
for _ in range(20):
pos = {}
for axis in axes:
pos[axis] = random.randrange(-100, 500)
pos_sequence.append(pos)
def _verify_move_absolute(stage):
axes_mapping = stage.axes_mapping
# move one axis inside supported axes
stage_pos = stage.report_position()
for pos_dict in pos_sequence:
axis = random.choice(list(axes_mapping.keys()))
pos = pos_dict[axis]
axis_min = stage_configuration["stage"][f"{axis}_min"]
axis_max = stage_configuration["stage"][f"{axis}_max"]
move_dict = {f"{axis}_abs": pos}
stage.move_absolute(move_dict)
temp_pos = stage.report_position()
if not stage.stage_limits or (pos >= axis_min and pos <= axis_max):
stage_pos[f"{axis}_pos"] = pos
assert stage_pos == temp_pos
# move all axes inside supported axes
stage_pos = stage.report_position()
for pos_dict in pos_sequence:
move_dict = {}
for axis in axes_mapping.keys():
move_dict[f"{axis}_abs"] = pos_dict[axis]
stage.move_absolute(move_dict)
temp_pos = stage.report_position()
for axis in axes_mapping:
pos = pos_dict[axis]
axis_min = stage_configuration["stage"][f"{axis}_min"]
axis_max = stage_configuration["stage"][f"{axis}_max"]
if not stage.stage_limits or (pos >= axis_min and pos <= axis_max):
stage_pos[f"{axis}_pos"] = pos
assert stage_pos == temp_pos
# move all axes (including supported axes and non-supported axes)
stage_pos = stage.report_position()
for pos_dict in pos_sequence:
move_dict = dict(
map(lambda axis: (f"{axis}_abs", pos_dict[axis]), pos_dict)
)
stage.move_absolute(move_dict)
temp_pos = stage.report_position()
for axis in axes_mapping:
pos = pos_dict[axis]
axis_min = stage_configuration["stage"][f"{axis}_min"]
axis_max = stage_configuration["stage"][f"{axis}_max"]
if not stage.stage_limits or (pos >= axis_min and pos <= axis_max):
stage_pos[f"{axis}_pos"] = pos
assert stage_pos == temp_pos
return _verify_move_absolute

View File

@@ -0,0 +1,337 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import pytest
import random
# Third Party Imports
# Local Imports
from navigate.model.devices.stage.asi import ASIStage
from navigate.model.devices.APIs.asi.asi_tiger_controller import TigerController
class MockASIStage:
def __init__(self, ignore_obj):
self.axes = ["X", "Y", "Z", "M", "N"]
self.is_open = False
self.input_buffer = []
self.output_buffer = []
self.ignore_obj = ignore_obj
for axis in self.axes:
setattr(self, f"{axis}_abs", 0)
def open(self):
self.is_open = True
def reset_input_buffer(self):
self.input_buffer = []
def reset_output_buffer(self):
self.output_buffer = []
def write(self, command):
command = command.decode(encoding="ascii")[:-1]
temps = command.split()
command = temps[0]
if command == "WHERE":
axes = temps[1:]
pos = [":A"]
for axis in self.axes:
if axis not in axes:
continue
pos.append(str(getattr(self, f"{axis}_abs")))
self.output_buffer.append(" ".join(pos))
elif command == "MOVE":
success = True
for i in range(1, len(temps)):
axis, pos = temps[i].split("=")
if axis in self.axes:
setattr(self, f"{axis}_abs", float(pos))
else:
success = False
if success:
self.output_buffer.append(":A")
else:
self.output_buffer.append(":N")
elif command == "/":
self.output_buffer.append(":A")
elif command == "HALT":
self.output_buffer.append(":A")
elif command == "SPEED":
self.output_buffer.append(":A")
elif command == "BU":
axes = " ".join(self.axes)
self.output_buffer.append(
f"TIGER_COMM\rMotor Axes: {axes} 0 1\rAxis Addr: 1 1 2 2 8 8\rHex "
"Addr: 31 31 32 32 39 39\rAxis Props: 10 10 0 0 0 0"
)
elif command == "AA":
self.output_buffer.append(":A")
elif command == "AZ":
self.output_buffer.append(":A")
elif command == "B":
self.output_buffer.append(":A")
elif command == "PC":
self.output_buffer.append(":A")
elif command == "E":
self.output_buffer.append(":A")
def readline(self):
return bytes(self.output_buffer.pop(0), encoding="ascii")
def __getattr__(self, __name: str):
return self.ignore_obj
@pytest.fixture
def asi_serial_device(ignore_obj):
return MockASIStage(ignore_obj)
class TestStageASI:
"""Unit Test for ASI Stage Class"""
@pytest.fixture(autouse=True)
def setup_class(
self,
stage_configuration,
asi_serial_device,
random_single_axis_test,
random_multiple_axes_test,
):
self.microscope_name = "Mesoscale"
self.configuration = {
"configuration": {
"microscopes": {self.microscope_name: stage_configuration}
}
}
self.stage_configuration = stage_configuration
self.stage_configuration["stage"]["hardware"]["type"] = "ASI"
self.asi_serial_device = asi_serial_device
self.random_single_axis_test = random_single_axis_test
self.random_multiple_axes_test = random_multiple_axes_test
def build_device_connection(self):
port = self.stage_configuration["stage"]["hardware"]["port"]
baudrate = self.stage_configuration["stage"]["hardware"]["baudrate"]
# Patch TigerController.get_default_motor_axis_sequence
TigerController.get_default_motor_axis_sequence = lambda self: [
"X",
"Y",
"Z",
"M",
"N",
]
asi_stage = TigerController(port, baudrate)
asi_stage.serial = self.asi_serial_device
asi_stage.connect_to_serial()
return asi_stage
def test_stage_attributes(self):
stage = ASIStage(self.microscope_name, None, self.configuration)
# Methods
assert hasattr(stage, "get_position_dict") and callable(
getattr(stage, "get_position_dict")
)
assert hasattr(stage, "report_position") and callable(
getattr(stage, "report_position")
)
assert hasattr(stage, "move_axis_absolute") and callable(
getattr(stage, "move_axis_absolute")
)
assert hasattr(stage, "move_absolute") and callable(
getattr(stage, "move_absolute")
)
assert hasattr(stage, "stop") and callable(getattr(stage, "stop"))
assert hasattr(stage, "get_abs_position") and callable(
getattr(stage, "get_abs_position")
)
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x"], ["Y"]),
(["y"], ["Z"]),
(["x", "z"], ["X", "Y"]),
(["f", "z"], ["M", "X"]),
(["x", "y", "z"], ["Y", "X", "M"]),
(["x", "y", "z", "f"], ["X", "M", "Y", "Z"]),
(["x", "y", "z", "f"], ["x", "M", "y", "Z"]),
],
)
def test_initialize_stage(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = ASIStage(self.microscope_name, None, self.configuration)
# Attributes
for axis in axes:
assert hasattr(stage, f"{axis}_pos")
assert hasattr(stage, f"{axis}_min")
assert hasattr(stage, f"{axis}_max")
assert getattr(stage, f"{axis}_pos") == 0
assert (
getattr(stage, f"{axis}_min")
== self.stage_configuration["stage"][f"{axis}_min"]
)
assert (
getattr(stage, f"{axis}_max")
== self.stage_configuration["stage"][f"{axis}_max"]
)
if axes_mapping is None:
# using default mapping which is hard coded in pi.py
default_mapping = {"x": "Z", "y": "Y", "z": "X", "f": "M"}
for axis, device_axis in stage.axes_mapping.items():
assert default_mapping[axis] == device_axis
assert len(stage.axes_mapping) <= len(stage.axes)
else:
for i, axis in enumerate(axes):
assert stage.axes_mapping[axis] == axes_mapping[i].upper()
assert stage.stage_limits is True
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x"], ["Y"]),
(["y"], ["Z"]),
(["x", "z"], ["X", "Y"]),
(["f", "z"], ["M", "X"]),
(["x", "y", "z"], ["Y", "X", "M"]),
(["x", "y", "z", "f"], ["X", "M", "Y", "Z"]),
(["x", "y", "z", "f"], ["x", "M", "y", "Z"]),
],
)
def test_report_position(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
self.configuration["configuration"]["microscopes"][self.microscope_name][
"zoom"
] = {}
self.configuration["configuration"]["microscopes"][self.microscope_name][
"zoom"
]["pixel_size"] = {"5X": 1.3}
asi_stage = self.build_device_connection()
stage = ASIStage(self.microscope_name, asi_stage, self.configuration)
for _ in range(10):
pos_dict = {}
for axis in axes:
pos = random.randrange(-100, 500)
pos_dict[f"{axis}_pos"] = float(pos)
if axis == "theta":
setattr(
asi_stage.serial,
f"{stage.axes_mapping[axis]}_abs",
pos * 1000.0,
)
else:
setattr(
asi_stage.serial,
f"{stage.axes_mapping[axis]}_abs",
pos * 10.0,
)
temp_pos = stage.report_position()
assert pos_dict == temp_pos
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x"], ["Y"]),
(["y"], ["Z"]),
(["x", "z"], ["X", "Y"]),
(["f", "z"], ["M", "X"]),
(["x", "y", "z"], ["Y", "X", "M"]),
(["x", "y", "z", "f"], ["X", "M", "Y", "Z"]),
(["x", "y", "z", "f"], ["x", "M", "y", "Z"]),
],
)
def test_move_axis_absolute(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
asi_stage = self.build_device_connection()
stage = ASIStage(self.microscope_name, asi_stage, self.configuration)
self.random_single_axis_test(stage)
stage.stage_limits = False
self.random_single_axis_test(stage)
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x"], ["Y"]),
(["y"], ["Z"]),
(["x", "z"], ["X", "Y"]),
(["f", "z"], ["M", "X"]),
(["x", "y", "z"], ["Y", "X", "M"]),
(["x", "y", "z", "f"], ["X", "M", "Y", "Z"]),
(["x", "y", "z", "f"], ["x", "M", "y", "Z"]),
],
)
def test_move_absolute(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
asi_stage = self.build_device_connection()
stage = ASIStage(self.microscope_name, asi_stage, self.configuration)
self.random_multiple_axes_test(stage)
stage.stage_limits = False
self.random_multiple_axes_test(stage)

View File

@@ -0,0 +1,244 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import pytest
import random
# Third Party Imports
# Local Imports
from navigate.model.devices.stage.mcl import MCLStage
class MockMCLController:
def __init__(self):
self.axes = ["x", "y", "z", "f", "aux"]
for axis in self.axes:
setattr(self, f"{axis}_abs", 0)
self.MadlibError = Exception
def MCL_SingleReadN(self, axis, handle=None):
try:
return getattr(self, f"{axis}_abs")
except Exception:
raise self.MadlibError
def MCL_SingleWriteN(self, pos, axis, handle=None):
setattr(self, f"{axis}_abs", pos)
def MCL_ReleaseHandle(self, handle):
pass
class TestStageMCL:
"""Unit Test for StageBase Class"""
@pytest.fixture(autouse=True)
def setup_class(
self, stage_configuration, random_single_axis_test, random_multiple_axes_test
):
self.microscope_name = "Mesoscale"
self.configuration = {
"configuration": {
"microscopes": {self.microscope_name: stage_configuration}
}
}
self.stage_configuration = stage_configuration
self.stage_configuration["stage"]["hardware"]["type"] = "MCL"
self.random_single_axis_test = random_single_axis_test
self.random_multiple_axes_test = random_multiple_axes_test
def test_stage_attributes(self):
stage = MCLStage(self.microscope_name, None, self.configuration)
# Methods
assert hasattr(stage, "get_position_dict") and callable(
getattr(stage, "get_position_dict")
)
assert hasattr(stage, "report_position") and callable(
getattr(stage, "report_position")
)
assert hasattr(stage, "move_axis_absolute") and callable(
getattr(stage, "move_axis_absolute")
)
assert hasattr(stage, "move_absolute") and callable(
getattr(stage, "move_absolute")
)
assert hasattr(stage, "stop") and callable(getattr(stage, "stop"))
assert hasattr(stage, "get_abs_position") and callable(
getattr(stage, "get_abs_position")
)
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x", "y", "z", "f", "theta"], None),
(["x"], ["x"]),
(["y"], ["z"]),
(["x", "z"], ["y", "z"]),
(["f", "z"], ["z", "x"]),
(["x", "y", "z"], ["y", "x", "f"]),
(["x", "y", "z", "f"], ["x", "z", "f", "y"]),
(["x", "y", "z", "f", "theta"], ["z", "f", "x", "y", "aux"]),
],
)
def test_initialize_stage(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = MCLStage(self.microscope_name, None, self.configuration)
# Attributes
for axis in axes:
assert hasattr(stage, f"{axis}_pos")
assert hasattr(stage, f"{axis}_min")
assert hasattr(stage, f"{axis}_max")
assert getattr(stage, f"{axis}_pos") == 0
assert (
getattr(stage, f"{axis}_min")
== self.stage_configuration["stage"][f"{axis}_min"]
)
assert (
getattr(stage, f"{axis}_max")
== self.stage_configuration["stage"][f"{axis}_max"]
)
if axes_mapping is None:
# using default mapping which is hard coded in mcl.py
default_mapping = {"x": "x", "y": "y", "z": "z", "f": "f", "theta": "aux"}
for axis, device_axis in stage.axes_mapping.items():
assert default_mapping[axis] == device_axis
assert len(stage.axes_mapping) <= len(stage.axes)
else:
for i, axis in enumerate(axes):
assert stage.axes_mapping[axis] == axes_mapping[i]
assert stage.stage_limits is True
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x", "y", "z", "f", "theta"], None),
(["x"], ["x"]),
(["y"], ["z"]),
(["x", "z"], ["y", "z"]),
(["f", "z"], ["z", "x"]),
(["x", "y", "z"], ["y", "x", "f"]),
(["x", "y", "z", "f"], ["x", "z", "f", "y"]),
(["x", "y", "z", "f", "theta"], ["z", "f", "x", "y", "aux"]),
],
)
def test_report_position(self, axes, axes_mapping):
MCL_device = MockMCLController()
device_connection = {"controller": MCL_device, "handle": None}
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = MCLStage(self.microscope_name, device_connection, self.configuration)
for _ in range(10):
pos_dict = {}
for axis in axes:
pos = random.randrange(-100, 500)
pos_dict[f"{axis}_pos"] = float(pos)
setattr(MCL_device, f"{stage.axes_mapping[axis]}_abs", float(pos))
temp_pos = stage.report_position()
assert pos_dict == temp_pos
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x", "y", "z", "f", "theta"], None),
(["x"], ["x"]),
(["y"], ["z"]),
(["x", "z"], ["y", "z"]),
(["f", "z"], ["z", "x"]),
(["x", "y", "z"], ["y", "x", "f"]),
(["x", "y", "z", "f"], ["x", "z", "f", "y"]),
(["x", "y", "z", "f", "theta"], ["z", "f", "x", "y", "aux"]),
],
)
def test_move_axis_absolute(self, axes, axes_mapping):
MCL_device = MockMCLController()
device_connection = {"controller": MCL_device, "handle": None}
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = MCLStage(self.microscope_name, device_connection, self.configuration)
self.random_single_axis_test(stage)
stage.stage_limits = False
self.random_single_axis_test(stage)
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x", "y", "z", "f", "theta"], None),
(["x"], ["x"]),
(["y"], ["z"]),
(["x", "z"], ["y", "z"]),
(["f", "z"], ["z", "x"]),
(["x", "y", "z"], ["y", "x", "f"]),
(["x", "y", "z", "f"], ["x", "z", "f", "y"]),
(["x", "y", "z", "f", "theta"], ["z", "f", "x", "y", "aux"]),
],
)
def test_move_absolute(self, axes, axes_mapping):
MCL_device = MockMCLController()
device_connection = {"controller": MCL_device, "handle": None}
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = MCLStage(self.microscope_name, device_connection, self.configuration)
self.random_multiple_axes_test(stage)
stage.stage_limits = False
self.random_multiple_axes_test(stage)

View File

@@ -0,0 +1,259 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import pytest
import random
# Third Party Imports
from pipython import GCSError
# Local Imports
from navigate.model.devices.stage.pi import PIStage
class MockPIStage:
def __init__(self):
self.axes = [1, 2, 3, 4, 5]
for axis in self.axes:
setattr(self, f"{axis}_abs", 0)
def MOV(self, pos_dict):
for axis in pos_dict:
if axis not in self.axes:
continue
setattr(self, f"{axis}_abs", pos_dict[axis])
def qPOS(self, axes):
pos = {}
for axis in axes:
if axis not in self.axes:
raise GCSError
pos[str(axis)] = getattr(self, f"{axis}_abs")
return pos
def STP(self, noraise=True):
pass
def waitontarget(self, pi_device, timeout=5.0, **kwargs):
pass
def CloseConnection(self):
pass
class TestStagePI:
"""Unit Test for PI Stage Class"""
@pytest.fixture(autouse=True)
def setup_class(
self, stage_configuration, random_single_axis_test, random_multiple_axes_test
):
self.microscope_name = "Mesoscale"
self.configuration = {
"configuration": {
"microscopes": {self.microscope_name: stage_configuration}
}
}
self.stage_configuration = stage_configuration
self.stage_configuration["stage"]["hardware"]["type"] = "PI"
self.random_single_axis_test = random_single_axis_test
self.random_multiple_axes_test = random_multiple_axes_test
def test_stage_attributes(self):
stage = PIStage(self.microscope_name, None, self.configuration)
# Methods
assert hasattr(stage, "get_position_dict") and callable(
getattr(stage, "get_position_dict")
)
assert hasattr(stage, "report_position") and callable(
getattr(stage, "report_position")
)
assert hasattr(stage, "move_axis_absolute") and callable(
getattr(stage, "move_axis_absolute")
)
assert hasattr(stage, "move_absolute") and callable(
getattr(stage, "move_absolute")
)
assert hasattr(stage, "stop") and callable(getattr(stage, "stop"))
assert hasattr(stage, "get_abs_position") and callable(
getattr(stage, "get_abs_position")
)
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x", "y", "z", "f", "theta"], None),
(["x"], [1]),
(["y"], [2]),
(["x", "z"], [1, 3]),
(["f", "z"], [2, 3]),
(["x", "y", "z"], [1, 2, 3]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
(["x", "y", "z", "f", "theta"], [3, 5, 2, 1, 4]),
],
)
def test_initialize_stage(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = PIStage(self.microscope_name, None, self.configuration)
# Attributes
for axis in axes:
assert hasattr(stage, f"{axis}_pos")
assert hasattr(stage, f"{axis}_min")
assert hasattr(stage, f"{axis}_max")
assert getattr(stage, f"{axis}_pos") == 0
assert (
getattr(stage, f"{axis}_min")
== self.stage_configuration["stage"][f"{axis}_min"]
)
assert (
getattr(stage, f"{axis}_max")
== self.stage_configuration["stage"][f"{axis}_max"]
)
if axes_mapping is None:
# using default mapping which is hard coded in pi.py
default_mapping = {"x": 1, "y": 2, "z": 3, "f": 5, "theta": 4}
for axis, device_axis in stage.axes_mapping.items():
assert default_mapping[axis] == device_axis
assert len(stage.axes_mapping) <= len(stage.axes)
else:
for i, axis in enumerate(axes):
assert stage.axes_mapping[axis] == axes_mapping[i]
assert stage.stage_limits is True
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x", "y", "z", "f", "theta"], None),
(["x"], [1]),
(["y"], [2]),
(["x", "z"], [1, 3]),
(["f", "z"], [2, 3]),
(["x", "y", "z"], [1, 2, 3]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
(["x", "y", "z", "f", "theta"], [3, 5, 2, 1, 4]),
],
)
def test_report_position(self, axes, axes_mapping):
PI_device = MockPIStage()
device_connection = {"pi_tools": PI_device, "pi_device": PI_device}
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = PIStage(self.microscope_name, device_connection, self.configuration)
for _ in range(10):
pos_dict = {}
for axis in axes:
pos = random.randrange(-100, 500)
pos_dict[f"{axis}_pos"] = float(pos)
if axis != "theta":
setattr(PI_device, f"{stage.axes_mapping[axis]}_abs", pos / 1000)
else:
setattr(PI_device, f"{stage.axes_mapping[axis]}_abs", float(pos))
temp_pos = stage.report_position()
assert pos_dict == temp_pos
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x", "y", "z", "f", "theta"], None),
(["x"], [1]),
(["y"], [2]),
(["x", "z"], [1, 3]),
(["f", "z"], [2, 3]),
(["x", "y", "z"], [1, 2, 3]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
(["x", "y", "z", "f", "theta"], [3, 5, 2, 1, 4]),
],
)
def test_move_axis_absolute(self, axes, axes_mapping):
PI_device = MockPIStage()
device_connection = {"pi_tools": PI_device, "pi_device": PI_device}
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = PIStage(self.microscope_name, device_connection, self.configuration)
self.random_single_axis_test(stage)
stage.stage_limits = False
self.random_single_axis_test(stage)
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x", "y", "z", "f", "theta"], None),
(["x"], [1]),
(["y"], [2]),
(["x", "z"], [1, 3]),
(["f", "z"], [2, 3]),
(["x", "y", "z"], [1, 2, 3]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
(["x", "y", "z", "f", "theta"], [3, 5, 2, 1, 4]),
],
)
def test_move_absolute(self, axes, axes_mapping):
PI_device = MockPIStage()
device_connection = {"pi_tools": PI_device, "pi_device": PI_device}
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = PIStage(self.microscope_name, device_connection, self.configuration)
self.random_multiple_axes_test(stage)
stage.stage_limits = False
self.random_multiple_axes_test(stage)

View File

@@ -0,0 +1,231 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import pytest
import random
# Third Party Imports
# Local Imports
from navigate.model.devices.stage.synthetic import SyntheticStage
class TestStageBase:
"""Unit Test for StageBase Class"""
@pytest.fixture(autouse=True)
def setup_class(self, stage_configuration):
self.microscope_name = "Mesoscale"
self.configuration = {
"configuration": {
"microscopes": {self.microscope_name: stage_configuration}
}
}
self.stage_configuration = stage_configuration
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x", "y", "z", "f", "theta"], None),
(["x"], [1]),
(["y"], [2]),
(["x", "z"], [1, 3]),
(["f", "z"], [2, 3]),
(["x", "y", "z"], [1, 2, 3]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
(["x", "y", "z", "f", "theta"], [3, 5, 2, 1, 4]),
],
)
def test_stage_attributes(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = SyntheticStage(self.microscope_name, None, self.configuration)
# Attributes
for axis in axes:
assert hasattr(stage, f"{axis}_pos")
assert hasattr(stage, f"{axis}_min")
assert hasattr(stage, f"{axis}_max")
assert getattr(stage, f"{axis}_pos") == 0
assert (
getattr(stage, f"{axis}_min")
== self.stage_configuration["stage"][f"{axis}_min"]
)
assert (
getattr(stage, f"{axis}_max")
== self.stage_configuration["stage"][f"{axis}_max"]
)
# Check default axes mapping
if axes_mapping is None:
assert stage.axes_mapping == {axis: axis.capitalize() for axis in axes}
else:
for i, axis in enumerate(axes):
assert stage.axes_mapping[axis] == axes_mapping[i]
assert stage.stage_limits is True
@pytest.mark.parametrize(
"axes, axes_pos",
[
(["x"], [1]),
(["y"], [2]),
(["x", "z"], [1, 3]),
(["f", "z"], [2, 3]),
(["x", "y", "z"], [1, 2, 3]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
(["x", "y", "z", "f", "theta"], [3, 5, 2, 1, 4]),
],
)
def test_get_position_dict(self, axes, axes_pos):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
stage = SyntheticStage(self.microscope_name, None, self.configuration)
for i, axis in enumerate(axes):
setattr(stage, f"{axis}_pos", axes_pos[i])
pos_dict = stage.get_position_dict()
for k, v in pos_dict.items():
assert getattr(stage, k) == v
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], [1]),
(["y"], [2]),
(["x", "z"], [1, 3]),
(["f", "z"], [2, 3]),
(["x", "y", "z"], [1, 2, 3]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
(["x", "y", "z", "f", "theta"], [3, 5, 2, 1, 4]),
],
)
def test_get_abs_position(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = SyntheticStage(self.microscope_name, None, self.configuration)
for axis in axes:
axis_min = self.stage_configuration["stage"][f"{axis}_min"]
axis_max = self.stage_configuration["stage"][f"{axis}_max"]
# axis_abs_position inside the boundaries
axis_abs = random.randrange(axis_min, axis_max)
assert stage.get_abs_position(axis, axis_abs) == axis_abs
# axis_abs_position < axis_min
axis_abs = axis_min - 10.5
assert stage.get_abs_position(axis, axis_abs) == -1e50
# turn off stage_limits
stage.stage_limits = False
assert stage.get_abs_position(axis, axis_abs) == axis_abs
stage.stage_limits = True
# axis_abs_position > axis_max
axis_abs = axis_max + 10.5
assert stage.get_abs_position(axis, axis_abs) == -1e50
# turn off stage_limits
stage.stage_limits = False
assert stage.get_abs_position(axis, axis_abs) == axis_abs
stage.stage_limits = True
# axis is not supported
all_axes = set(["x", "y", "z", "f", "theta"])
sub_axes = all_axes - set(axes)
for axis in sub_axes:
assert stage.get_abs_position(axis, 1.0) == -1e50
# turn off stage_limits
stage.stage_limits = False
assert stage.get_abs_position(axis, axis_abs) == -1e50
stage.stage_limits = True
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], [1]),
(["y"], [2]),
(["x", "z"], [1, 3]),
(["f", "z"], [2, 3]),
(["x", "y", "z"], [1, 2, 3]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
(["x", "y", "z", "f", "theta"], [3, 5, 2, 1, 4]),
],
)
def test_verify_abs_position(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = SyntheticStage(self.microscope_name, None, self.configuration)
move_dict = {}
abs_dict = {}
for axis in axes:
axis_min = self.stage_configuration["stage"][f"{axis}_min"]
axis_max = self.stage_configuration["stage"][f"{axis}_max"]
# axis_abs_position inside the boundaries
axis_abs = random.randrange(axis_min, axis_max)
move_dict[f"{axis}_abs"] = axis_abs
abs_dict[axis] = axis_abs
assert stage.verify_abs_position(move_dict) == abs_dict
# turn off stage_limits
stage.stage_limits = False
axis = random.choice(axes)
axis_min = self.stage_configuration["stage"][f"{axis}_min"]
axis_max = self.stage_configuration["stage"][f"{axis}_max"]
# Test minimum boundary
move_dict[f"{axis}_abs"] = axis_min - 1.5
abs_dict[axis] = axis_min - 1.5
assert stage.verify_abs_position(move_dict) == abs_dict
# Test maximum boundary
move_dict[f"{axis}_abs"] = axis_max + 1.5
abs_dict[axis] = axis_max + 1.5
assert stage.verify_abs_position(move_dict) == abs_dict
stage.stage_limits = True
# axis is not included in axes list
axis_abs = random.randrange(axis_min, axis_max)
move_dict[f"{axis}_abs"] = axis_abs
abs_dict[axis] = axis_abs
move_dict["theta_abs"] = 180
if "theta" in axes:
abs_dict["theta"] = 180
assert stage.verify_abs_position(move_dict) == abs_dict
stage.stage_limits = False
assert stage.verify_abs_position(move_dict) == abs_dict

View File

@@ -0,0 +1,158 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import pytest
import random
from unittest.mock import patch
# Third Party Imports
# Local Imports
from navigate.model.devices.stage.ni import NIStage
from test.model.dummy import DummyModel
from navigate.tools.common_functions import copy_proxy_object
class TestNIStage:
"""Unit Test for NI stage Class"""
@pytest.fixture(autouse=True)
def setup_class(
self,
stage_configuration,
ignore_obj,
random_single_axis_test,
random_multiple_axes_test,
):
dummy_model = DummyModel()
self.configuration = copy_proxy_object(dummy_model.configuration)
self.microscope_name = list(
self.configuration["configuration"]["microscopes"].keys()
)[0]
self.configuration["configuration"]["microscopes"][self.microscope_name][
"stage"
] = stage_configuration["stage"]
self.stage_configuration = stage_configuration
self.stage_configuration["stage"]["hardware"]["type"] = "NI"
self.stage_configuration["stage"]["hardware"]["volts_per_micron"] = "0.1"
self.stage_configuration["stage"]["hardware"]["max"] = 5.0
self.stage_configuration["stage"]["hardware"]["min"] = 0.1
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = ["PXI6259/ao2"]
self.daq = ignore_obj
self.random_single_axis_test = random_single_axis_test
self.random_multiple_axes_test = random_multiple_axes_test
@patch("nidaqmx.Task")
def test_stage_attributes(self, *args):
stage = NIStage(self.microscope_name, self.daq, self.configuration)
# Methods
assert hasattr(stage, "get_position_dict") and callable(
getattr(stage, "get_position_dict")
)
assert hasattr(stage, "report_position") and callable(
getattr(stage, "report_position")
)
assert hasattr(stage, "move_axis_absolute") and callable(
getattr(stage, "move_axis_absolute")
)
assert hasattr(stage, "move_absolute") and callable(
getattr(stage, "move_absolute")
)
assert hasattr(stage, "stop") and callable(getattr(stage, "stop"))
assert hasattr(stage, "get_abs_position") and callable(
getattr(stage, "get_abs_position")
)
@pytest.mark.parametrize("axes", [(["x"]), (["y"]), (["f"])])
def test_initialize_stage(self, axes):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
with patch("nidaqmx.Task"):
stage = NIStage(self.microscope_name, self.daq, self.configuration)
# Attributes
for axis in axes:
assert hasattr(stage, f"{axis}_pos")
assert hasattr(stage, f"{axis}_min")
assert hasattr(stage, f"{axis}_max")
assert getattr(stage, f"{axis}_pos") == 0
assert (
getattr(stage, f"{axis}_min")
== self.stage_configuration["stage"][f"{axis}_min"]
)
assert (
getattr(stage, f"{axis}_max")
== self.stage_configuration["stage"][f"{axis}_max"]
)
for i, axis in enumerate(axes):
assert (
stage.axes_mapping[axis]
== self.stage_configuration["stage"]["hardware"]["axes_mapping"][i]
)
@pytest.mark.parametrize("axes", [(["x"]), (["y"]), (["f"])])
def test_report_position(self, axes):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
with patch("nidaqmx.Task"):
stage = NIStage(self.microscope_name, self.daq, self.configuration)
for _ in range(10):
pos_dict = {}
for axis in axes:
pos = random.randrange(-100, 500)
pos_dict[f"{axis}_pos"] = float(pos)
setattr(stage, f"{axis}_pos", float(pos))
temp_pos = stage.report_position()
assert pos_dict == temp_pos
@pytest.mark.parametrize("axes", [(["x"]), (["y"]), (["f"])])
def test_move_axis_absolute(self, axes):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
with patch("nidaqmx.Task"):
stage = NIStage(self.microscope_name, self.daq, self.configuration)
self.random_single_axis_test(stage)
stage.stage_limits = False
self.random_single_axis_test(stage)
@pytest.mark.parametrize("axes", [(["x"]), (["y"]), (["f"])])
def test_move_absolute(self, axes):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
with patch("nidaqmx.Task"):
stage = NIStage(self.microscope_name, self.daq, self.configuration)
self.random_multiple_axes_test(stage)
stage.stage_limits = False
self.random_multiple_axes_test(stage)

View File

@@ -0,0 +1,298 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import pytest
import random
# Third Party Imports
# Local Imports
from navigate.model.devices.stage.sutter import MP285Stage
from navigate.model.devices.APIs.sutter.MP285 import MP285
class MockMP285Stage:
def __init__(self, ignore_obj):
self.axes = ["x", "y", "z"]
for axis in self.axes:
setattr(self, f"{axis}_abs", 0)
self.input_buffer = []
self.output_buffer = []
self.in_waiting = 0
self.ignore_obj = ignore_obj
def open(self):
pass
def reset_input_buffer(self):
self.input_buffer = []
def reset_output_buffer(self):
self.output_buffer = []
def write(self, command):
if command == bytes.fromhex("63") + bytes.fromhex("0d"):
# get current x, y, and z position
self.output_buffer.append(
self.x_abs.to_bytes(4, byteorder="little", signed=True)
+ self.y_abs.to_bytes(4, byteorder="little", signed=True)
+ self.z_abs.to_bytes(4, byteorder="little", signed=True)
+ bytes.fromhex("0d")
)
self.in_waiting += 13
elif (
command[0] == int("6d", 16)
and len(command) == 14
and command[-1] == int("0d", 16)
):
# move x, y, and z to specific position
self.x_abs = int.from_bytes(command[1:5], byteorder="little", signed=True)
self.y_abs = int.from_bytes(command[5:9], byteorder="little", signed=True)
self.z_abs = int.from_bytes(command[9:13], byteorder="little", signed=True)
self.output_buffer.append(bytes.fromhex("0d"))
self.in_waiting += 1
elif (
command[0] == int("56", 16)
and len(command) == 4
and command[-1] == int("0d", 16)
):
# set resolution and velocity
self.output_buffer.append(bytes.fromhex("0d"))
self.in_waiting += 1
elif command[0] == int("03", 16) and len(command) == 1:
# interrupt move
self.output_buffer.append(bytes.fromhex("0d"))
self.in_waiting += 1
elif command == bytes.fromhex("61") + bytes.fromhex("0d"):
# set absolute mode
self.output_buffer.append(bytes.fromhex("0d"))
self.in_waiting += 1
elif command == bytes.fromhex("62") + bytes.fromhex("0d"):
# set relative mode
self.in_waiting += 1
self.output_buffer.append(bytes.fromhex("0d"))
def read_until(self, expected, size=100):
return self.output_buffer.pop(0)
def read(self, byte_num=1):
self.in_waiting -= len(self.output_buffer[0])
return self.output_buffer.pop(0)
def __getattr__(self, __name: str):
return self.ignore_obj
@pytest.fixture
def mp285_serial_device(ignore_obj):
return MockMP285Stage(ignore_obj)
class TestStageSutter:
"""Unit Test for StageBase Class"""
@pytest.fixture(autouse=True)
def setup_class(
self,
stage_configuration,
mp285_serial_device,
random_single_axis_test,
random_multiple_axes_test,
):
self.microscope_name = "Mesoscale"
self.configuration = {
"configuration": {
"microscopes": {self.microscope_name: stage_configuration}
}
}
self.stage_configuration = stage_configuration
self.stage_configuration["stage"]["hardware"]["type"] = "MP285"
self.mp285_serial_device = mp285_serial_device
self.random_single_axis_test = random_single_axis_test
self.random_multiple_axes_test = random_multiple_axes_test
def build_device_connection(self):
port = self.stage_configuration["stage"]["hardware"]["port"]
baudrate = self.stage_configuration["stage"]["hardware"]["baudrate"]
timeout = 5.0
mp285 = MP285(port, baudrate, timeout)
mp285.serial = self.mp285_serial_device
mp285.connect_to_serial()
return mp285
def test_stage_attributes(self):
stage = MP285Stage(
self.microscope_name, self.build_device_connection(), self.configuration
)
# Methods
assert hasattr(stage, "get_position_dict") and callable(
getattr(stage, "get_position_dict")
)
assert hasattr(stage, "report_position") and callable(
getattr(stage, "report_position")
)
assert hasattr(stage, "move_absolute") and callable(
getattr(stage, "move_absolute")
)
assert hasattr(stage, "stop") and callable(getattr(stage, "stop"))
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x"], ["y"]),
(["y"], ["z"]),
(["x", "z"], ["y", "z"]),
(["f", "z"], ["x", "z"]),
(["x", "y", "z"], ["y", "z", "x"]),
],
)
def test_initialize_stage(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = MP285Stage(
self.microscope_name, self.build_device_connection(), self.configuration
)
# Attributes
for axis in axes:
assert hasattr(stage, f"{axis}_pos")
assert hasattr(stage, f"{axis}_min")
assert hasattr(stage, f"{axis}_max")
assert getattr(stage, f"{axis}_pos") == 0
assert (
getattr(stage, f"{axis}_min")
== self.stage_configuration["stage"][f"{axis}_min"]
)
assert (
getattr(stage, f"{axis}_max")
== self.stage_configuration["stage"][f"{axis}_max"]
)
if axes_mapping is None:
# using default mapping which is hard coded in sutter.py
default_mapping = {"x": "x", "y": "y", "z": "z"}
for axis, device_axis in stage.axes_mapping.items():
assert default_mapping[axis] == device_axis
assert len(stage.axes_mapping) <= len(stage.axes)
else:
for i, axis in enumerate(axes):
assert stage.axes_mapping[axis] == axes_mapping[i]
assert stage.stage_limits is True
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x"], ["y"]),
(["y"], ["z"]),
(["x", "z"], ["y", "z"]),
(["f", "z"], ["x", "z"]),
(["x", "y", "z"], ["y", "z", "x"]),
],
)
def test_report_position(self, axes, axes_mapping):
mp285_stage = self.build_device_connection()
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = MP285Stage(self.microscope_name, mp285_stage, self.configuration)
for _ in range(10):
pos_dict = {}
for axis in axes:
pos = random.randrange(-100, 500)
if axis in stage.axes_mapping:
pos_dict[f"{axis}_pos"] = pos * 0.04
setattr(mp285_stage.serial, f"{stage.axes_mapping[axis]}_abs", pos)
else:
pos_dict[f"{axis}_pos"] = 0
temp_pos = stage.report_position()
assert pos_dict == temp_pos
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x"], ["y"]),
(["y"], ["z"]),
(["x", "z"], ["y", "z"]),
(["f", "z"], ["x", "z"]),
(["x", "y", "z"], ["y", "z", "x"]),
],
)
def test_move_axis_absolute(self, axes, axes_mapping):
mp285_stage = self.build_device_connection()
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = MP285Stage(self.microscope_name, mp285_stage, self.configuration)
self.random_single_axis_test(stage)
stage.stage_limits = False
self.random_single_axis_test(stage)
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x"], ["y"]),
(["y"], ["z"]),
(["x", "z"], ["y", "z"]),
(["f", "z"], ["x", "z"]),
(["x", "y", "z"], ["y", "z", "x"]),
],
)
def test_move_absolute(self, axes, axes_mapping):
mp285_stage = self.build_device_connection()
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = MP285Stage(self.microscope_name, mp285_stage, self.configuration)
self.random_multiple_axes_test(stage)
stage.stage_limits = False
self.random_multiple_axes_test(stage)

View File

@@ -0,0 +1,253 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import pytest
import random
# Third Party Imports
# Local Imports
from navigate.model.devices.stage.thorlabs import KIM001Stage
class MockKimController:
# mocks single serial number device
def __init__(self, ignore_obj):
self.axes = [1, 2, 3, 4]
self.ignore_obj = ignore_obj
for axis in self.axes:
setattr(self, f"{axis}_abs", 0)
def KIM_RequestCurrentPosition(self, serial_number, axis):
pass
def KIM_GetCurrentPosition(self, serial_number, axis):
return getattr(self, f"{axis}_abs", 0)
def KIM_MoveAbsolute(self, serial_number, axis, pos: int):
if axis in self.axes:
setattr(self, f"{axis}_abs", int(pos))
def __getattr__(self, __name: str):
return self.ignore_obj
@pytest.fixture
def kim_controller(ignore_obj):
return MockKimController(ignore_obj)
class TestStageTlKCubeInertial:
"""Unit Test for StageBase Class"""
@pytest.fixture(autouse=True)
def setup_class(
self,
stage_configuration,
kim_controller,
random_single_axis_test,
random_multiple_axes_test,
):
self.microscope_name = "Mesoscale"
self.configuration = {
"configuration": {
"microscopes": {self.microscope_name: stage_configuration}
}
}
self.stage_configuration = stage_configuration
self.stage_configuration["stage"]["hardware"]["type"] = "Thorlabs"
self.kim_controller = kim_controller
self.random_single_axis_test = random_single_axis_test
self.random_multiple_axes_test = random_multiple_axes_test
def test_stage_attributes(self):
stage = KIM001Stage(
self.microscope_name, self.kim_controller, self.configuration
)
# Methods
assert hasattr(stage, "get_position_dict") and callable(
getattr(stage, "get_position_dict")
)
assert hasattr(stage, "report_position") and callable(
getattr(stage, "report_position")
)
assert hasattr(stage, "move_axis_absolute") and callable(
getattr(stage, "move_axis_absolute")
)
assert hasattr(stage, "move_absolute") and callable(
getattr(stage, "move_absolute")
)
assert hasattr(stage, "stop") and callable(getattr(stage, "stop"))
assert hasattr(stage, "get_abs_position") and callable(
getattr(stage, "get_abs_position")
)
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x"], [1]),
(["y"], [3]),
(["x", "z"], [3, 1]),
(["f", "z"], [1, 4]),
(["x", "y", "z"], [1, 2, 4]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
],
)
def test_initialize_stage(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = KIM001Stage(
self.microscope_name, self.kim_controller, self.configuration
)
# Attributes
for axis in axes:
assert hasattr(stage, f"{axis}_pos")
assert hasattr(stage, f"{axis}_min")
assert hasattr(stage, f"{axis}_max")
assert getattr(stage, f"{axis}_pos") == 0
assert (
getattr(stage, f"{axis}_min")
== self.stage_configuration["stage"][f"{axis}_min"]
)
assert (
getattr(stage, f"{axis}_max")
== self.stage_configuration["stage"][f"{axis}_max"]
)
if axes_mapping is None:
# using default mapping which is hard coded in pi.py
default_mapping = {"x": 4, "y": 2, "z": 3, "f": 1}
for axis, device_axis in stage.axes_mapping.items():
assert default_mapping[axis] == device_axis
assert len(stage.axes_mapping) <= len(stage.axes)
else:
for i, axis in enumerate(axes):
assert stage.axes_mapping[axis] == axes_mapping[i]
assert stage.stage_limits is True
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x"], [1]),
(["y"], [3]),
(["x", "z"], [3, 1]),
(["f", "z"], [1, 4]),
(["x", "y", "z"], [1, 2, 4]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
],
)
def test_report_position(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = KIM001Stage(
self.microscope_name, self.kim_controller, self.configuration
)
for _ in range(10):
pos_dict = {}
for axis in axes:
pos = random.randrange(-100, 500)
pos_dict[f"{axis}_pos"] = float(pos)
setattr(self.kim_controller, f"{stage.axes_mapping[axis]}_abs", pos)
temp_pos = stage.report_position()
assert pos_dict == temp_pos
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x"], [1]),
(["y"], [3]),
(["x", "z"], [3, 1]),
(["f", "z"], [1, 4]),
(["x", "y", "z"], [1, 2, 4]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
],
)
def test_move_axis_absolute(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = KIM001Stage(
self.microscope_name, self.kim_controller, self.configuration
)
self.random_single_axis_test(stage)
stage.stage_limits = False
self.random_single_axis_test(stage)
@pytest.mark.parametrize(
"axes, axes_mapping",
[
(["x"], None),
(["y"], None),
(["x", "z"], None),
(["f", "z"], None),
(["x", "y", "z"], None),
(["x", "y", "z", "f"], None),
(["x"], [1]),
(["y"], [3]),
(["x", "z"], [3, 1]),
(["f", "z"], [1, 4]),
(["x", "y", "z"], [1, 2, 4]),
(["x", "y", "z", "f"], [1, 3, 2, 4]),
],
)
def test_move_absolute(self, axes, axes_mapping):
self.stage_configuration["stage"]["hardware"]["axes"] = axes
self.stage_configuration["stage"]["hardware"]["axes_mapping"] = axes_mapping
stage = KIM001Stage(
self.microscope_name, self.kim_controller, self.configuration
)
self.random_multiple_axes_test(stage)
stage.stage_limits = False
self.random_multiple_axes_test(stage)

View File

@@ -0,0 +1,205 @@
# Standard Library Imports
import time
import unittest
# Third Party Imports
import pytest
# Local Imports
from navigate.model.devices.stage.thorlabs import KST101Stage
@pytest.mark.hardware
class TestStageClass(unittest.TestCase):
def setUp(self):
# Create configuration for microscope stage
self.serial_number = 26001318
self.dv_units = 20000000
self.real_units = 9.957067 # mm
self.dv_per_mm = self.dv_units / self.real_units
self.mm_per_dv = self.real_units / self.dv_units
self.microscope_name = "test"
self.config = {
"configuration": {
"microscopes": {
f"{self.microscope_name}": {
"stage": {
"hardware": {
"serial_number": str(self.serial_number),
"axes": "f",
"axes_mapping": [1],
"device_units_per_mm": self.dv_per_mm,
"f_min": 0,
"f_max": 25,
},
"f_min": 0,
"f_max": 25,
}
}
}
}
}
# Create the stage controller class
self.stage = KST101Stage(
microscope_name=self.microscope_name,
device_connection=None,
configuration=self.config,
)
def tearDown(self):
self.kcube_connection.KST_Close(str(self.serial_number))
def test_homing(self):
"""Test the homing function"""
self.stage.run_homing()
def test_move_axis_absolute(self):
distance = 0.100
# Get the current position
self.stage.report_position()
start = self.stage.f_pos
print(f"starting stage position = {start}")
# Move the target distance
target = start + distance
self.stage.move_axis_absolute("f", target, True)
# Read the position and report
self.stage.report_position()
end = self.stage.f_pos
print(
f"The final position in device units:{end/self.dv_per_mm}, "
f"in real units:{end}mm,\n",
f"Distance moved = {(end-start)}mm",
)
def test_move_absolute(self):
distance = 0.200
# Get the current position
self.stage.report_position()
start = self.stage.f_pos
print(f"starting stage position = {start}")
# Move the target distance
target = start + distance
self.stage.move_to_position(target, True)
# Read the position and report
self.stage.report_position()
end = self.stage.f_pos
print(
f"The final position in device units:{end}, in real units:{end}mm,\n",
f"Distance moved = {(end-start)}mm",
)
def test_move_to_position(self):
distance = 0.100
# Get the current position
self.stage.report_position()
start = self.stage.f_pos
print(f"starting stage position = {start:.4f}")
# move target distance, wait till done
self.stage.move_to_position(start + distance, True)
# get the final position
self.stage.report_position()
end = self.stage.f_pos
print(f"End stage position = {end:.4f}", f"distance moved = {end-start:.6f}")
@pytest.mark.hardware
class TestKSTDeviceController(unittest.TestCase):
def setUp(self):
# test build connection function
self.serial_number = 26001318
# perform calibration
dv_units = 20000000
real_units = 9.957067 # mm
self.dv_per_mm = dv_units / real_units
# Open connection to stage
self.kcube_connection = KST101Stage.connect(self.serial_number)
time.sleep(2)
# Move the stage to middle of travel
self.kcube_connection.KST_MoveToPosition(
str(self.serial_number), int(12.5 * self.dv_per_mm)
)
time.sleep(5)
current_pos = self.kcube_connection.KST_GetCurrentPosition(
str(self.serial_number)
)
print(f"Stage currently at:{current_pos} dvUnits")
def tearDown(self):
self.kcube_connection.KST_Close(str(self.serial_number))
def test_move(self):
"""Test how long commands take to execute move some distance"""
distance = 12.5
start = self.kcube_connection.KST_GetCurrentPosition(str(self.serial_number))
final_position = start + distance
self.kcube_connection.KST_MoveToPosition(
str(self.serial_number), int(final_position * self.dv_per_mm)
)
time.sleep(5)
tstart = time.time()
self.kcube_connection.KST_MoveToPosition(str(self.serial_number), start)
pos = None
while pos != start:
pos = self.kcube_connection.KST_GetCurrentPosition(str(self.serial_number))
tend = time.time()
print(f"it takes {tend - tstart:.3f}s to move {distance:.3}mm")
def test_jog(self):
"""Test MoveJog"""
# get the initial position
start = self.kcube_connection.KST_GetCurrentPosition(str(self.serial_number))
# Test a short jog
self.kcube_connection.KST_MoveJog(str(self.serial_number), 1)
time.sleep(2)
self.kcube_connection.KST_MoveStop(str(self.serial_number))
time.sleep(2)
# read stage and make sure it moved
jog_pos = self.kcube_connection.KST_GetCurrentPosition(str(self.serial_number))
print(f"JogMove moved from {start} to {jog_pos}, starting jog back...")
self.kcube_connection.KST_MoveJog(str(self.serial_number), 2)
time.sleep(2)
self.kcube_connection.KST_MoveStop(str(self.serial_number))
time.sleep(2)
end = self.kcube_connection.KST_GetCurrentPosition(str(self.serial_number))
print(f"JogMove back moved from {jog_pos} to {end}")
def test_polling(self):
"""Start polling, then run the jog test"""
print("testing polling")
# start polling
self.kcube_connection.KST_StartPolling(str(self.serial_number), 100)
# Run Jog during active polling
self.test_jog()
# End polling
self.kcube_connection.KST_StopPolling(str(self.serial_number))
# pos = self.kcube_connection.KST_GetCurrentPosition(str(self.serial_number))
# print(f"final position: {pos}")

View File

@@ -0,0 +1,80 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below)
# provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import pytest
# Third Party Imports
# Local Imports
class TestSyntheticHardware:
@pytest.fixture(autouse=True)
def setup_class(self, dummy_model):
self.dummy_model = dummy_model
self.microscope_name = "Mesoscale"
def test_synthetic_daq(self):
from navigate.model.devices.daq.synthetic import SyntheticDAQ
SyntheticDAQ(self.dummy_model.configuration)
def test_synthetic_camera(self):
from navigate.model.devices.camera.synthetic import (
SyntheticCamera,
SyntheticCameraController,
)
scc = SyntheticCameraController()
SyntheticCamera(self.microscope_name, scc, self.dummy_model.configuration)
def test_synthetic_stage(self):
from navigate.model.devices.stage.synthetic import SyntheticStage
SyntheticStage(self.microscope_name, None, self.dummy_model.configuration)
def test_synthetic_zoom(self):
from navigate.model.devices.zoom.synthetic import SyntheticZoom
SyntheticZoom(self.microscope_name, None, self.dummy_model.configuration)
def test_synthetic_shutter(self):
from navigate.model.devices.shutter.synthetic import SyntheticShutter
SyntheticShutter(self.microscope_name, None, self.dummy_model.configuration)
def test_synthetic_laser(self):
from navigate.model.devices.laser.synthetic import SyntheticLaser
SyntheticLaser(self.microscope_name, None, self.dummy_model.configuration, 0)

View File

@@ -0,0 +1,85 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
import pytest
@pytest.fixture
def dummy_zoom(dummy_model):
from navigate.model.devices.zoom.synthetic import SyntheticZoom
return SyntheticZoom(
dummy_model.active_microscope_name, None, dummy_model.configuration
)
def test_zoom_base_attributes(dummy_zoom):
assert hasattr(dummy_zoom, "zoomdict")
assert hasattr(dummy_zoom, "zoomvalue")
assert hasattr(dummy_zoom, "set_zoom") and callable(getattr(dummy_zoom, "set_zoom"))
assert hasattr(dummy_zoom, "move") and callable(getattr(dummy_zoom, "move"))
assert hasattr(dummy_zoom, "read_position") and callable(
getattr(dummy_zoom, "read_position")
)
def test_build_stage_dict(dummy_zoom):
import random
a, b, c = random.randint(1, 1000), random.randint(1, 1000), random.randint(1, 1000)
dummy_zoom.configuration["stage_positions"] = {
"BABB": {"f": {"0.63x": a, "1x": b, "2x": c}}
}
dummy_zoom.build_stage_dict()
assert dummy_zoom.stage_offsets["BABB"]["f"]["0.63x"]["0.63x"] == 0
assert dummy_zoom.stage_offsets["BABB"]["f"]["0.63x"]["1x"] == b - a
assert dummy_zoom.stage_offsets["BABB"]["f"]["0.63x"]["2x"] == c - a
assert dummy_zoom.stage_offsets["BABB"]["f"]["1x"]["0.63x"] == a - b
assert dummy_zoom.stage_offsets["BABB"]["f"]["1x"]["1x"] == 0
assert dummy_zoom.stage_offsets["BABB"]["f"]["1x"]["2x"] == c - b
assert dummy_zoom.stage_offsets["BABB"]["f"]["2x"]["0.63x"] == a - c
assert dummy_zoom.stage_offsets["BABB"]["f"]["2x"]["1x"] == b - c
assert dummy_zoom.stage_offsets["BABB"]["f"]["2x"]["2x"] == 0
def test_set_zoom(dummy_zoom):
for zoom in dummy_zoom.zoomdict.keys():
dummy_zoom.set_zoom(zoom)
assert dummy_zoom.zoomvalue == zoom
try:
dummy_zoom.set_zoom("not_a_zoom")
assert False
except ValueError:
assert True

View File

@@ -0,0 +1,60 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import unittest
# Third Party Imports
import pytest
import platform
# Local Imports
class TestZoomDynamixel(unittest.TestCase):
"""Unit Test for DynamixelZoom Class
Does not instantiate object owing to DLL"""
@pytest.mark.skipif(platform.system() != "Windows", reason="No DLL for mac")
def test_zoom_dynamixel_attributes(self):
from navigate.model.devices.zoom.dynamixel import DynamixelZoom
attributes = dir(DynamixelZoom)
desired_attributes = ["move", "read_position", "set_zoom"]
for da in desired_attributes:
assert da in attributes
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,67 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Standard Library Imports
import unittest
# Third Party Imports
# Local Imports
from navigate.model.devices.zoom.synthetic import SyntheticZoom
from test.model.dummy import DummyModel
class TestZoomSynthetic(unittest.TestCase):
"""Unit Test for SyntheticZoom Class"""
dummy_model = DummyModel()
microscope_name = "Mesoscale"
zoom_class = SyntheticZoom(microscope_name, None, dummy_model.configuration)
def test_zoom_synthetic_attributes(self):
assert hasattr(self.zoom_class, "zoomdict")
assert hasattr(self.zoom_class, "zoomvalue")
assert hasattr(self.zoom_class, "set_zoom") and callable(
getattr(self.zoom_class, "set_zoom")
)
assert hasattr(self.zoom_class, "move") and callable(
getattr(self.zoom_class, "move")
)
assert hasattr(self.zoom_class, "read_position") and callable(
getattr(self.zoom_class, "read_position")
)
if __name__ == "__main__":
unittest.main()