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

0
test/__init__.py Normal file
View File

855
test/config/test_config.py Normal file
View File

@@ -0,0 +1,855 @@
# 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 Imports
import pathlib
import unittest
from unittest.mock import patch, MagicMock
from multiprocessing import Manager
from multiprocessing.managers import ListProxy, DictProxy
import os
import time
import random
import yaml
import sys
# Third Party Imports
# Local Imports
import navigate.config.config as config
from navigate.tools.file_functions import save_yaml_file, delete_folder, load_yaml_file
def test_config_methods():
methods = dir(config)
desired_methods = [
"DictProxy",
"ListProxy",
"Path",
"__builtins__",
"__cached__",
"__doc__",
"__file__",
"__loader__",
"__name__",
"__package__",
"__spec__",
"build_nested_dict",
"build_ref_name",
"load_param_from_module",
"save_yaml_file",
"get_configuration_paths",
"get_navigate_path",
"isfile",
"load_configs",
"os",
"platform",
"shutil",
"sys",
"time",
"update_config_dict",
"verify_experiment_config",
"verify_waveform_constants",
"verify_positions_config",
"verify_configuration",
"support_deceased_configuration",
"yaml",
"logging",
"logger",
"p",
"Union",
"multiprocessing",
]
for method in methods:
assert method in desired_methods
def test_get_navigate_path():
"""Test that the Navigate path is a string."""
assert isinstance(config.get_navigate_path(), str)
path_string = config.get_navigate_path()
assert ".navigate" in path_string
def test_get_navigate_path_windows(monkeypatch):
"""Test that the Navigate path is a string."""
monkeypatch.setattr(config.platform, "system", lambda: "Windows")
monkeypatch.setattr(config.os, "getenv", lambda x: "LOCALAPPDATA")
monkeypatch.setattr(config.os.path, "exists", lambda x: True)
assert isinstance(config.get_navigate_path(), str)
path_string = config.get_navigate_path()
assert path_string.startswith("LOCALAPPDATA")
assert path_string.endswith(".navigate")
def test_get_navigate_path_mac(monkeypatch):
"""Test that the Navigate path is a string."""
monkeypatch.setattr(config.platform, "system", lambda: "Darwin")
monkeypatch.setattr(config.os, "getenv", lambda x: "HOME")
monkeypatch.setattr(config.os.path, "exists", lambda x: True)
assert isinstance(config.get_navigate_path(), str)
path_string = config.get_navigate_path()
assert path_string.startswith("HOME")
assert path_string.endswith(".navigate")
# Write a test for config.get_configuration_paths()
def test_get_configuration_paths():
"""Test that the configuration paths are a list."""
paths = config.get_configuration_paths()
for path in paths:
assert isinstance(path, pathlib.Path)
assert len(paths) == 7
def test_get_configuration_paths_create_dir(monkeypatch):
"""Test that the configuration path is created,
and that they are a list."""
monkeypatch.setattr(config, "get_navigate_path", lambda: "TESTPATH")
paths = config.get_configuration_paths()
for path in paths:
assert isinstance(path, pathlib.Path)
assert os.path.exists(path), "Each configuration yaml file is copied"
assert path.suffix.lower() in [".yml", ".yaml"]
# delete generated folder
delete_folder("TESTPATH")
# test that the system is exited if no file is provided to load_yaml_config
def test_load_yaml_config_no_file():
"""Test that the system exits if no file is provided."""
from unittest import mock
with mock.patch("sys.exit") as mock_sys_exit:
config.load_configs(manager=Manager(), **{})
mock_sys_exit.assert_called_once()
class TestLoadConfigsWithYAMLError(unittest.TestCase):
"""Test the load_configs function.
Target is the yaml.YAMLError exception clause.
"""
@patch("yaml.load")
@patch("builtins.open")
@patch("pathlib.Path.exists")
def test_yaml_error(self, mock_exists, mock_open, mock_yaml_load):
# Set up the mocks
mock_exists.return_value = True
mock_open.return_value = MagicMock()
mock_yaml_load.side_effect = yaml.YAMLError("Test YAMLError")
# Mocking sys.exit to prevent the test runner from exiting
with patch.object(sys, "exit") as mock_exit:
manager = MagicMock()
config.load_configs(manager, config1="path/to/config1.yaml")
# Check if sys.exit was called with the expected argument
mock_exit.assert_called_with(1)
# Check if the YAMLError was triggered
mock_yaml_load.assert_called_once()
class TestBuildNestedDict(unittest.TestCase):
def setUp(self):
self.manager = Manager()
self.parent_dict = {}
self.key_name = "nested_dict"
def tearDown(self):
self.manager.shutdown()
def test_build_nested_dict_with_string_data(self):
string_data = "string"
expected_result = {"nested_dict": "string"}
config.build_nested_dict(
self.manager, self.parent_dict, self.key_name, string_data
)
self.assertEqual(self.parent_dict, expected_result)
self.assertEqual(self.parent_dict[self.key_name], "string")
assert isinstance(self.parent_dict, dict)
def test_build_nested_dict_with_list_data(self):
list_data = ["string1", "string2"]
config.build_nested_dict(
self.manager, self.parent_dict, self.key_name, list_data
)
assert isinstance(self.parent_dict, dict)
assert isinstance(self.parent_dict[self.key_name], ListProxy)
for i in range(2):
assert self.parent_dict[self.key_name][i] == list_data[i]
def test_build_nested_dict_with_dict_data(self):
dict_data = {"key1": "string1", "key2": "string2"}
config.build_nested_dict(
self.manager, self.parent_dict, self.key_name, dict_data
)
assert isinstance(self.parent_dict, dict)
assert isinstance(self.parent_dict[self.key_name], DictProxy)
for key in dict_data.keys():
assert self.parent_dict[self.key_name][key] == dict_data[key]
def test_update_config_dict_with_bad_file_name(self):
test_entry = "string"
dict_data = {"key1": "string1", "key2": "string2"}
# Build the nested config
config.build_nested_dict(
self.manager, self.parent_dict, self.key_name, dict_data
)
# Update the nested config
result = config.update_config_dict(
self.manager, self.parent_dict, self.key_name, test_entry
)
assert result is False
def test_update_config_dict_with_file_name(self):
test_entry = "test.yml"
# create an yaml file
test_yaml_data = {"test_key1": "test_string1", "test_key2": "test_string2"}
save_yaml_file("", test_yaml_data, test_entry)
dict_data = {"key1": "string1", "key2": "string2"}
# Build the nested config
config.build_nested_dict(
self.manager, self.parent_dict, self.key_name, dict_data
)
# Update the nested config
result = config.update_config_dict(
self.manager, self.parent_dict, self.key_name, test_entry
)
assert result is True
assert isinstance(self.parent_dict[self.key_name], DictProxy)
for k in self.parent_dict[self.key_name].keys():
assert self.parent_dict[self.key_name][k] == test_yaml_data[k]
# delete test yaml file
os.remove(test_entry)
class TestVerifyExperimentConfig(unittest.TestCase):
def setUp(self):
self.manager = Manager()
current_path = os.path.abspath(os.path.dirname(__file__))
root_path = os.path.dirname(os.path.dirname(current_path))
self.config_path = os.path.join(root_path, "src", "navigate", "config")
self.test_root = "test_dir"
os.mkdir(self.test_root)
configuration = config.load_configs(
self.manager,
configuration=os.path.join(self.config_path, "configuration.yaml"),
)
config.verify_configuration(self.manager, configuration)
saving_dict_sample = {
"root_directory": config.get_navigate_path(),
"save_directory": config.get_navigate_path(),
"user": "Kevin",
"tissue": "Lung",
"celltype": "MV3",
"label": "GFP",
"file_type": "TIFF",
"date": time.strftime("%Y-%m-%d"),
"solvent": "BABB",
}
camera_parameters_dict_sample = {
"x_pixels": 2048,
"y_pixels": 2048,
"img_x_pixels": 2048,
"img_y_pixels": 2048,
"sensor_mode": "Normal",
"readout_direction": "Top-to-Bottom",
"number_of_pixels": 10,
"binning": "1x1",
"frames_to_average": 1,
"databuffer_size": 100,
}
# Autofocus
# autofocus_sample = {
# "coarse_range": 500,
# "coarse_step_size": 50,
# "coarse_selected": True,
# "fine_range": 50,
# "fine_step_size": 5,
# "fine_selected": True,
# "robust_fit": False,
# }
stage_parameters_dict_sample = {
"limits": True,
}
for microscope_name in configuration["configuration"]["microscopes"].keys():
stage_parameters_dict_sample[microscope_name] = {}
for k in ["theta_step", "f_step", "z_step"]:
stage_parameters_dict_sample[microscope_name][k] = configuration[
"configuration"
]["microscopes"][microscope_name]["stage"].get(k, 30)
stage_parameters_dict_sample[microscope_name]["xy_step"] = min(
configuration["configuration"]["microscopes"][microscope_name][
"stage"
].get("x_step", 500),
configuration["configuration"]["microscopes"][microscope_name][
"stage"
].get("y_step", 500),
)
microscope_name = configuration["configuration"]["microscopes"].keys()[0]
zoom = configuration["configuration"]["microscopes"][microscope_name]["zoom"][
"position"
].keys()[0]
microscope_parameters_dict_sample = {
"microscope_name": microscope_name,
"image_mode": "live",
"zoom": zoom,
"stack_cycling_mode": "per_stack",
"start_position": 0.0,
"end_position": 100.0,
"step_size": 20.0,
"number_z_steps": 5,
"timepoints": 1,
"stack_pause": 0.0,
"is_save": False,
"stack_acq_time": 1.0,
"timepoint_interval": 0,
"experiment_duration": 1.03,
"is_multiposition": False,
"stack_z_origin": 0,
"stack_focus_origin": 0,
"start_focus": 0.0,
"end_focus": 0.0,
"abs_z_start": 0.0,
"abs_z_end": 100.0,
"waveform_template": "Default",
}
# multipositions_sample = [[10.0, 10.0, 10.0, 10.0, 10.0]]
self.experiment_sample = {
"Saving": saving_dict_sample,
"CameraParameters": camera_parameters_dict_sample,
"StageParameters": stage_parameters_dict_sample,
"MicroscopeState": microscope_parameters_dict_sample,
}
def tearDown(self):
delete_folder(self.test_root)
self.manager.shutdown()
def assert_equal_dict(self, dict1, dict2):
# dict1 and dict2 are not nested dict
for k in dict1.keys():
assert dict1[k] == dict2[k], f"{k}: {dict1[k]} -- {dict2[k]}"
def test_load_empty_experiment_file(self):
experiment_file_path = os.path.join(self.test_root, "experiment.yml")
with open(experiment_file_path, "w") as f:
f.write("")
configuration = config.load_configs(
self.manager,
configuration=os.path.join(self.config_path, "configuration.yaml"),
experiment=experiment_file_path,
)
config.verify_configuration(self.manager, configuration)
config.verify_experiment_config(self.manager, configuration)
experiement_config = configuration["experiment"]
assert type(experiement_config) == DictProxy
# Saving parameters
self.assert_equal_dict(
self.experiment_sample["Saving"], experiement_config["Saving"]
)
# Camera parameters
self.assert_equal_dict(
self.experiment_sample["CameraParameters"],
experiement_config["CameraParameters"],
)
# AutoFocusParameters
# Stage parameters
for k, value in self.experiment_sample["StageParameters"].items():
if type(value) == dict:
assert k in experiement_config["StageParameters"].keys()
self.assert_equal_dict(value, experiement_config["StageParameters"][k])
else:
assert value == experiement_config["StageParameters"][k]
# MicroscopeState parameters
self.assert_equal_dict(
self.experiment_sample["MicroscopeState"],
experiement_config["MicroscopeState"],
)
# # MultiPositions
# for i, position in enumerate(self.experiment_sample["MultiPositions"]):
# assert position == experiement_config["MultiPositions"][i]
def test_load_experiment_file_with_missing_parameters(self):
experiment = load_yaml_file(os.path.join(self.config_path, "experiment.yml"))
# Saving prameters
saving_parameters = list(self.experiment_sample["Saving"].keys())
saving_parameters_deleted = self.delete_random_entries_from_dict(
saving_parameters, experiment["Saving"]
)
# Camera parameters
camera_parameters = list(self.experiment_sample["CameraParameters"].keys())
camera_parameters.append("img_x_pixels")
camera_parameters.append("img_y_pixels")
camera_parameters_deleted = self.delete_random_entries_from_dict(
camera_parameters, experiment["CameraParameters"]
)
# StageParameters
configuration = load_yaml_file(
os.path.join(self.config_path, "configuration.yaml")
)
# delete limits
if "limits" in experiment["StageParameters"].keys():
del experiment["StageParameters"]["limits"]
# delete part of stage parameters of one microscope
microscope_names = list(configuration["microscopes"].keys())
if microscope_names[0] not in experiment["StageParameters"]:
experiment["StageParameters"][microscope_names[0]] = {
"z_step": 100.0,
"theta_step": 10.0,
}
# delete all stage parameter of another microscope
if microscope_names[1] in experiment["StageParameters"].keys():
del experiment["StageParameters"][microscope_names[1]]
# MicroscopeState
micrscope_parameters = list(self.experiment_sample["MicroscopeState"].keys())
micrscope_parameters.append("channels")
micrscope_parameters_deleted = self.delete_random_entries_from_dict(
micrscope_parameters, experiment["MicroscopeState"]
)
save_yaml_file(self.test_root, experiment, "experiment_missing_parameters.yml")
configuration = config.load_configs(
self.manager,
configuration=os.path.join(self.config_path, "configuration.yaml"),
experiment=os.path.join(
self.test_root, "experiment_missing_parameters.yml"
),
)
config.verify_configuration(self.manager, configuration)
config.verify_experiment_config(self.manager, configuration)
# verify Saving parameters are added
for k in saving_parameters_deleted:
assert (
k in configuration["experiment"]["Saving"].keys()
), f"parameter {k} should be added to Saving parameters"
# verify CameraParameters are added
for k in camera_parameters_deleted:
assert (
k in configuration["experiment"]["CameraParameters"].keys()
), f"parameter {k} should be added into CameraParameters"
# verify MicroscopeState parameters are added
for k in micrscope_parameters_deleted:
assert (
k in configuration["experiment"]["MicroscopeState"].keys()
), f"parameter {k} should be added to MicroscopeState"
# verify Stage parameters are added
assert (
"limits" in configuration["experiment"]["StageParameters"].keys()
), "limits should be added to Stage parameters"
for microscope_name in microscope_names:
for k in ["xy_step", "z_step", "f_step", "theta_step"]:
assert (
k in configuration["experiment"]["StageParameters"][microscope_name]
)
def test_load_experiment_file_with_wrong_parameter_values(self):
configuration = config.load_configs(
self.manager,
configuration=os.path.join(self.config_path, "configuration.yaml"),
experiment=os.path.join(self.config_path, "experiment.yml"),
)
config.verify_configuration(self.manager, configuration)
experiment = configuration["experiment"]
# Saving parameters
# check if root_directory and save_directory exist
experiment["Saving"]["root_directory"] = self.config_path
experiment["Saving"]["save_directory"] = os.path.join(
self.test_root, "not_exist", "not_exist"
)
config.verify_experiment_config(self.manager, configuration)
assert experiment["Saving"]["root_directory"] == self.config_path
assert os.path.exists(experiment["Saving"]["save_directory"])
assert experiment["Saving"]["save_directory"] != os.path.join(
self.test_root, "not_exist", "not_exist"
)
# CameraParameters
# x_pixels, y_pixels
experiment["CameraParameters"]["x_pixels"] = -10
experiment["CameraParameters"]["y_pixels"] = "abcd"
config.verify_experiment_config(self.manager, configuration)
assert (
experiment["CameraParameters"]["x_pixels"]
== self.experiment_sample["CameraParameters"]["x_pixels"]
)
assert (
experiment["CameraParameters"]["y_pixels"]
== self.experiment_sample["CameraParameters"]["y_pixels"]
)
binning = int(experiment["CameraParameters"]["binning"][0])
assert (
experiment["CameraParameters"]["img_x_pixels"]
== experiment["CameraParameters"]["x_pixels"] // binning
)
assert (
experiment["CameraParameters"]["img_y_pixels"]
== experiment["CameraParameters"]["y_pixels"] // binning
)
# binning
for v in ["abcd", "3x3", "12.4"]:
experiment["CameraParameters"]["binning"] = v
config.verify_experiment_config(self.manager, configuration)
assert experiment["CameraParameters"]["binning"] == "1x1"
assert (
experiment["CameraParameters"]["img_x_pixels"]
== experiment["CameraParameters"]["x_pixels"]
)
assert (
experiment["CameraParameters"]["img_y_pixels"]
== experiment["CameraParameters"]["y_pixels"]
)
# sensor_mode
experiment["CameraParameters"]["sensor_mode"] = "None"
config.verify_experiment_config(self.manager, configuration)
assert experiment["CameraParameters"]["sensor_mode"] == "Normal"
experiment["CameraParameters"]["sensor_mode"] = "Lightsheet"
config.verify_experiment_config(self.manager, configuration)
assert experiment["CameraParameters"]["sensor_mode"] == "Normal"
experiment["CameraParameters"]["sensor_mode"] = "Light-Sheet"
config.verify_experiment_config(self.manager, configuration)
assert experiment["CameraParameters"]["sensor_mode"] == "Light-Sheet"
# readout_direction
for v in ["abcd", 123, None]:
experiment["CameraParameters"]["readout_direction"] = v
config.verify_experiment_config(self.manager, configuration)
assert (
experiment["CameraParameters"]["readout_direction"] == "Top-to-Bottom"
)
experiment["CameraParameters"]["readout_direction"] = "Bottom-to-Top"
config.verify_experiment_config(self.manager, configuration)
assert experiment["CameraParameters"]["readout_direction"] == "Bottom-to-Top"
# other parameters should be int
for k in ["number_of_pixels", "databuffer_size", "frames_to_average"]:
for v in ["abc", -10, 0]:
experiment["CameraParameters"][k] = v
config.verify_experiment_config(self.manager, configuration)
assert (
experiment["CameraParameters"][k]
== self.experiment_sample["CameraParameters"][k]
)
# StageParameters
experiment["StageParameters"]["limits"] = "abc"
config.verify_experiment_config(self.manager, configuration)
assert experiment["StageParameters"]["limits"] is True
microscope_names = list(configuration["configuration"]["microscopes"].keys())
for microscope_name in microscope_names:
for k in ["xy_step", "z_step", "f_step", "theta_step"]:
experiment["StageParameters"][microscope_name][k] = "abc"
config.verify_experiment_config(self.manager, configuration)
assert isinstance(
experiment["StageParameters"][microscope_name][k], int
)
# MicroscopeState
experiment["MicroscopeState"]["microscope_name"] = "nonexist_microscope"
config.verify_experiment_config(self.manager, configuration)
assert experiment["MicroscopeState"]["microscope_name"] == microscope_names[0]
experiment["MicroscopeState"]["zoom"] = "abc"
config.verify_experiment_config(self.manager, configuration)
assert (
experiment["MicroscopeState"]["zoom"]
== list(
configuration["configuration"]["microscopes"][microscope_names[0]][
"zoom"
]["position"].keys()
)[0]
)
for k in [
"start_position",
"end_position",
"step_size",
"number_z_steps",
"timepoints",
"stack_acq_time",
"timepoint_interval",
"experiment_duration",
"stack_z_origin",
"stack_focus_origin",
"start_focus",
"end_focus",
"abs_z_start",
"abs_z_end",
]:
experiment["MicroscopeState"][k] = "nonsense_value"
config.verify_experiment_config(self.manager, configuration)
assert isinstance(experiment["MicroscopeState"][k], int) or isinstance(
experiment["MicroscopeState"][k], float
)
# channels
experiment["MicroscopeState"]["channels"] = [
{
"is_selected": True,
"laser": "488nm",
"laser_index": 0,
"filter": "Empty-Alignment",
"filter_position": 0,
"camera_exposure_time": 200.0,
"laser_power": 20.0,
"interval_time": 5.0,
"defocus": 0.0,
}
]
# number_of_filter_wheels =
config.verify_experiment_config(self.manager, configuration)
assert type(experiment["MicroscopeState"]["channels"]) is DictProxy
assert len(list(experiment["MicroscopeState"]["channels"].keys())) == 0
experiment["MicroscopeState"]["channels"] = {
"channel_0": {
"is_selected": True,
"laser": "488nm",
"laser_index": 0,
"filter": "Empty-Alignment",
"filter_position": 0,
"camera_exposure_time": 200.0,
"laser_power": 20.0,
"interval_time": 5.0,
"defocus": 0.0,
}
}
config.verify_experiment_config(self.manager, configuration)
assert type(experiment["MicroscopeState"]["channels"]) is DictProxy
assert len(list(experiment["MicroscopeState"]["channels"].keys())) == 0
experiment["MicroscopeState"]["channels"] = {
"channel_100": {
"is_selected": True,
"laser": "488nm",
"laser_index": 0,
"filter": "Empty-Alignment",
"filter_position": 0,
"camera_exposure_time": 200.0,
"laser_power": 20.0,
"interval_time": 5.0,
"defocus": 0.0,
}
}
config.verify_experiment_config(self.manager, configuration)
assert type(experiment["MicroscopeState"]["channels"]) is DictProxy
assert len(list(experiment["MicroscopeState"]["channels"].keys())) == 0
microscope_name = experiment["MicroscopeState"]["microscope_name"]
lasers = [
f"{laser['wavelength']}nm"
for laser in configuration["configuration"]["microscopes"][microscope_name][
"laser"
]
]
filterwheels = list(
configuration["configuration"]["microscopes"][microscope_name][
"filter_wheel"
][0]["available_filters"].keys()
)
config.update_config_dict(
self.manager,
experiment["MicroscopeState"]["channels"],
"channel_2",
{
"is_selected": 1,
"laser": "48nm",
"laser_index": -1,
"filter_wheel_0": "nonexsit_filter_***",
"filter_position_0": 1,
"camera_exposure_time": -200.0,
"laser_power": "a",
"interval_time": -3,
"defocus": "a",
},
)
expected_value = {
"is_selected": False,
"laser": lasers[0],
"laser_index": 0,
"filter_wheel_0": filterwheels[0],
"filter_position_0": 0,
"camera_exposure_time": 200.0,
"laser_power": 20.0,
"interval_time": 0.0,
"defocus": 0.0,
}
config.verify_experiment_config(self.manager, configuration)
assert type(experiment["MicroscopeState"]["channels"]) is DictProxy
assert "channel_2" in experiment["MicroscopeState"]["channels"].keys()
for k in expected_value:
assert (
experiment["MicroscopeState"]["channels"]["channel_2"][k]
== expected_value[k]
)
config.update_config_dict(
self.manager,
experiment["MicroscopeState"]["channels"],
"channel_2",
{
"is_selected": 1,
"laser": lasers[1],
"laser_index": 3,
"filter_wheel_0": filterwheels[2],
"filter_position_0": 1,
"camera_exposure_time": -200.0,
"laser_power": "a",
"interval_time": -3,
"defocus": "a",
},
)
expected_value = {
"is_selected": False,
"laser": lasers[1],
"laser_index": 1,
"filter_wheel_0": filterwheels[2],
"filter_position_0": 2,
"camera_exposure_time": 200.0,
"laser_power": 20.0,
"interval_time": 0.0,
"defocus": 0.0,
}
config.verify_experiment_config(self.manager, configuration)
assert type(experiment["MicroscopeState"]["channels"]) is DictProxy
assert "channel_2" in experiment["MicroscopeState"]["channels"].keys()
for k in expected_value:
assert (
experiment["MicroscopeState"]["channels"]["channel_2"][k]
== expected_value[k]
)
def select_random_entries_from_list(self, parameter_list):
n = random.randint(1, len(parameter_list))
return random.choices(parameter_list, k=n)
def delete_random_entries_from_dict(self, parameter_list, parameter_dict):
n = random.randint(1, len(parameter_list))
deleted_parameters = random.choices(parameter_list, k=n)
for k in deleted_parameters:
if k in parameter_dict.keys():
del parameter_dict[k]
return deleted_parameters
def test_load_empty_multi_positions(self):
positions_file_path = os.path.join(self.test_root, "multi_positions.yml")
with open(positions_file_path, "w") as f:
f.write("")
positions = load_yaml_file(positions_file_path)
new_positions = config.verify_positions_config(positions)
assert isinstance(new_positions, list)
assert len(new_positions) == 0
def test_load_multi_positions_with_corrupted_values(self):
positions = [
[1, 2, 3],
["a", "b", "c", 1, 2],
[
10,
"a",
30,
40,
],
]
new_positions = config.verify_positions_config(positions)
assert isinstance(new_positions, list)
assert len(new_positions) == 1
positions = [
[1, 2, 3],
["a", "b", "c", 1, 2],
[1, 2, 3, 4, 5],
[
10,
"a",
30,
40,
],
]
new_positions = config.verify_positions_config(positions)
assert isinstance(new_positions, list)
assert len(new_positions) == 2
positions = [
[1, 2, 3],
["a", "b", "c", 1, 2],
[1, 2, 3, 4, 5],
[
10,
"a",
30,
40,
],
[1, 2, 3, 4, 5, 6],
]
new_positions = config.verify_positions_config(positions)
assert isinstance(new_positions, list)
assert len(new_positions) == 3

View File

@@ -0,0 +1,426 @@
# 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 Imports
import unittest
import yaml
import os
# Third Party Imports
# Local Imports
class TestConfiguration(unittest.TestCase):
def setUp(self):
current_path = os.path.abspath(os.path.dirname(__file__))
root_path = os.path.dirname(os.path.dirname(current_path))
yaml_path = os.path.join(
root_path, "src", "navigate", "config", "configuration.yaml"
)
with open(yaml_path) as file:
self.data = yaml.safe_load(file)
def tearDown(self):
pass
# # hardware head section has been removed
# def test_hardware_section(self):
# expected_hardware = ["daq", "camera", "filter_wheel", "stage", "zoom"]
# hardware_types = self.data["hardware"].keys()
# for hardware_type in hardware_types:
# self.assertIn(hardware_type, expected_hardware)
# if isinstance(self.data["hardware"][hardware_type], dict):
# hardware_keys = self.data["hardware"][hardware_type].keys()
# for key in hardware_keys:
# self.assertIn("type", self.data["hardware"][hardware_type])
# elif isinstance(self.data["hardware"][hardware_type], list):
# for i in range(len(self.data["hardware"][hardware_type])):
# self.assertIn("type", self.data["hardware"][hardware_type][i])
def test_gui_section(self):
expected_keys = ["channels"]
expected_channel_keys = [
"count",
# "laser_power",
# "exposure_time",
# "interval_time",
]
expected_stack_keys = ["step_size", "start_pos", "end_pos"]
min_max_step_keys = ["min", "max", "step"]
gui_keys = self.data["gui"].keys()
for key in gui_keys:
self.assertIn(key, expected_keys)
# Channels Entry
if key == "channels":
channel_keys = self.data["gui"][key].keys()
for channel_key in channel_keys:
self.assertIn(channel_key, expected_channel_keys)
if channel_key != "count":
channel_key_keys = self.data["gui"][key][channel_key].keys()
for channel_key_key in channel_key_keys:
self.assertIn(channel_key_key, min_max_step_keys)
# Stack Acquisition Entry
elif key == "stack_acquisition":
stack_keys = self.data["gui"][key].keys()
for stack_key in stack_keys:
self.assertIn(stack_key, expected_stack_keys)
stack_key_keys = self.data["gui"][key][stack_key].keys()
for stack_key_key in stack_key_keys:
self.assertIn(stack_key_key, min_max_step_keys)
# Timepoint Entry
elif key == "timepoint":
timepoint_keys = self.data["gui"][key].keys()
for timepoint_key in timepoint_keys:
timepoint_key_keys = self.data["gui"][key][timepoint_key].keys()
for timepoint_key_key in timepoint_key_keys:
self.assertIn(timepoint_key_key, min_max_step_keys)
else:
raise ValueError("Unexpected key in gui section")
def test_microscope_section(self):
expected_hardware = [
"daq",
"camera",
"remote_focus",
"galvo",
"shutter",
"laser",
"filter_wheel",
"stage",
"zoom",
]
microscopes = self.data["microscopes"].keys()
for microscope in microscopes:
hardware = self.data["microscopes"][microscope].keys()
for hardware_type in hardware:
self.assertIn(hardware_type, expected_hardware)
if hardware_type == "daq":
self.daq_section(microscope=microscope, hardware_type=hardware_type)
elif hardware_type == "camera":
self.camera_section(
microscope=microscope, hardware_type=hardware_type
)
elif hardware_type == "remote_focus":
self.remote_focus_section(
microscope=microscope, hardware_type=hardware_type
)
elif hardware_type == "galvo":
self.galvo_section(
microscope=microscope, hardware_type=hardware_type
)
elif hardware_type == "filter_wheel":
self.filter_wheel_section(
microscope=microscope, hardware_type=hardware_type
)
elif hardware_type == "stage":
self.stage_section(
microscope=microscope, hardware_type=hardware_type
)
elif hardware_type == "zoom":
self.zoom_section(
microscope=microscope, hardware_type=hardware_type
)
elif hardware_type == "shutter":
self.shutter_section(
microscope=microscope, hardware_type=hardware_type
)
elif hardware_type == "laser":
self.laser_section(
microscope=microscope, hardware_type=hardware_type
)
else:
raise ValueError("Unexpected hardware type")
def daq_section(self, microscope, hardware_type):
expected_daq_keys = [
"hardware",
"sample_rate",
"sweep_time",
"master_trigger_out_line",
"camera_trigger_out_line",
"trigger_source",
"laser_port_switcher",
"laser_switch_state",
]
type_keys = ["type"]
daq_keys = self.data["microscopes"][microscope][hardware_type].keys()
for key in daq_keys:
if key == "hardware":
for type_key in type_keys:
self.assertIn(
type_key,
self.data["microscopes"][microscope][hardware_type][key],
)
else:
self.assertIn(key, expected_daq_keys)
def camera_section(self, microscope, hardware_type):
expected_keys = [
"hardware",
"defect_correct_mode",
"delay",
"settle_down",
"flip_x",
"flip_y",
]
type_keys = ["type", "serial_number", "camera_connection"]
camera_keys = self.data["microscopes"][microscope][hardware_type].keys()
for key in camera_keys:
if key == "hardware":
for type_key in type_keys:
self.assertIn(
type_key,
self.data["microscopes"][microscope][hardware_type][key],
)
else:
self.assertIn(key, expected_keys)
def remote_focus_section(self, microscope, hardware_type):
expected_keys = [
"hardware",
]
type_keys = ["type", "channel", "min", "max", "port", "baudrate"]
remote_focus_keys = self.data["microscopes"][microscope][hardware_type].keys()
for key in remote_focus_keys:
if key == "hardware":
for type_key in type_keys:
self.assertIn(
type_key,
self.data["microscopes"][microscope][hardware_type][key],
)
else:
self.assertIn(key, expected_keys)
def galvo_section(self, microscope, hardware_type):
expected_keys = [
"hardware",
"waveform",
"phase",
]
type_keys = ["type", "channel", "min", "max"]
if isinstance(self.data["microscopes"][microscope][hardware_type], list):
for i in range(len(self.data["microscopes"][microscope][hardware_type])):
galvo_keys = self.data["microscopes"][microscope][hardware_type][
i
].keys()
for key in galvo_keys:
if key == "hardware":
for type_key in type_keys:
self.assertIn(
type_key,
self.data["microscopes"][microscope][hardware_type][i][
key
],
)
else:
self.assertIn(key, expected_keys)
else:
raise ValueError("Galvo section is not a list")
def filter_wheel_section(self, microscope, hardware_type):
expected_keys = [
"hardware",
"filter_wheel_delay",
"available_filters",
]
type_keys = ["type", "wheel_number", "port", "baudrate"]
keys = self.data["microscopes"][microscope][hardware_type].keys()
for key in keys:
if key == "hardware":
for type_key in type_keys:
self.assertIn(
type_key,
self.data["microscopes"][microscope][hardware_type][key],
)
elif key == "available_filters":
assert isinstance(
self.data["microscopes"][microscope][hardware_type][key], dict
)
else:
self.assertIn(key, expected_keys)
def stage_section(self, microscope, hardware_type):
expected_keys = [
"hardware",
"x_max",
"x_min",
"y_max",
"y_min",
"z_max",
"z_min",
"f_max",
"f_min",
"theta_max",
"theta_min",
"x_offset",
"y_offset",
"z_offset",
"theta_offset",
"f_offset",
"joystick_axes",
"flip_x",
"flip_y",
"flip_z",
"flip_f",
]
type_keys = [
"type",
"serial_number",
"axes",
"volts_per_micron",
"axes_mapping",
"max",
"min",
"controllername",
"stages",
"refmode",
"port",
"baudrate",
"timeout",
]
for key in self.data["microscopes"][microscope][hardware_type].keys():
if key == "hardware":
if isinstance(
self.data["microscopes"][microscope][hardware_type][key], list
):
for i in range(
len(self.data["microscopes"][microscope][hardware_type][key])
):
for type_key in type_keys:
self.assertIn(
type_key,
self.data["microscopes"][microscope][hardware_type][
key
][i],
)
elif isinstance(
self.data["microscopes"][microscope][hardware_type][key], dict
):
for type_key in type_keys:
self.assertIn(
type_key,
self.data["microscopes"][microscope][hardware_type][key],
)
else:
raise ValueError("Stage hardware is not a list or dict")
else:
self.assertIn(key, expected_keys)
def zoom_section(self, microscope, hardware_type):
expected_keys = ["hardware", "position", "pixel_size", "stage_positions"]
type_keys = ["type", "servo_id", "port", "baudrate"]
for key in self.data["microscopes"][microscope][hardware_type].keys():
if key == "hardware":
for type_key in type_keys:
self.assertIn(
type_key,
self.data["microscopes"][microscope][hardware_type][key],
)
elif key == "position":
assert isinstance(
self.data["microscopes"][microscope][hardware_type][key], dict
)
elif key == "pixel_size":
assert isinstance(
self.data["microscopes"][microscope][hardware_type][key], dict
)
elif key == "stage_positions":
assert isinstance(
self.data["microscopes"][microscope][hardware_type][key], dict
)
else:
self.assertIn(key, expected_keys)
def shutter_section(self, microscope, hardware_type):
expected_keys = ["hardware"]
type_keys = ["type", "channel", "min", "max"]
for key in self.data["microscopes"][microscope][hardware_type].keys():
if key == "hardware":
for type_key in type_keys:
self.assertIn(
type_key,
self.data["microscopes"][microscope][hardware_type][key],
)
else:
self.assertIn(key, expected_keys)
def laser_section(self, microscope, hardware_type):
expected_keys = [
"wavelength",
"onoff",
"power",
"type",
]
hardware_keys = ["type", "channel", "min", "max"]
if isinstance(self.data["microscopes"][microscope][hardware_type], list):
for i in range(len(self.data["microscopes"][microscope][hardware_type])):
laser_keys = self.data["microscopes"][microscope][hardware_type][
i
].keys()
for key in laser_keys:
if key == "onoff" or key == "power":
onoff_keys = self.data["microscopes"][microscope][
hardware_type
][i][key]["hardware"].keys()
for onoff_key in onoff_keys:
self.assertIn(onoff_key, hardware_keys)
else:
self.assertIn(key, expected_keys)
else:
raise ValueError("Laser section is not a list")

View File

@@ -0,0 +1,203 @@
# 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 Imports
import unittest
import yaml
import os
import datetime
# Third Party Imports
# Local Imports
class TextExperimentFile(unittest.TestCase):
"""Test the experiment configuration file."""
def setUp(self):
current_path = os.path.abspath(os.path.dirname(__file__))
root_path = os.path.dirname(os.path.dirname(current_path))
yaml_path = os.path.join(
root_path, "src", "navigate", "config", "experiment.yml"
)
with open(yaml_path) as file:
self.data = yaml.safe_load(file)
def tearDown(self):
pass
def parse_entries(self, section, expected_values):
"""Parse the entries in the configuration file.
Parameters
----------
section : str
The section of the configuration file to parse
expected_values : dict
A dictionary of expected values for the section
Raises
------
AssertionError
If the key is not in the section or if the value is not the expected type
"""
keys = self.data[section].keys()
for key in keys:
self.assertIn(key, expected_values)
assert isinstance(
self.data[section][key], expected_values[key]
), f"{key} is not of type {expected_values[key]}"
def test_user(self):
expected_values = {"name": str}
self.parse_entries(section="User", expected_values=expected_values)
def test_saving(self):
expected_values = {
"root_directory": str,
"save_directory": str,
"user": str,
"tissue": str,
"celltype": str,
"label": str,
"file_type": str,
"date": datetime.date,
"solvent": str,
}
self.parse_entries(section="Saving", expected_values=expected_values)
def test_camera_parameters(self):
expected_values = {
"x_pixels": int,
"y_pixels": int,
"sensor_mode": str,
"readout_direction": str,
"number_of_pixels": int,
"binning": str,
"pixel_size": float,
"frames_to_average": float,
"databuffer_size": int,
}
self.parse_entries(section="CameraParameters", expected_values=expected_values)
def test_autofocus_parameters(self):
expected_values = {
"coarse_range": int,
"coarse_step_size": int,
"coarse_selected": int,
"fine_range": int,
"fine_step_size": int,
"fine_selected": bool,
"robust_fit": bool,
"spline_fit": bool,
"test_significance": bool,
}
self.parse_entries(
section="AutoFocusParameters", expected_values=expected_values
)
def test_stage_parameters(self):
expected_values = {
"xy_step": float,
"z_step": float,
"theta_step": float,
"f_step": float,
"x": float,
"y": float,
"z": float,
"theta": float,
"f": float,
"limits": bool,
}
self.parse_entries(section="StageParameters", expected_values=expected_values)
def test_microscope_state(self):
expected_values = {
"microscope_name": str,
"image_mode": str,
"zoom": str,
"stack_cycling_mode": str,
"start_position": float,
"end_position": float,
"step_size": float,
"number_z_steps": float,
"timepoints": int,
"stack_pause": float,
"is_save": bool,
"stack_acq_time": float,
"timepoint_interval": int,
"experiment_duration": float,
"is_multiposition": bool,
"channels": dict,
"stack_z_origin": float,
"stack_focus_origin": float,
"start_focus": float,
"end_focus": float,
"abs_z_start": float,
"abs_z_end": float,
"waveform_template": str,
}
self.parse_entries(section="MicroscopeState", expected_values=expected_values)
# Check that the channels dictionary has the correct keys
channel_keys = self.data["MicroscopeState"]["channels"].keys()
for key in channel_keys:
# Number of channels can vary depending upon the experiment.
assert "channel_" in key
expected_values = {
"is_selected": bool,
"laser": str,
"laser_index": int,
"filter": str,
"filter_position": int,
"camera_exposure_time": float,
"laser_power": str,
"interval_time": str,
"defocus": float,
}
key_keys = self.data["MicroscopeState"]["channels"][key].keys()
for key_key in key_keys:
self.assertIn(key_key, expected_values)
assert isinstance(
self.data["MicroscopeState"]["channels"][key][key_key],
expected_values[key_key],
), f"{key_key} is not of type {expected_values[key_key]}"

View File

@@ -0,0 +1,62 @@
# 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 Imports
import unittest
import yaml
import os
# Third Party Imports
# Local Imports
class TestAPIConfiguration(unittest.TestCase):
def setUp(self):
current_path = os.path.abspath(os.path.dirname(__file__))
root_path = os.path.dirname(os.path.dirname(current_path))
yaml_path = os.path.join(
root_path, "src", "navigate", "config", "rest_api_config.yml"
)
with open(yaml_path) as file:
self.data = yaml.safe_load(file)
def tearDown(self):
pass
def test_api_config(self):
keys = self.data["Ilastik"].keys()
self.assertIn("url", keys)
ilastik_path = self.data["Ilastik"]["url"]
assert "http" in ilastik_path, "Ilastik path not found in config file."
assert "ilastik" in ilastik_path, "Ilastik path not found in config file."

View File

@@ -0,0 +1,110 @@
# 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 Imports
import unittest
import yaml
import os
# Third Party Imports
# Local Imports
class TestWaveformConstants(unittest.TestCase):
def test_yaml_structure(self):
current_path = os.path.abspath(os.path.dirname(__file__))
root_path = os.path.dirname(os.path.dirname(current_path))
yaml_path = os.path.join(
root_path, "src", "navigate", "config", "waveform_constants.yml"
)
# Load the YAML file
with open(yaml_path) as file:
data = yaml.safe_load(file)
expected_keys = ["amplitude", "offset"]
microscope_names = data["remote_focus_constants"].keys()
for microscope_name in microscope_names:
assert isinstance(microscope_name, str)
magnifications = data["remote_focus_constants"][microscope_name].keys()
for magnification in magnifications:
assert isinstance(magnification, str)
wavelengths = data["remote_focus_constants"][microscope_name][
magnification
].keys()
for wavelength in wavelengths:
assert isinstance(wavelength, str)
for key in expected_keys:
assert (
key
in data["remote_focus_constants"][microscope_name][
magnification
][wavelength].keys()
)
expected_keys = ["amplitude", "offset", "frequency"]
galvos = data["galvo_constants"].keys()
for galvo in galvos:
assert isinstance(galvo, str)
for microscope_name in microscope_names:
assert microscope_name in data["galvo_constants"][galvo].keys()
magnifications = data["galvo_constants"][galvo][microscope_name].keys()
for magnification in magnifications:
assert (
magnification
in data["galvo_constants"][galvo][microscope_name].keys()
)
for key in expected_keys:
assert (
key
in data["galvo_constants"][galvo][microscope_name][
magnification
].keys()
)
other_constants = data["other_constants"].keys()
assert "remote_focus_settle_duration" in other_constants
# assert "percent_smoothing" in other_constants
# assert "remote_focus_delay" in other_constants
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,64 @@
# 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 Imports
import unittest
import yaml
import os
# Third Party Imports
# Local Imports
class TestWaveformTemplates(unittest.TestCase):
def test_yaml_structure(self):
current_path = os.path.abspath(os.path.dirname(__file__))
root_path = os.path.dirname(os.path.dirname(current_path))
yaml_path = os.path.join(
root_path, "src", "navigate", "config", "waveform_templates.yml"
)
with open(yaml_path) as file:
data = yaml.safe_load(file)
expected_keys = ["Default", "Confocal-Projection", "Bidirectional"]
waveform_keys = data.keys()
for key in waveform_keys:
assert key in expected_keys
for subkey in data[key].keys():
assert subkey in ["repeat", "expand"]
if __name__ == "__main__":
unittest.main()

174
test/conftest.py Normal file
View File

@@ -0,0 +1,174 @@
"""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 tkinter as tk
# Third Party Imports
import pytest
# Local Imports
@pytest.fixture(scope="package")
def dummy_model():
"""Dummy model for testing.
Returns
-------
DummyModel
Dummy model for testing.
"""
from test.model.dummy import DummyModel
model = DummyModel()
return model
@pytest.fixture(scope="session")
def tk_root():
root = tk.Tk()
yield root
root.destroy()
@pytest.fixture(scope="session")
def dummy_view(tk_root):
"""Dummy view for testing.
Creates a dummy view for the controller tests.
Will be deleted post test session
Returns:
tkinter.Tk: Dummy view
"""
from navigate.view.main_application_window import MainApp
view = MainApp(tk_root)
tk_root.update()
yield view
@pytest.fixture(scope="package")
def dummy_controller(dummy_view):
"""Dummy controller for testing.
Fixture that will mock controller functions called by sub controllers
Returns
-------
DummyController
Dummy controller for testing.
"""
from test.model.dummy import DummyController
controller = DummyController(dummy_view)
return controller
# @pytest.fixture(scope="package")
# def root():
# import tkinter as tk
# root = tk.Tk()
# yield root
# root.destroy()
# @pytest.fixture(scope="package")
# def splash_screen(root):
# from navigate.view.splash_screen import SplashScreen
# splash_screen = SplashScreen(root, "./icon/splash_screen_image.png")
# return splash_screen
# @pytest.fixture(scope="package")
# def controller(root, splash_screen):
# from types import SimpleNamespace
# from pathlib import Path
# from navigate.controller.controller import Controller
# # Use configuration files that ship with the code base
# configuration_directory = Path.joinpath(
# Path(__file__).resolve().parent.parent, "src", "navigate", "config"
# )
# configuration_path = Path.joinpath(configuration_directory, "configuration.yaml")
# experiment_path = Path.joinpath(configuration_directory, "experiment.yml")
# waveform_constants_path = Path.joinpath(configuration_directory,
# "waveform_constants.yml")
# rest_api_path = Path.joinpath(configuration_directory, "rest_api_config.yml")
# controller = Controller(
# root,
# splash_screen,
# configuration_path,
# experiment_path,
# waveform_constants_path,
# rest_api_path,
# False,
# SimpleNamespace(synthetic_hardware=True),
# )
# yield controller
# controller.execute("exit")
# @pytest.fixture(scope="package")
# def model(controller):
# return controller.model
class IgnoreObj:
def __init__(self):
pass
def __getattr__(self, __name: str):
return self
def __call__(self, *args, **kwargs):
pass
def __setattr__(self, __name: str, __value):
pass
def __getitem__(self, __key: str):
return self
def __setitem__(self, __key: str, __value):
pass
@pytest.fixture(scope="package")
def ignore_obj():
return IgnoreObj()

View File

View File

@@ -0,0 +1,617 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
import tkinter
# 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
from unittest.mock import MagicMock
# Third party imports
import pytest
# Local imports
from navigate.controller.sub_controllers import AcquireBarController
from navigate.view.popups.acquire_popup import (
AcquirePopUp,
)
from navigate.model.data_sources import FILE_TYPES
class TestAcquireBarController:
"""Tests for the AcquireBarController class"""
@pytest.fixture(autouse=True)
def setup_class(self, dummy_controller):
"""Setup for the TestAcquireBarController class
Parameters
----------
dummy_controller : DummyController
Instance of the DummyController class
"""
c = dummy_controller
v = dummy_controller.view
self.acquire_bar_controller = AcquireBarController(
view=v.acquire_bar, parent_controller=c
)
self.acquire_bar_controller.populate_experiment_values()
c.channels_tab_controller.populate_experiment_values()
c.camera_setting_controller = MagicMock()
def test_init(self):
"""Tests the initialization of the AcquireBarController class
Raises
------
AssertionError
If the initialization of the AcquireBarController class is not correct
"""
assert isinstance(self.acquire_bar_controller, AcquireBarController)
def test_attr(self):
"""Tests the attributes of the AcquireBarController class
Raises
------
AssertionError
If the attributes of the AcquireBarController class are not correct
"""
# Listing off attributes to check existence
attrs = ["mode", "is_save", "mode_dict"]
for attr in attrs:
assert hasattr(self.acquire_bar_controller, attr)
@pytest.mark.parametrize(
"mode,mode_expected,value_expected",
[
("live", "indeterminate", None),
("single", "determinate", 0),
("customized", "indeterminate", None),
("z-stack", "determinate", 0),
],
)
def test_progress_bar(self, mode, mode_expected, value_expected):
"""Tests the progress bar of the AcquireBarController class
Parameters
----------
mode : str
Mode of the progress bar
mode_expected : str
Expected mode of the progress bar
value_expected : int
Expected value of the progress bar
Raises
------
AssertionError
If the progress bar of the AcquireBarController class is not correct
"""
# Startup progress bars
images_received = 0
mode = mode
stop = False
self.acquire_bar_controller.progress_bar(
images_received=images_received,
microscope_state=self.acquire_bar_controller.parent_controller.configuration[
"experiment"
][
"MicroscopeState"
],
mode=mode,
stop=stop,
)
progress_mode = str(self.acquire_bar_controller.view.CurAcq["mode"])
ovr_mode = str(self.acquire_bar_controller.view.OvrAcq["mode"])
assert progress_mode == mode_expected, (
f"Wrong progress bar mode ({progress_mode}) "
f"relative to microscope mode ({mode})"
)
assert ovr_mode == mode_expected, (
f"Wrong progress bar mode ({progress_mode}) "
f"relative to microscope mode ({mode})"
)
if value_expected is not None:
progress_start = int(self.acquire_bar_controller.view.CurAcq["value"])
ovr_start = int(self.acquire_bar_controller.view.OvrAcq["value"])
assert (
progress_start == value_expected
), "Wrong starting value for progress bar"
assert ovr_start == value_expected, "Wrong starting value for progress bar"
# Updating progress bar
images_received = 1
while images_received < 6:
self.acquire_bar_controller.progress_bar(
images_received=images_received,
microscope_state=self.acquire_bar_controller.parent_controller.configuration[
"experiment"
][
"MicroscopeState"
],
mode=mode,
stop=stop,
)
making_progress = float(self.acquire_bar_controller.view.CurAcq["value"])
ovr_progress = float(self.acquire_bar_controller.view.OvrAcq["value"])
assert (
making_progress > 0
), f"Progress bar should be moving in {mode} mode (making_progress)"
assert (
ovr_progress > 0
), f"Progress bar should be moving in {mode} mode (ovr_progress)"
images_received += 1
# Stopping progress bar
self.acquire_bar_controller.progress_bar(
images_received=images_received,
microscope_state=self.acquire_bar_controller.parent_controller.configuration[
"experiment"
][
"MicroscopeState"
],
mode=mode,
stop=True,
)
after_stop = float(self.acquire_bar_controller.view.CurAcq["value"])
after_ovr = float(self.acquire_bar_controller.view.OvrAcq["value"])
assert after_stop == 0, "Progress Bar did not stop"
assert after_ovr == 0, "Progress Bar did not stop"
@pytest.mark.parametrize("mode", ["live", "single", "z-stack", "customized"])
def test_get_set_mode(self, mode):
"""Tests the get_mode and set_mode methods of the AcquireBarController class
Parameters
----------
mode : str
Mode of the progress bar
Raises
------
AssertionError
If the get_mode and set_mode methods of the
AcquireBarController class are not correct
"""
self.acquire_bar_controller.set_mode(mode)
test = self.acquire_bar_controller.get_mode()
assert test == mode, "Mode not set correctly"
# assert imaging mode is updated in the experiment
assert (
self.acquire_bar_controller.parent_controller.configuration["experiment"][
"MicroscopeState"
]["image_mode"]
== mode
)
def test_set_save(self):
"""Tests the set_save method of the AcquireBarController class
Raises
------
AssertionError
If the set_save method of the AcquireBarController class is not correct
"""
# Assuming save state starts as False
self.acquire_bar_controller.set_save_option(True)
assert self.acquire_bar_controller.is_save is True, "Save option not correct"
# Return value to False
self.acquire_bar_controller.set_save_option(False)
assert (
self.acquire_bar_controller.is_save is False
), "Save option did not return to original value"
def test_stop_acquire(self):
"""Tests the stop_acquire method of the AcquireBarController class
Raises
------
AssertionError
If the stop_acquire method of the AcquireBarController class is not correct
"""
# Stopping acquisition
self.acquire_bar_controller.stop_acquire()
assert self.acquire_bar_controller.view.acquire_btn["text"] == "Acquire"
@pytest.mark.parametrize(
"user_mode,expected_mode",
[
("Continuous Scan", "live"),
("Z-Stack", "z-stack"),
("Single Acquisition", "single"),
("Customized", "customized"),
],
)
def test_update_microscope_mode(self, user_mode, expected_mode):
"""Tests the update_microscope_mode method of the AcquireBarController class
Parameters
----------
user_mode : str
Mode of the progress bar
expected_mode : str
Expected state of the progress bar
Raises
------
AssertionError
If the update_microscope_mode method of
the AcquireBarController class is not correct
"""
# Assuming mode starts on live
self.acquire_bar_controller.mode = "live"
# Setting to mode specified by user
self.acquire_bar_controller.view.pull_down.set(user_mode)
# Generate event that calls update microscope mode
self.acquire_bar_controller.view.pull_down.event_generate(
"<<ComboboxSelected>>"
)
# Checking that new mode gets set by function
assert self.acquire_bar_controller.mode == expected_mode
assert (
self.acquire_bar_controller.parent_controller.configuration["experiment"][
"MicroscopeState"
]["image_mode"]
== expected_mode
)
# Resetting to live
self.acquire_bar_controller.view.pull_down.set("Continuous Scan")
self.acquire_bar_controller.view.pull_down.event_generate(
"<<ComboboxSelected>>"
)
assert self.acquire_bar_controller.mode == "live"
def test_populate_experiment_values(self):
"""Tests the populate_experiment_values method of the AcquireBarController class
Raises
------
AssertionError
If the populate_experiment_values method
of the AcquireBarController class is not correct
"""
# Calling function to populate values
self.acquire_bar_controller.populate_experiment_values()
# Checking values are what we expect
for key, value in self.acquire_bar_controller.saving_settings.items():
assert (
self.acquire_bar_controller.saving_settings[key]
== self.acquire_bar_controller.parent_controller.configuration[
"experiment"
]["Saving"][key]
)
# Assuming default value in exp file,
# can be altered TODO maybe set default to current date
assert self.acquire_bar_controller.saving_settings["date"] == "2022-06-07"
assert (
self.acquire_bar_controller.mode
== self.acquire_bar_controller.parent_controller.configuration[
"experiment"
]["MicroscopeState"]["image_mode"]
)
@pytest.mark.parametrize(
"text,is_acquiring, save,mode,file_types,choice",
[
("Stop", False, None, "live", [], None),
("Stop", True, None, "live", [], None),
("Acquire", True, True, "live", [], None),
("Acquire", False, True, "live", [], None),
("Acquire", False, False, "z-stack", [], None),
("Acquire", False, True, "z-stack", FILE_TYPES, "Done"),
("Acquire", False, True, "z-stack", FILE_TYPES, "Cancel"),
],
)
def test_launch_popup_window(
self, text, is_acquiring, save, mode, file_types, choice
):
"""Tests the launch_popup_window method of the AcquireBarController class
This is the largest test for this controller.
It will test multiple functions that are all used together
and difficult to isolate.
Funcs Tested:
launch_popup_window
update_file_type
launch_acquisition
update_experiment_values
acquire_pop.popup.dismiss # This will be double tested in view
Parameters
----------
text : str
Text of the button that is clicked
save : bool
Whether or not to save the image
mode : str
Mode of the progress bar
file_types : list
List of file types to save as
choice : str
Choice of the user in the popup window
Raises
------
AssertionError
If the launch_popup_window method of the
AcquireBarController class is not correct
"""
# Setup Gui for test
self.acquire_bar_controller.view.acquire_btn.configure(state="normal")
self.acquire_bar_controller.view.acquire_btn.configure(text=text)
self.acquire_bar_controller.is_save = save
self.acquire_bar_controller.set_mode(mode)
self.acquire_bar_controller.is_acquiring = is_acquiring
# Test based on setup, launches popup
self.acquire_bar_controller.view.acquire_btn.invoke()
# Checking things are what we expect
if text == "Stop":
assert self.acquire_bar_controller.view.acquire_btn["text"] == "Stop"
if is_acquiring:
assert (
str(self.acquire_bar_controller.view.acquire_btn["state"])
== "disabled"
)
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "stop_acquire"
else:
assert (
str(self.acquire_bar_controller.view.acquire_btn["state"])
== "normal"
)
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "Empty command list"
if text == "Acquire":
if is_acquiring:
assert (
str(self.acquire_bar_controller.view.acquire_btn["state"])
== "normal"
)
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "Empty command list"
return
# First scenario Save is on and in live mode
if save is True and mode == "live":
assert self.acquire_bar_controller.view.acquire_btn["text"] == "Acquire"
assert (
str(self.acquire_bar_controller.view.acquire_btn["state"])
== "disabled"
)
res = self.acquire_bar_controller.parent_controller.pop()
print(res)
print(self.acquire_bar_controller.parent_controller.pop())
assert res == "acquire"
# Second scenario Save is off and mode is not live
if save is False and mode != "live":
assert self.acquire_bar_controller.view.acquire_btn["text"] == "Acquire"
assert (
str(self.acquire_bar_controller.view.acquire_btn["state"])
== "disabled"
)
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "acquire"
# Third and final scenario Save is on and mode is not live
if save is True and mode != "live":
# Checking if popup created
assert isinstance(self.acquire_bar_controller.acquire_pop, AcquirePopUp)
assert self.acquire_bar_controller.acquire_pop.popup.winfo_exists() == 1
# Testing update_file_type if list exists
widgets = self.acquire_bar_controller.acquire_pop.get_widgets()
if len(file_types) > 0:
for file in file_types:
widgets["file_type"].set(file)
assert (
self.acquire_bar_controller.saving_settings["file_type"]
== file
)
# Resetting file type back to orginal
widgets["file_type"].set("TIFF")
assert (
self.acquire_bar_controller.saving_settings["file_type"]
== "TIFF"
)
# Check that loop thru saving settings is correct
for k, v in self.acquire_bar_controller.saving_settings.items():
if widgets.get(k, None):
value = widgets[k].get().strip()
assert value == v
# Grabbing buttons to test
buttons = self.acquire_bar_controller.acquire_pop.get_buttons()
if choice == "Cancel":
# Testing cancel button
buttons["Cancel"].invoke() # Call to dismiss popup
# Check toplevel gone
assert (
self.acquire_bar_controller.acquire_pop.popup.winfo_exists()
== 0
)
assert (
str(self.acquire_bar_controller.view.acquire_btn["state"])
== "normal"
)
elif choice == "Done":
# Testing done button
# Update experiment values test
# Changing popup vals to test update
# experiment values inside launch acquisition
widgets["user"].set("John")
widgets["tissue"].set("Heart")
widgets["celltype"].set("34T")
widgets["label"].set("BCB")
widgets["solvent"].set("uDISCO")
widgets["file_type"].set("OME-TIFF")
# Tab frame
for i in range(100):
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"misc"
].insert(tkinter.END, f"L{i}")
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"shear_data"
].set(True)
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"shear_dimension"
].set("XZ")
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"shear_angle"
].set(45)
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"rotate_data"
].set(True)
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"rotate_angle_x"
].set(90)
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"rotate_angle_y"
].set(90)
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"rotate_angle_z"
].set(90)
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"down_sample_data"
].set(True)
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"lateral_down_sample"
].set("2x")
self.acquire_bar_controller.acquire_pop.tab_frame.inputs[
"axial_down_sample"
].set("2x")
# Launch acquisition start/test
buttons["Done"].invoke() # Call to launch acquisition
# Check if update experiment values works correctly
pop_vals = self.acquire_bar_controller.acquire_pop.get_variables()
for k, v in self.acquire_bar_controller.saving_settings.items():
if pop_vals.get(k, None):
value = pop_vals[k].strip()
assert value == v
# Check command sent to controller
# and if acquire button changed to Stop
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "acquire_and_save"
assert (
str(self.acquire_bar_controller.view.acquire_btn["state"])
== "disabled"
)
assert (
self.acquire_bar_controller.acquire_pop.popup.winfo_exists()
== 0
)
def test_frequent_start_and_stop_acquisition(self):
# set up
self.acquire_bar_controller.view.acquire_btn.configure(state="normal")
self.acquire_bar_controller.view.acquire_btn.configure(text="Acquire")
self.acquire_bar_controller.is_save = False
self.acquire_bar_controller.set_mode("live")
self.acquire_bar_controller.is_acquiring = False
# start acquisition
self.acquire_bar_controller.view.acquire_btn.invoke()
assert self.acquire_bar_controller.view.acquire_btn["text"] == "Acquire"
assert str(self.acquire_bar_controller.view.acquire_btn["state"]) == "disabled"
assert self.acquire_bar_controller.is_acquiring is True
# assert dummy_controller_to_test_acquire_bar.acquisition_count == 1
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "acquire"
# not in acquisition, click the "Acquire" button several times
self.acquire_bar_controller.view.acquire_btn.invoke()
assert self.acquire_bar_controller.view.acquire_btn["text"] == "Acquire"
assert str(self.acquire_bar_controller.view.acquire_btn["state"]) == "disabled"
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "Empty command list"
self.acquire_bar_controller.view.acquire_btn.invoke()
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "Empty command list"
# in acquisition, click "Stop" button several times
self.acquire_bar_controller.view.acquire_btn.configure(state="normal")
self.acquire_bar_controller.view.acquire_btn.configure(text="Stop")
self.acquire_bar_controller.is_acquiring = True
self.acquire_bar_controller.view.acquire_btn.invoke()
assert self.acquire_bar_controller.view.acquire_btn["text"] == "Stop"
assert str(self.acquire_bar_controller.view.acquire_btn["state"]) == "disabled"
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "stop_acquire"
self.acquire_bar_controller.view.acquire_btn.invoke()
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "Empty command list"
self.acquire_bar_controller.view.acquire_btn.invoke()
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "Empty command list"
self.acquire_bar_controller.view.acquire_btn.invoke()
res = self.acquire_bar_controller.parent_controller.pop()
assert res == "Empty command list"

View File

@@ -0,0 +1,202 @@
# 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 application imports
from navigate.controller.sub_controllers import AutofocusPopupController
from navigate.view.popups.autofocus_setting_popup import AutofocusPopup
class TestAutofocusPopupController:
"""Class for testing autofocus popup controller
Methods
-------
test_init()
Tests that the controller is initialized correctly
test_attr()
Tests that the attributes are initialized correctly
test_populate_experiment_values()
Tests that the values are populated correctly
test_update_experiment_values()
Tests that the values are updated correctly
test_start_autofocus()
Tests that the start autofocus function works correctly
test_display_plot()
Tests that the display plot function works correctly
"""
@pytest.fixture(autouse=True)
def setup_class(self, dummy_controller):
"""Setup for testing autofocus popup controller
Parameters
----------
dummy_controller : DummyController
Dummy controller for testing
"""
autofocus_popup = AutofocusPopup(dummy_controller.view)
self.autofocus_controller = AutofocusPopupController(
autofocus_popup, dummy_controller
)
def test_init(self):
"""Tests that the controller is initialized correctly
Raises
------
AssertionError
If the controller is not initialized correctly
"""
assert isinstance(self.autofocus_controller, AutofocusPopupController)
assert self.autofocus_controller.view.popup.winfo_exists() == 1
def test_attr(self):
"""Tests that the attributes are initialized correctly
Raises
------
AssertionError
If the attributes are not initialized correctly
"""
# Listing off attributes to check existence
attrs = [
"autofocus_fig",
"autofocus_coarse",
"widgets",
"setting_dict",
]
for attr in attrs:
assert hasattr(self.autofocus_controller, attr)
def test_populate_experiment_values(self):
"""Tests that the values are populated correctly
Raises
------
AssertionError
If the values are not populated correctly
"""
microscope_name = self.autofocus_controller.microscope_name
device = self.autofocus_controller.widgets["device"].get()
device_ref = self.autofocus_controller.widgets["device_ref"].get()
for k in self.autofocus_controller.widgets:
if k != "device" and k != "device_ref":
assert self.autofocus_controller.widgets[k].get() == str(
self.autofocus_controller.setting_dict[microscope_name][device][
device_ref
][k]
)
# Some values are ints but Tkinter only uses strings
def test_update_experiment_values(self):
"""Tests that the values are updated correctly
Raises
------
AssertionError
If the values are not updated correctly
"""
# Changing values
self.autofocus_controller.widgets["coarse_range"].set(200)
self.autofocus_controller.widgets["coarse_step_size"].set(30)
self.autofocus_controller.view.setting_vars["coarse_selected"].set(False)
self.autofocus_controller.widgets["fine_range"].set(25)
self.autofocus_controller.widgets["fine_step_size"].set(2)
self.autofocus_controller.view.setting_vars["fine_selected"].set(False)
microscope_name = self.autofocus_controller.microscope_name
device = self.autofocus_controller.widgets["device"].get()
device_ref = self.autofocus_controller.widgets["device_ref"].get()
# Checking values match
for k in self.autofocus_controller.widgets:
if k != "device" and k != "device_ref":
assert self.autofocus_controller.widgets[k].get() == str(
self.autofocus_controller.setting_dict[microscope_name][device][
device_ref
][k]
)
for k in self.autofocus_controller.view.setting_vars:
assert (
self.autofocus_controller.view.setting_vars[k].get()
== self.autofocus_controller.setting_dict[microscope_name][device][
device_ref
][k]
)
def test_start_autofocus(self):
"""Tests that the start autofocus function works correctly
Raises
------
AssertionError
If the start autofocus function does not work correctly
"""
# Calling function
self.autofocus_controller.start_autofocus()
# Checking message sent
res = self.autofocus_controller.parent_controller.pop()
assert res == "autofocus"
self.autofocus_controller.parent_controller.clear()
def test_display_plot(self):
"""Tests that the display plot function works correctly
Todo: Retrieve data from axessubplot instance and
check that it is correct
Raises
------
AssertionError
If the display plot function does not work correctly
"""
# Make this robust by sending data and then
# checking each plot is plotting correct data low priority
x_data = np.linspace(start=69750.0, stop=70250.0, num=101)
y_data = np.random.rand(101)
data = [x_data, y_data]
self.autofocus_controller.display_plot([data, False, True])
pass

View File

@@ -0,0 +1,607 @@
# 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 Imports
# Third Party Imports
import pytest
import random
# Local Imports
from navigate.controller.sub_controllers.camera_settings import (
CameraSettingController,
)
class TestCameraSettingController:
@pytest.fixture(autouse=True)
def setup_class(self, dummy_controller):
c = dummy_controller
v = dummy_controller.view
self.camera_settings = CameraSettingController(
v.settings.camera_settings_tab, c
)
def test_init(self):
assert isinstance(self.camera_settings, CameraSettingController)
# Setup, going to check what the default values widgets are set too
microscope_name = self.camera_settings.parent_controller.configuration[
"experiment"
]["MicroscopeState"]["microscope_name"]
camera_config_dict = self.camera_settings.parent_controller.configuration[
"configuration"
]["microscopes"][microscope_name]["camera"]
# Default Values
assert (
self.camera_settings.default_pixel_size
== camera_config_dict["pixel_size_in_microns"]
)
assert self.camera_settings.default_height == camera_config_dict["y_pixels"]
assert self.camera_settings.default_width == camera_config_dict["x_pixels"]
# Camera Mode
assert list(self.camera_settings.mode_widgets["Sensor"].widget["values"]) == [
"Normal",
"Light-Sheet",
]
assert (
str(self.camera_settings.mode_widgets["Sensor"].widget["state"])
== "readonly"
)
# Readout Mode
assert list(self.camera_settings.mode_widgets["Readout"].widget["values"]) == [
"Top-to-Bottom",
"Bottom-to-Top",
"Bidirectional",
"Rev. Bidirectional",
]
assert (
str(self.camera_settings.mode_widgets["Readout"].widget["state"])
== "disabled"
)
# Pixels
assert (
str(self.camera_settings.mode_widgets["Pixels"].widget["state"])
== "disabled"
)
assert self.camera_settings.mode_widgets["Pixels"].widget.get() == ""
assert self.camera_settings.mode_widgets["Pixels"].widget.cget("from") == 1
assert (
self.camera_settings.mode_widgets["Pixels"].widget.cget("to")
== self.camera_settings.default_height / 2
)
assert self.camera_settings.mode_widgets["Pixels"].widget.cget("increment") == 1
# Framerate
assert (
str(self.camera_settings.framerate_widgets["exposure_time"].widget["state"])
== "disabled"
)
assert (
str(self.camera_settings.framerate_widgets["readout_time"].widget["state"])
== "disabled"
)
assert (
str(self.camera_settings.framerate_widgets["max_framerate"].widget["state"])
== "disabled"
)
# Set range value
assert (
self.camera_settings.roi_widgets["Width"].widget.cget("to")
== self.camera_settings.default_width
)
assert self.camera_settings.roi_widgets["Width"].widget.cget("from") == 2
assert self.camera_settings.roi_widgets["Width"].widget.cget("increment") == 2
assert (
self.camera_settings.roi_widgets["Height"].widget.cget("to")
== self.camera_settings.default_height
)
assert self.camera_settings.roi_widgets["Height"].widget.cget("from") == 2
assert self.camera_settings.roi_widgets["Height"].widget.cget("increment") == 2
# Set binning options
assert list(self.camera_settings.roi_widgets["Binning"].widget["values"]) == [
"{}x{}".format(i, i) for i in [1, 2, 4]
]
assert (
str(self.camera_settings.roi_widgets["Binning"].widget["state"])
== "readonly"
)
# FOV
assert (
str(self.camera_settings.roi_widgets["FOV_X"].widget["state"]) == "disabled"
)
assert (
str(self.camera_settings.roi_widgets["FOV_Y"].widget["state"]) == "disabled"
)
def test_attr(self):
attrs = [
"in_initialization",
"resolution_value",
"mode",
"solvent",
"mode_widgets",
"framerate_widgets",
"roi_widgets",
"roi_btns",
"default_pixel_size",
"default_width",
"default_height",
"pixel_event_id",
]
for attr in attrs:
assert hasattr(self.camera_settings, attr)
def test_populate_experiment_values(self):
microscope_name = self.camera_settings.parent_controller.configuration[
"experiment"
]["MicroscopeState"]["microscope_name"]
self.camera_settings.parent_controller.configuration["experiment"][
"CameraParameters"
][microscope_name]["readout_time"] = 0.1
# Populate widgets with values from experiment file and check
self.camera_settings.populate_experiment_values()
camera_setting_dict = self.camera_settings.parent_controller.configuration[
"experiment"
]["CameraParameters"][microscope_name]
# Checking values altered are correct
assert dict(self.camera_settings.camera_setting_dict) == dict(
self.camera_settings.parent_controller.configuration["experiment"][
"CameraParameters"
][microscope_name]
)
assert (
str(self.camera_settings.mode_widgets["Sensor"].get())
== camera_setting_dict["sensor_mode"]
)
if camera_setting_dict["sensor_mode"] == "Normal":
pass
# assert str(self.camera_settings.mode_widgets[
# "Readout"].get()) == ""
# assert str(self.camera_settings.mode_widgets[
# "Pixels"].get()) == ""
elif camera_setting_dict["sensor_mode"] == "Light-Sheet":
assert (
str(self.camera_settings.mode_widgets["Readout"].get())
== self.camera_settings.camera_setting_dict["readout_direction"]
)
assert (
str(self.camera_settings.mode_widgets["Pixels"].get())
== self.camera_settings.camera_setting_dict["number_of_pixels"]
)
# ROI
assert (
self.camera_settings.roi_widgets["Width"].get()
== camera_setting_dict["x_pixels"]
)
assert (
self.camera_settings.roi_widgets["Height"].get()
== camera_setting_dict["y_pixels"]
)
assert self.camera_settings.roi_widgets[
"Top_X"
].get() == camera_setting_dict.get("top_x", 0)
assert self.camera_settings.roi_widgets[
"Top_Y"
].get() == camera_setting_dict.get("top_y", 0)
if camera_setting_dict.get("is_centered", True):
assert (
str(self.camera_settings.roi_widgets["Top_X"].widget["state"])
== "disabled"
)
assert (
str(self.camera_settings.roi_widgets["Top_Y"].widget["state"])
== "disabled"
)
# Binning
assert (
str(self.camera_settings.roi_widgets["Binning"].get())
== camera_setting_dict["binning"]
)
# Exposure Time
channels = self.camera_settings.parent_controller.configuration["experiment"][
"MicroscopeState"
]["channels"]
exposure_time = channels[list(channels.keys())[0]]["camera_exposure_time"]
assert (
self.camera_settings.framerate_widgets["exposure_time"].get()
== exposure_time
)
assert (
self.camera_settings.framerate_widgets["frames_to_average"].get()
== camera_setting_dict["frames_to_average"]
)
assert self.camera_settings.in_initialization is False
@pytest.mark.parametrize("mode", ["Normal", "Light-Sheet"])
def test_update_experiment_values(self, mode):
microscope_name = self.camera_settings.parent_controller.configuration[
"experiment"
]["MicroscopeState"]["microscope_name"]
# Setup basic default experiment
self.camera_settings.camera_setting_dict = (
self.camera_settings.parent_controller.configuration["experiment"][
"CameraParameters"
][microscope_name]
)
# Setting up new values in widgets
self.camera_settings.mode_widgets["Sensor"].set(mode)
self.camera_settings.roi_widgets["Binning"].set("4x4")
if mode == "Light-Sheet":
self.camera_settings.mode_widgets["Readout"].set("Bottom-to-Top")
self.camera_settings.mode_widgets["Pixels"].set(15)
self.camera_settings.roi_widgets["Binning"].set("1x1")
width, height = random.randint(1, 2000), random.randint(1, 2000)
self.camera_settings.roi_widgets["Width"].set(width)
self.camera_settings.roi_widgets["Height"].set(height)
self.camera_settings.framerate_widgets["frames_to_average"].set(5)
# Update experiment dict and assert
self.camera_settings.update_experiment_values()
assert self.camera_settings.camera_setting_dict["sensor_mode"] == mode
if mode == "Light-Sheet":
assert (
self.camera_settings.camera_setting_dict["readout_direction"]
== "Bottom-to-Top"
)
assert (
int(self.camera_settings.camera_setting_dict["number_of_pixels"]) == 15
)
step_width = self.camera_settings.step_width
step_height = self.camera_settings.step_height
set_width = int(width // step_width) * step_width
set_height = int(height // step_height) * step_height
if mode == "Light-Sheet":
assert self.camera_settings.camera_setting_dict["binning"] == "1x1"
assert self.camera_settings.camera_setting_dict["img_x_pixels"] == set_width
assert (
self.camera_settings.camera_setting_dict["img_y_pixels"] == set_height
)
binning = 1
else:
assert self.camera_settings.camera_setting_dict["binning"] == "4x4"
# make sure image size is divisible by step_width and step_height
assert self.camera_settings.camera_setting_dict["img_x_pixels"] == (
set_width // 4
) - (set_width // 4 % step_width)
assert self.camera_settings.camera_setting_dict["img_y_pixels"] == (
set_height // 4
) - (set_height // 4 % step_height)
binning = 4
# make sure x, y pixels are img_x, img_y pixels * binning
assert (
self.camera_settings.camera_setting_dict["x_pixels"]
== self.camera_settings.camera_setting_dict["img_x_pixels"] * binning
)
assert (
self.camera_settings.camera_setting_dict["y_pixels"]
== self.camera_settings.camera_setting_dict["img_y_pixels"] * binning
)
assert (
self.camera_settings.camera_setting_dict["pixel_size"]
== self.camera_settings.default_pixel_size
)
assert self.camera_settings.camera_setting_dict["frames_to_average"] == 5
@pytest.mark.parametrize("mode", ["Normal", "Light-Sheet"])
def test_update_sensor_mode(self, mode):
self.camera_settings.populate_experiment_values()
microscope_name = self.camera_settings.parent_controller.configuration[
"experiment"
]["MicroscopeState"]["microscope_name"]
camera_setting_dict = self.camera_settings.parent_controller.configuration[
"experiment"
]["CameraParameters"][microscope_name]
# Set mode
self.camera_settings.mode_widgets["Sensor"].widget.set(mode)
self.camera_settings.mode_widgets["Sensor"].widget.event_generate(
"<<ComboboxSelected>>"
)
# Call update
# self.camera_settings.update_sensor_mode()
# Check values
if mode == "Normal":
assert str(self.camera_settings.mode_widgets["Readout"].get()) == " "
assert (
str(self.camera_settings.mode_widgets["Readout"].widget["state"])
== "disabled"
)
assert (
str(self.camera_settings.mode_widgets["Pixels"].widget["state"])
== "disabled"
)
assert str(self.camera_settings.mode_widgets["Pixels"].widget.get()) == ""
if mode == "Light-Sheet":
assert (
str(self.camera_settings.mode_widgets["Readout"].get())
== camera_setting_dict["readout_direction"]
)
assert (
str(self.camera_settings.mode_widgets["Readout"].widget["state"])
== "readonly"
)
assert (
str(self.camera_settings.mode_widgets["Pixels"].widget["state"])
== "normal"
)
assert int(self.camera_settings.mode_widgets["Pixels"].widget.get()) == int(
self.camera_settings.camera_setting_dict["number_of_pixels"]
)
def test_update_exposure_time(self):
# Call funciton
self.camera_settings.update_exposure_time(35)
# Check
assert self.camera_settings.framerate_widgets["exposure_time"].get() == 35
@pytest.mark.parametrize("name", ["All", "1600", "1024", "512"])
def test_update_roi(self, name):
# Call button to check if handler setup correctly
self.camera_settings.roi_btns[name].invoke()
# Check
if name == "All":
name = "2048"
assert str(self.camera_settings.roi_widgets["Width"].get()) == name
assert str(self.camera_settings.roi_widgets["Height"].get()) == name
def test_update_fov(self):
self.camera_settings.populate_experiment_values()
# Change invoke
self.camera_settings.in_initialization = False
self.camera_settings.roi_widgets["Width"].widget.set(2048)
self.camera_settings.roi_widgets["Height"].widget.set(2048)
xFov = int(self.camera_settings.roi_widgets["FOV_X"].get())
yFov = int(self.camera_settings.roi_widgets["FOV_Y"].get())
self.camera_settings.roi_widgets["Width"].widget.set(1600)
self.camera_settings.roi_widgets["Height"].widget.set(1600)
# need these since we switched to read events
self.camera_settings.roi_widgets["Width"].get_variable().get()
self.camera_settings.roi_widgets["Height"].get_variable().get()
# Check
assert xFov != int(self.camera_settings.roi_widgets["FOV_X"].get())
assert yFov != int(self.camera_settings.roi_widgets["FOV_Y"].get())
# Reset
self.camera_settings.roi_widgets["Width"].widget.set(2048)
self.camera_settings.roi_widgets["Height"].widget.set(2048)
# need these since we switched to read events
self.camera_settings.roi_widgets["Width"].get_variable().get()
self.camera_settings.roi_widgets["Height"].get_variable().get()
assert int(self.camera_settings.roi_widgets["FOV_X"].get()) == 13066
assert int(self.camera_settings.roi_widgets["FOV_Y"].get()) == 13066
@pytest.mark.parametrize("mode", ["live", "z-stack", "stop", "single"])
@pytest.mark.parametrize("readout", ["Normal", "Light-Sheet"])
def test_set_mode(self, mode, readout):
# Populate widgets with values from experiment file
self.camera_settings.populate_experiment_values()
# Set mode
self.camera_settings.mode_widgets["Sensor"].widget.set(readout)
self.camera_settings.update_sensor_mode()
self.camera_settings.set_mode(mode)
# Check
assert self.camera_settings.mode == mode
if mode != "stop":
state = "disabled"
else:
state = "normal"
if mode != "stop":
state_readonly = "disabled"
else:
state_readonly = "readonly"
assert (
str(self.camera_settings.mode_widgets["Sensor"].widget["state"])
== state_readonly
)
if str(self.camera_settings.mode_widgets["Sensor"].get()) == "Light-Sheet":
assert (
str(self.camera_settings.mode_widgets["Readout"].widget["state"])
== state_readonly
)
if mode == "live":
assert (
str(self.camera_settings.mode_widgets["Pixels"].widget["state"])
== "normal"
)
else:
assert (
str(self.camera_settings.mode_widgets["Pixels"].widget["state"])
== state
)
else:
assert (
str(self.camera_settings.mode_widgets["Readout"].widget["state"])
== "disabled"
)
assert (
str(self.camera_settings.mode_widgets["Pixels"].widget["state"])
== "disabled"
)
assert (
str(
self.camera_settings.framerate_widgets["frames_to_average"].widget[
"state"
]
)
== state
)
assert str(self.camera_settings.roi_widgets["Width"].widget["state"]) == state
assert str(self.camera_settings.roi_widgets["Height"].widget["state"]) == state
assert (
str(self.camera_settings.roi_widgets["Binning"].widget["state"])
== state_readonly
)
for btn_name in self.camera_settings.roi_btns:
assert str(self.camera_settings.roi_btns[btn_name]["state"]) == state
@pytest.mark.parametrize("zoom", ["0.63x", "1x", "2x", "3x", "4x", "5x", "6x"])
def test_calculate_physical_dimensions(self, zoom):
self.camera_settings.parent_controller.configuration["experiment"][
"MicroscopeState"
]["zoom"] = zoom
self.camera_settings.populate_experiment_values()
# Calling
self.camera_settings.calculate_physical_dimensions()
pixel_size = self.camera_settings.default_pixel_size
x_pixel = float(self.camera_settings.roi_widgets["Width"].get())
y_pixel = float(self.camera_settings.roi_widgets["Height"].get())
microscope_state_dict = self.camera_settings.parent_controller.configuration[
"experiment"
]["MicroscopeState"]
zoom = microscope_state_dict["zoom"]
microscope_name = microscope_state_dict["microscope_name"]
pixel_size = self.camera_settings.parent_controller.configuration[
"configuration"
]["microscopes"][microscope_name]["zoom"]["pixel_size"][zoom]
dim_x = x_pixel * pixel_size
dim_y = y_pixel * pixel_size
assert float(self.camera_settings.roi_widgets["FOV_X"].get()) == float(
int(dim_x)
)
assert float(self.camera_settings.roi_widgets["FOV_Y"].get()) == float(
int(dim_y)
)
# Reset to zoom of 1
self.camera_settings.parent_controller.configuration["experiment"][
"MicroscopeState"
]["zoom"] = "1x"
assert (
self.camera_settings.parent_controller.configuration["experiment"][
"MicroscopeState"
]["zoom"]
== "1x"
)
def test_calculate_readout_time(self):
"""
TODO need more info about camera before testing
"""
pass
@pytest.mark.parametrize(
"mode", ["single", "live", "customized", "z-stack", "stop"]
)
def test_update_number_of_pixels(self, mode):
import random
self.camera_settings.populate_experiment_values()
self.camera_settings.mode = mode
self.camera_settings.mode_widgets["Pixels"].set("")
assert self.camera_settings.camera_setting_dict["number_of_pixels"] != ""
n_pixels = random.randint(1, 100)
self.camera_settings.mode_widgets["Pixels"].set(n_pixels)
# Check
assert self.camera_settings.camera_setting_dict["number_of_pixels"] == int(
n_pixels
)
if mode != "live" and mode != "stop":
assert (
self.camera_settings.camera_setting_dict["number_of_pixels"] == n_pixels
)
@pytest.mark.parametrize(
"x_pixels, y_pixels", [(512, 512), (4096, 4096), (2304, 1024), (1024, 2048)]
)
def test_update_camera_device_related_setting(self, x_pixels, y_pixels):
self.camera_settings.populate_experiment_values()
microscope_name = self.camera_settings.parent_controller.configuration[
"experiment"
]["MicroscopeState"]["microscope_name"]
camera_config = self.camera_settings.parent_controller.configuration[
"configuration"
]["microscopes"][microscope_name]["camera"]
default_x_pixels = camera_config["x_pixels"]
default_y_pixels = camera_config["y_pixels"]
camera_config["x_pixels"] = x_pixels
camera_config["y_pixels"] = y_pixels
self.camera_settings.update_camera_device_related_setting()
assert self.camera_settings.roi_widgets["Width"].get() == min(
self.camera_settings.camera_setting_dict["x_pixels"], x_pixels
)
assert self.camera_settings.roi_widgets["Height"].get() == min(
self.camera_settings.camera_setting_dict["y_pixels"], y_pixels
)
assert self.camera_settings.roi_widgets["Width"].widget["to"] == x_pixels
assert self.camera_settings.roi_widgets["Height"].widget["to"] == y_pixels
camera_config["x_pixels"] = default_x_pixels
camera_config["y_pixels"] = default_y_pixels

View File

@@ -0,0 +1,813 @@
# 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.
#
from navigate.controller.sub_controllers.camera_view import CameraViewController
import pytest
import random
from unittest.mock import MagicMock
import numpy as np
class TestCameraViewController:
@pytest.fixture(autouse=True)
def setup_class(self, dummy_controller):
c = dummy_controller
self.v = dummy_controller.view
c.model = MagicMock()
c.model.get_offset_variance_maps = MagicMock(return_value=[None, None])
self.camera_view = CameraViewController(self.v.camera_waveform.camera_tab, c)
self.microscope_state = {
"channels": {
"channel_1": {
"is_selected": True,
"laser": "488nm",
"laser_index": 0,
"camera_exposure_time": 200.0,
"laser_power": 20.0,
"interval_time": 1.0,
"defocus": 100.0,
"filter_wheel_0": "Empty-Alignment",
"filter_position_0": 0,
"filter_wheel_1": "Empty-Alignment",
"filter_position_1": 0,
},
"channel_2": {
"is_selected": True,
"laser": "488nm",
"laser_index": 0,
"camera_exposure_time": 200.0,
"laser_power": 20.0,
"interval_time": 1.0,
"defocus": 100.0,
"filter_wheel_0": "Empty-Alignment",
"filter_position_0": 0,
"filter_wheel_1": "Empty-Alignment",
"filter_position_1": 0,
},
"channel_3": {
"is_selected": True,
"laser": "488nm",
"laser_index": 0,
"camera_exposure_time": 200.0,
"laser_power": 20.0,
"interval_time": 1.0,
"defocus": 100.0,
"filter_wheel_0": "Empty-Alignment",
"filter_position_0": 0,
"filter_wheel_1": "Empty-Alignment",
"filter_position_1": 0,
},
},
"number_z_steps": np.random.randint(10, 100),
"stack_cycling_mode": "per_stack",
"image_mode": "z-stack",
}
def test_init(self):
assert isinstance(self.camera_view, CameraViewController)
def test_update_display_state(self):
pass
def test_get_absolute_position(self, monkeypatch):
def mock_winfo_pointerx():
self.x = int(random.random())
return self.x
def mock_winfo_pointery():
self.y = int(random.random())
return self.y
monkeypatch.setattr(self.v, "winfo_pointerx", mock_winfo_pointerx)
monkeypatch.setattr(self.v, "winfo_pointery", mock_winfo_pointery)
# call the function under test
x, y = self.camera_view.get_absolute_position()
# make assertions about the return value
assert x == self.x
assert y == self.y
def test_popup_menu(self, monkeypatch):
# create a fake event object
self.startx = int(random.random())
self.starty = int(random.random())
event = type("Event", (object,), {"x": self.startx, "y": self.starty})()
self.grab_released = False
self.x = int(random.random())
self.y = int(random.random())
self.absx = int(random.random())
self.absy = int(random.random())
# monkey patch the get_absolute_position method to return specific values
def mock_get_absolute_position():
self.absx = int(random.random())
self.absy = int(random.random())
return self.absx, self.absy
monkeypatch.setattr(
self.camera_view, "get_absolute_position", mock_get_absolute_position
)
def mock_tk_popup(x, y):
self.x = x
self.y = y
def mock_grab_release():
self.grab_released = True
monkeypatch.setattr(self.camera_view.menu, "tk_popup", mock_tk_popup)
monkeypatch.setattr(self.camera_view.menu, "grab_release", mock_grab_release)
# call the function under test
self.camera_view.popup_menu(event)
# make assertions about the state of the view object
assert self.camera_view.move_to_x == self.startx
assert self.camera_view.move_to_y == self.starty
assert self.x == self.absx
assert self.y == self.absy
assert self.grab_released is True
@pytest.mark.parametrize("name", ["minmax", "image"])
@pytest.mark.parametrize("data", [[random.randint(0, 49), random.randint(50, 100)]])
def test_initialize(self, name, data):
self.camera_view.initialize(name, data)
# Checking values
if name == "minmax":
assert self.camera_view.image_palette["Min"].get() == data[0]
assert self.camera_view.image_palette["Max"].get() == data[1]
if name == "image":
assert self.camera_view.image_metrics["Frames"].get() == data[0]
def test_set_mode(self):
# Test default mode
self.camera_view.set_mode()
assert self.camera_view.mode == ""
assert self.camera_view.menu.entrycget("Move Here", "state") == "disabled"
# Test 'live' mode
self.camera_view.set_mode("live")
assert self.camera_view.mode == "live"
assert self.camera_view.menu.entrycget("Move Here", "state") == "normal"
# Test 'stop' mode
self.camera_view.set_mode("stop")
assert self.camera_view.mode == "stop"
assert self.camera_view.menu.entrycget("Move Here", "state") == "normal"
# Test invalid mode
self.camera_view.set_mode("invalid")
assert self.camera_view.mode == "invalid"
assert self.camera_view.menu.entrycget("Move Here", "state") == "disabled"
@pytest.mark.parametrize("mode", ["stop", "live"])
def test_move_stage(self, mode):
# Setup to check formula inside func is correct
microscope_name = self.camera_view.parent_controller.configuration[
"experiment"
]["MicroscopeState"]["microscope_name"]
zoom_value = self.camera_view.parent_controller.configuration["experiment"][
"MicroscopeState"
]["zoom"]
pixel_size = self.camera_view.parent_controller.configuration["configuration"][
"microscopes"
][microscope_name]["zoom"]["pixel_size"][zoom_value]
current_center_x = (
self.camera_view.zoom_rect[0][0] + self.camera_view.zoom_rect[0][1]
) / 2
current_center_y = (
self.camera_view.zoom_rect[1][0] + self.camera_view.zoom_rect[1][1]
) / 2
self.camera_view.move_to_x = int(random.random())
self.camera_view.move_to_y = int(random.random())
# This is the formula to check
offset_x = (
(self.camera_view.move_to_x - current_center_x)
/ self.camera_view.zoom_scale
* self.camera_view.canvas_width_scale
* pixel_size
)
offset_y = (
(self.camera_view.move_to_y - current_center_y)
/ self.camera_view.zoom_scale
* self.camera_view.canvas_width_scale
* pixel_size
)
# Set the mode to check if statements
self.camera_view.mode = mode
# Act
self.camera_view.move_stage()
# Check
assert self.camera_view.parent_controller.pop() == "get_stage_position"
res = self.camera_view.parent_controller.pop()
if mode == "stop":
assert res == "move_stage_and_acquire_image"
else:
assert res == "move_stage_and_update_info"
# Checking that move stage properly changed pos
new_pos = self.camera_view.parent_controller.pop()
self.camera_view.parent_controller.stage_pos["x"] -= offset_x
self.camera_view.parent_controller.stage_pos["y"] += offset_y
assert new_pos == self.camera_view.parent_controller.stage_pos
def test_reset_display(self, monkeypatch):
# Mock process image function
process_image_called = False
def mock_process_image():
nonlocal process_image_called
process_image_called = True
monkeypatch.setattr(self.camera_view, "process_image", mock_process_image)
# Reset and check
self.camera_view.reset_display()
assert np.array_equal(
self.camera_view.zoom_rect,
np.array(
[
[0, self.camera_view.view.canvas_width],
[0, self.camera_view.view.canvas_height],
]
),
)
assert np.array_equal(self.camera_view.zoom_offset, np.array([[0], [0]]))
assert self.camera_view.zoom_value == 1
assert self.camera_view.zoom_scale == 1
assert self.camera_view.zoom_width == self.camera_view.view.canvas_width
assert self.camera_view.zoom_height == self.camera_view.view.canvas_height
assert process_image_called is True
def test_process_image(self):
self.camera_view.image = np.random.randint(0, 256, (600, 800))
self.camera_view.digital_zoom = MagicMock()
# self.camera_view.detect_saturation = MagicMock()
self.camera_view.down_sample_image = MagicMock()
self.camera_view.scale_image_intensity = MagicMock()
self.camera_view.add_crosshair = MagicMock()
self.camera_view.apply_lut = MagicMock()
self.camera_view.populate_image = MagicMock()
self.camera_view.process_image()
self.camera_view.digital_zoom.assert_called()
# self.camera_view.detect_saturation.assert_called()
self.camera_view.down_sample_image.assert_called()
self.camera_view.scale_image_intensity.assert_called()
self.camera_view.add_crosshair.assert_called()
self.camera_view.apply_lut.assert_called()
self.camera_view.populate_image.assert_called()
@pytest.mark.parametrize("num,value", [(4, 0.95), (5, 1.05)])
def test_mouse_wheel(self, num, value):
# Test zoom in and out
event = MagicMock()
event.num = num
event.x = int(random.random())
event.y = int(random.random())
event.delta = 120
self.camera_view.zoom_value = 1
self.camera_view.zoom_scale = 1
self.camera_view.zoom_width = self.camera_view.view.canvas_width
self.camera_view.zoom_height = self.camera_view.view.canvas_height
self.camera_view.reset_display = MagicMock()
self.camera_view.process_image = MagicMock()
self.camera_view.mouse_wheel(event)
assert self.camera_view.zoom_value == value
assert self.camera_view.zoom_scale == value
assert self.camera_view.zoom_width == self.camera_view.view.canvas_width / value
assert (
self.camera_view.zoom_height == self.camera_view.view.canvas_height / value
)
if (
self.camera_view.zoom_width > self.camera_view.view.canvas_width
or self.camera_view.zoom_height > self.camera_view.view.canvas_height
):
assert self.camera_view.reset_display.called is True
self.camera_view.process_image.assert_called()
else:
assert self.camera_view.reset_display.called is False
not self.camera_view.process_image.assert_called()
@pytest.mark.skip("AssertionError: Expected 'mock' to have been called.")
def test_digital_zoom(self):
# Setup
a, b, c, d, e, f = [random.randint(1, 100) for _ in range(6)]
g, h = [random.randint(100, 400) for _ in range(2)]
i, j = [random.randint(500, 1000) for _ in range(2)]
val, scale, widthsc, heightsc = [random.randint(2, 4) for _ in range(4)]
self.camera_view.zoom_rect = np.array([[a, b], [c, d]])
self.camera_view.zoom_offset = np.array([[e], [f]])
self.camera_view.zoom_value = val
self.camera_view.zoom_scale = scale
self.camera_view.zoom_width = g # 300
self.camera_view.zoom_height = h # 400
self.camera_view.view.canvas_width = i # 800
self.camera_view.view.canvas_height = j # 600
self.camera_view.canvas_width_scale = widthsc
self.camera_view.canvas_height_scale = heightsc
self.camera_view.image = np.random.randint(0, 256, (600, 800))
self.camera_view.reset_display = MagicMock()
# Preprocess
new_zoom_rec = self.camera_view.zoom_rect - self.camera_view.zoom_offset
new_zoom_rec *= self.camera_view.zoom_value
new_zoom_rec += self.camera_view.zoom_offset
x_start_index = int(-new_zoom_rec[0][0] / self.camera_view.zoom_scale)
x_end_index = int(x_start_index + self.camera_view.zoom_width)
y_start_index = int(-new_zoom_rec[1][0] / self.camera_view.zoom_scale)
y_end_index = int(y_start_index + self.camera_view.zoom_height)
crosshair_x = (new_zoom_rec[0][0] + new_zoom_rec[0][1]) / 2
crosshair_y = (new_zoom_rec[1][0] + new_zoom_rec[1][1]) / 2
if crosshair_x < 0 or crosshair_x >= self.camera_view.view.canvas_width:
crosshair_x = -1
if crosshair_y < 0 or crosshair_y >= self.camera_view.view.canvas_height:
crosshair_y = -1
new_image = self.camera_view.image[
y_start_index
* self.camera_view.canvas_height_scale : y_end_index
* self.camera_view.canvas_height_scale,
x_start_index
* self.camera_view.canvas_width_scale : x_end_index
* self.camera_view.canvas_width_scale,
]
# Call func
self.camera_view.digital_zoom()
# Check zoom rec
assert np.array_equal(self.camera_view.zoom_rect, new_zoom_rec)
# Check zoom offset
assert np.array_equal(self.camera_view.zoom_offset, np.array([[0], [0]]))
# Check zoom_value
assert self.camera_view.zoom_value == 1
# Check zoom_image
assert np.array_equal(self.camera_view.zoom_image, new_image)
# Check crosshairs
assert self.camera_view.crosshair_x == int(crosshair_x)
assert self.camera_view.crosshair_y == int(crosshair_y)
# Check reset display
self.camera_view.reset_display.assert_called()
@pytest.mark.parametrize("onoff", [True, False])
def test_left_click(self, onoff):
self.camera_view.add_crosshair = MagicMock()
self.camera_view.digital_zoom = MagicMock()
# self.camera_view.detect_saturation = MagicMock()
self.camera_view.down_sample_image = MagicMock()
self.camera_view.transpose_image = MagicMock()
self.camera_view.scale_image_intensity = MagicMock()
self.camera_view.apply_lut = MagicMock()
self.camera_view.populate_image = MagicMock()
event = MagicMock()
self.camera_view.image = np.random.randint(0, 256, (600, 800))
self.camera_view.apply_cross_hair = onoff
self.camera_view.left_click(event)
self.camera_view.add_crosshair.assert_called()
self.camera_view.populate_image.assert_called()
assert self.camera_view.apply_cross_hair == (not onoff)
@pytest.mark.parametrize("frames", [0, 1, 2, 10])
def test_update_max_count(self, frames):
self.camera_view.image_metrics["Frames"].set(frames)
idx = 0
temp = [0] * 40
while idx < 40:
self.camera_view._last_frame_display_max = np.random.randint(0, 500)
temp[idx] = self.camera_view._last_frame_display_max
idx += 1
# Act
self.camera_view.update_max_counts()
assert self.camera_view._max_intensity_history_idx == idx % 32
assert (
self.camera_view.max_intensity_history[
self.camera_view._max_intensity_history_idx - 1
]
== self.camera_view._last_frame_display_max
)
# Assert
if frames == 0:
assert self.camera_view.image_metrics["Frames"].get() == 1
frame_num = 1
else:
frame_num = frames
if frame_num <= idx:
rolling_average = sum(temp[idx - frame_num : idx]) / frame_num
else:
rolling_average = sum(temp[:idx]) / frame_num
assert self.camera_view.image_metrics["Image"].get() == round(
rolling_average, 0
)
def test_down_sample_image(self, monkeypatch):
import cv2
# create a test image
test_image = np.random.rand(100, 100)
self.zoom_image = test_image
# set the widget size
widget = type("MyWidget", (object,), {"widget": self.camera_view.view})
event = type(
"MyEvent",
(object,),
{
"widget": widget,
"width": np.random.randint(5, 1000),
"height": np.random.randint(5, 1000),
},
)
self.camera_view.resize(event)
# monkeypatch cv2.resize
def mocked_resize(src, dsize, interpolation=1):
return np.ones((dsize[0], dsize[1]))
monkeypatch.setattr(cv2, "resize", mocked_resize)
# call the function
down_sampled_image = self.camera_view.down_sample_image(test_image)
# assert that the image has been resized correctly
assert np.shape(down_sampled_image) == (
self.camera_view.view.canvas_width,
self.camera_view.view.canvas_height,
)
# assert that the image has not been modified
assert not np.array_equal(down_sampled_image, test_image)
@pytest.mark.parametrize("auto", [True, False])
def test_scale_image_intensity(self, auto):
# Create a test image
test_image = np.random.rand(100, 100)
# Set autoscale to True
self.camera_view.autoscale = auto
if auto is False:
self.camera_view.max_counts = 1.5
self.camera_view.min_counts = 0.5
# Call the function
scaled_image = self.camera_view.scale_image_intensity(test_image)
# Assert that max_counts and min_counts have been set correctly
if auto is True:
assert self.camera_view._last_frame_display_max == np.max(test_image)
# Assert that the image has been scaled correctly
assert np.min(scaled_image) >= 0
assert np.max(scaled_image) <= 255
def test_populate_image(self, monkeypatch):
from PIL import Image, ImageTk
import cv2
# Create test image
self.camera_view.cross_hair_image = np.random.rand(100, 100)
self.camera_view.ilastik_seg_mask = np.random.rand(100, 100)
# Set display_mask_flag to True
self.camera_view.display_mask_flag = True
# Monkeypatch the Image.fromarray() method of PIL
def mocked_fromarray(arr):
return arr
monkeypatch.setattr(Image, "fromarray", mocked_fromarray)
# Monkeypatch the cv2.resize() function
def mocked_resize(arr, size):
return arr
monkeypatch.setattr(cv2, "resize", mocked_resize)
# Monkeypatch the Image.blend() method of PIL
def mocked_blend(img1, img2, alpha):
return img1 * alpha + img2 * (1 - alpha)
monkeypatch.setattr(Image, "blend", mocked_blend)
def mocked_PhotoImage(img):
mock_photo = MagicMock()
# Set up width and height methods to return appropriate dimensions
mock_photo.width.return_value = 100
mock_photo.height.return_value = 100
return mock_photo
monkeypatch.setattr(ImageTk, "PhotoImage", mocked_PhotoImage)
self.camera_view.canvas.create_image = MagicMock()
self.camera_view.image_cache_flag = True
# Call the function
self.camera_view.populate_image(self.camera_view.cross_hair_image)
# Assert that the tk_image has been created correctly
assert self.camera_view._img_buf is not None
# Set display_mask_flag to True
self.camera_view.display_mask_flag = False
# Call the function
self.camera_view.populate_image(self.camera_view.cross_hair_image)
def test_initialize_non_live_display(self):
# Create test buffer and microscope_state
camera_parameters = {
"img_x_pixels": np.random.randint(1, 200),
"img_y_pixels": np.random.randint(1, 200),
}
# Call the function
self.camera_view.initialize_non_live_display(
self.microscope_state, camera_parameters
)
# Assert that the variables have been set correctly
assert self.camera_view.image_count == 0
assert self.camera_view.slice_index == 0
assert self.camera_view.number_of_channels == len(
self.microscope_state["channels"]
)
assert (
self.camera_view.number_of_slices == self.microscope_state["number_z_steps"]
)
assert (
self.camera_view.total_images_per_volume
== self.camera_view.number_of_channels * self.camera_view.number_of_slices
)
assert self.camera_view.original_image_width == int(
camera_parameters["img_x_pixels"]
)
assert self.camera_view.original_image_height == int(
camera_parameters["img_y_pixels"]
)
assert self.camera_view.canvas_width_scale == float(
self.camera_view.original_image_width / self.camera_view.canvas_width
)
assert self.camera_view.canvas_height_scale == float(
self.camera_view.original_image_height / self.camera_view.canvas_height
)
def test_identify_channel_index_and_slice(self):
# Not currently in use
pass
def test_retrieve_image_slice_from_volume(self):
# Not currently in use
pass
@pytest.mark.parametrize("transpose", [True, False])
def test_display_image(self, transpose):
"""Test the display of an image on the camera view
TODO: The recent refactor makes this test non-functional. It needs to be updated
The newer code does not use the camera_view.image attribute after the
transpose operation, so any transpose/image flipping is not reflected in the
final image. The test should be updated to reflect this.,
"""
self.camera_view.initialize_non_live_display(
self.microscope_state, {"img_x_pixels": 50, "img_y_pixels": 100}
)
self.camera_view.digital_zoom = MagicMock()
# self.camera_view.detect_saturation = MagicMock()
self.camera_view.down_sample_image = MagicMock()
self.camera_view.scale_image_intensity = MagicMock()
self.camera_view.apply_lut = MagicMock()
self.camera_view.populate_image = MagicMock()
images = np.random.rand(10, 100, 50)
self.camera_view.transpose = transpose
count = 0
self.camera_view.image_count = count
self.camera_view.image_metrics = {"Channel": MagicMock()}
self.camera_view.update_max_counts = MagicMock()
self.camera_view.flip_flags = {"x": False, "y": False}
image_id = np.random.randint(0, 10)
self.camera_view.try_to_display_image(images[image_id])
assert (
self.camera_view.spooled_images.size_y,
self.camera_view.spooled_images.size_x,
) == np.shape(images[image_id])
assert self.camera_view.image_count == count + 1
self.camera_view.flip_flags = {"x": True, "y": False}
image_id = np.random.randint(0, 10)
self.camera_view.try_to_display_image(images[image_id])
# assert np.shape(self.camera_view.image) == np.shape(images[image_id])
# if not transpose:
# assert (self.camera_view.image == images[image_id][:, ::-1]).all()
# else:
# assert (self.camera_view.image == images[image_id][:, ::-1].T).all()
assert self.camera_view.image_count == count + 2
self.camera_view.flip_flags = {"x": False, "y": True}
image_id = np.random.randint(0, 10)
self.camera_view.try_to_display_image(images[image_id])
# assert np.shape(self.camera_view.image) == np.shape(images[image_id])
# if not transpose:
# assert (self.camera_view.image == images[image_id][::-1, :]).all()
# else:
# assert (self.camera_view.image == images[image_id][::-1, :].T).all()
assert self.camera_view.image_count == count + 3
self.camera_view.flip_flags = {"x": True, "y": True}
image_id = np.random.randint(0, 10)
self.camera_view.try_to_display_image(images[image_id])
# assert np.shape(self.camera_view.image) == np.shape(images[image_id])
# if not transpose:
# assert (self.camera_view.image == images[image_id][::-1, ::-1]).all()
# else:
# assert (self.camera_view.image == images[image_id][::-1, ::-1].T).all()
assert self.camera_view.image_count == count + 4
def test_add_crosshair(self):
# Arrange
x = self.camera_view.canvas_width
y = self.camera_view.canvas_height
image = np.random.rand(x, y)
self.camera_view.apply_cross_hair = True
# Act
image2 = self.camera_view.add_crosshair(image)
# Assert
assert np.all(image2[:, self.camera_view.zoom_rect[0][1] // 2] == 255)
assert np.all(image2[self.camera_view.zoom_rect[1][1] // 2, :] == 255)
def test_apply_LUT(self):
# Someone else with better numpy understanding will need to do this TODO
pass
def test_update_LUT(self):
# Same as apply LUT TODO
pass
def test_toggle_min_max_button(self):
# Setup for true path
self.camera_view.image_palette["Autoscale"].set(True)
# Act by calling function
self.camera_view.toggle_min_max_buttons()
# Assert things are correct
assert str(self.camera_view.image_palette["Min"].widget["state"]) == "disabled"
assert str(self.camera_view.image_palette["Max"].widget["state"]) == "disabled"
# Setup for false path
self.camera_view.image_palette["Autoscale"].set(False)
# Mock function call to isolate
self.camera_view.update_min_max_counts = MagicMock()
# Act by calling function
self.camera_view.toggle_min_max_buttons()
# Assert things are correct and called
assert str(self.camera_view.image_palette["Min"].widget["state"]) == "normal"
assert str(self.camera_view.image_palette["Max"].widget["state"]) == "normal"
self.camera_view.update_min_max_counts.assert_called()
def test_transpose_image(self):
# Create test data
self.camera_view.image_palette["Flip XY"].set(True)
self.camera_view.transpose = None
# Call the function
self.camera_view.update_transpose_state()
# Assert the output
assert self.camera_view.transpose is True
# Create test data
self.camera_view.image_palette["Flip XY"].set(False)
self.camera_view.transpose = None
# Call the function
self.camera_view.update_transpose_state()
# Assert the output
assert self.camera_view.transpose is False
def test_update_min_max_counts(self):
# Create test data
min = np.random.randint(0, 10)
max = np.random.randint(0, 10)
self.camera_view.image_palette["Min"].set(min)
self.camera_view.image_palette["Max"].set(max)
self.camera_view.min_counts = None
self.camera_view.max_counts = None
# Call the function
self.camera_view.update_min_max_counts()
# Assert the output
assert self.camera_view.min_counts == min
assert self.camera_view.max_counts == max
def test_set_mask_color_table(self):
# This is beyond me currently TODO
pass
def test_display_mask(self, monkeypatch):
import cv2
# Create test data
self.camera_view.ilastik_seg_mask = None
self.camera_view.ilastik_mask_ready_lock.acquire()
mask = np.zeros((5, 5), dtype=np.uint8)
self.camera_view.mask_color_table = np.zeros((256, 1, 3), dtype=np.uint8)
# Define the monkeypatch
def mock_applyColorMap(mask, mask_color_table):
return mask
# Apply the monkeypatch
monkeypatch.setattr(cv2, "applyColorMap", mock_applyColorMap)
# Call the function
self.camera_view.display_mask(mask)
# Assert the output
assert (self.camera_view.ilastik_seg_mask == mask).all()
assert not self.camera_view.ilastik_mask_ready_lock.locked()
def test_update_canvas_size(self):
self.camera_view.view.canvas["width"] = random.randint(1, 2000)
self.camera_view.view.canvas["height"] = random.randint(1, 2000)
self.camera_view.update_canvas_size()
assert self.camera_view.canvas_width > 0
assert self.camera_view.canvas_height > 0

View File

@@ -0,0 +1,168 @@
# 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
class TestChannelSettingController:
@pytest.fixture(autouse=True)
def setup_class(self, dummy_controller):
from navigate.controller.sub_controllers.channels_tab import (
ChannelsTabController,
)
from navigate.controller.sub_controllers.channels_settings import (
ChannelSettingController,
)
self.ctc = ChannelsTabController(
dummy_controller.view.settings.channels_tab, dummy_controller
)
self.ctc.commands = []
self.ctc.execute = lambda command: self.ctc.commands.append(command)
self.channel_setting = ChannelSettingController(
self.ctc.view.channel_widgets_frame,
self.ctc,
dummy_controller.configuration_controller,
)
self.channel_setting.populate_experiment_values(
dummy_controller.configuration["experiment"]["MicroscopeState"]["channels"]
)
@pytest.mark.parametrize(
"mode,state,state_readonly",
[("stop", "normal", "readonly"), ("live", "disabled", "readonly")],
)
def test_set_mode(self, mode, state, state_readonly):
self.channel_setting.set_mode(mode)
for i in range(5):
assert str(self.channel_setting.view.channel_checks[i]["state"]) == state
# interval widget is disabled now
assert (
str(self.channel_setting.view.interval_spins[i]["state"]) == "disabled"
)
if mode == "stop":
assert (
str(self.channel_setting.view.laser_pulldowns[i]["state"])
== state_readonly
)
else:
assert (
str(self.channel_setting.view.laser_pulldowns[i]["state"])
== "disabled"
)
if self.channel_setting.mode != "live" or (
self.channel_setting.mode == "live"
and not self.channel_setting.view.channel_variables[i].get()
):
assert (
str(self.channel_setting.view.exptime_pulldowns[i]["state"])
== state
)
if not self.channel_setting.view.channel_variables[i].get():
assert (
str(self.channel_setting.view.laserpower_pulldowns[i]["state"])
== state
)
assert (
str(self.channel_setting.view.filterwheel_pulldowns[i]["state"])
== state_readonly
)
assert str(self.channel_setting.view.defocus_spins[i]["state"]) == state
def test_channel_callback(self):
import random
self.channel_setting.in_initialization = False
channel_dict = (
self.channel_setting.parent_controller.parent_controller.configuration[
"experiment"
]["MicroscopeState"]["channels"]
)
# shuffle the channels
new_channel_dict = {
k: v
for k, v in zip(
channel_dict.keys(),
random.choices(channel_dict.values(), k=len(channel_dict.keys())),
)
}
self.channel_setting.populate_experiment_values(channel_dict)
for channel_id in range(self.channel_setting.num):
vals = self.channel_setting.get_vals_by_channel(channel_id)
channel_key = f"channel_{str(channel_id + 1)}"
try:
setting_dict = channel_dict[channel_key]
new_setting_dict = new_channel_dict[channel_key]
except KeyError:
continue
# Test channel callback through trace writes
for k in setting_dict.keys():
if k == "laser_index" or k.startswith("filter_position"):
continue
if k == "defocus":
new_val = float(random.randint(1, 10))
else:
new_val = new_setting_dict[k]
vals[k].set(new_val)
assert str(vals[k].get()) == str(new_val)
if k != "defocus":
assert setting_dict[k] == new_setting_dict[k]
if k == "laser":
assert (
setting_dict["laser_index"] == new_setting_dict["laser_index"]
)
elif k == "filter":
assert (
setting_dict["filter_position"]
== new_setting_dict["filter_position"]
)
elif k == "camera_exposure_time" or k == "is_selected":
assert (
self.channel_setting.parent_controller.commands.pop()
== "recalculate_timepoint"
)
self.channel_setting.parent_controller.commands = [] # reset
def test_get_vals_by_channel(self):
# Not needed to test IMO
pass
def test_get_index(self):
# Not needed to test IMO
pass

View File

@@ -0,0 +1,385 @@
# 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 random
import copy
import pytest
import numpy as np
from unittest.mock import patch
@pytest.fixture
def channels_tab_controller(dummy_controller):
from navigate.controller.sub_controllers.channels_tab import (
ChannelsTabController,
)
return ChannelsTabController(
dummy_controller.view.settings.channels_tab, dummy_controller
)
def test_update_z_steps(channels_tab_controller):
# Calculate params
z_start, f_start = random.randint(1, 1000), random.randint(1, 1000)
z_end, f_end = random.randint(1, 1000), random.randint(1, 1000)
if z_end < z_start:
# Sort so we are always going low to high
tmp = z_start
tmp_f = f_start
z_start = z_end
f_start = f_end
z_end = tmp
f_end = tmp_f
step_size = max(1, min(random.randint(1, 10), (z_end - z_start) // 2))
# Set params
channels_tab_controller.microscope_state_dict = (
channels_tab_controller.parent_controller.configuration["experiment"][
"MicroscopeState"
]
)
channels_tab_controller.in_initialization = False
channels_tab_controller.stack_acq_vals["start_position"].set(z_start)
channels_tab_controller.stack_acq_vals["start_focus"].set(f_start)
channels_tab_controller.stack_acq_vals["end_position"].set(z_end)
channels_tab_controller.stack_acq_vals["end_focus"].set(f_end)
channels_tab_controller.stack_acq_vals["step_size"].set(step_size)
# Run
channels_tab_controller.update_z_steps()
# Verify
number_z_steps = int(np.ceil(np.abs((z_start - z_end) / step_size)))
assert (
int(channels_tab_controller.stack_acq_vals["number_z_steps"].get())
== number_z_steps
)
# test flip_z is True
microscope_name = (
channels_tab_controller.parent_controller.configuration_controller.microscope_name
)
stage_config = channels_tab_controller.parent_controller.configuration[
"configuration"
]["microscopes"][microscope_name]["stage"]
stage_config["flip_z"] = True
channels_tab_controller.z_origin = (z_start + z_end) / 2
channels_tab_controller.stack_acq_vals["start_position"].set(z_end)
channels_tab_controller.stack_acq_vals["start_focus"].set(f_end)
channels_tab_controller.stack_acq_vals["end_position"].set(z_start)
channels_tab_controller.stack_acq_vals["end_focus"].set(f_start)
channels_tab_controller.update_z_steps()
assert channels_tab_controller.stack_acq_vals["step_size"].get() == step_size
assert channels_tab_controller.microscope_state_dict["step_size"] == -1 * step_size
assert (
channels_tab_controller.stack_acq_vals["number_z_steps"].get() == number_z_steps
)
stage_config["flip_z"] = False
def test_update_start_position(channels_tab_controller):
z, f = random.randint(0, 1000), random.randint(0, 1000)
channels_tab_controller.parent_controller.configuration["experiment"][
"StageParameters"
]["z"] = z
channels_tab_controller.parent_controller.configuration["experiment"][
"StageParameters"
]["f"] = f
channels_tab_controller.update_start_position()
assert channels_tab_controller.z_origin == z
assert channels_tab_controller.focus_origin == f
assert int(channels_tab_controller.stack_acq_vals["start_position"].get()) == 0
assert int(channels_tab_controller.stack_acq_vals["start_focus"].get()) == 0
# test flip_z is True
microscope_name = (
channels_tab_controller.parent_controller.configuration_controller.microscope_name
)
stage_config = channels_tab_controller.parent_controller.configuration[
"configuration"
]["microscopes"][microscope_name]["stage"]
stage_config["flip_z"] = True
channels_tab_controller.update_start_position()
assert channels_tab_controller.z_origin == z
assert channels_tab_controller.focus_origin == f
assert int(channels_tab_controller.stack_acq_vals["end_position"].get()) == 0
assert int(channels_tab_controller.stack_acq_vals["end_focus"].get()) == 0
stage_config["flip_z"] = False
def test_update_end_position(channels_tab_controller):
configuration = channels_tab_controller.parent_controller.configuration
# Initialize
z, f = random.randint(0, 1000), random.randint(0, 1000)
z_shift, f_shift = random.randint(1, 500), random.randint(1, 500)
configuration["experiment"]["StageParameters"]["z"] = z + z_shift
configuration["experiment"]["StageParameters"]["f"] = f + f_shift
print(f"z: {z} z-shift: {z_shift} f: {f} f-shift: {f_shift}")
print(f'z-dict: {configuration["experiment"]["StageParameters"]["z"]}')
print(f'f-dict: {configuration["experiment"]["StageParameters"]["f"]}')
# Step backwards and record results
channels_tab_controller.z_origin = z - z_shift
channels_tab_controller.focus_origin = f - f_shift
channels_tab_controller.update_end_position()
z_origin_minus = copy.deepcopy(channels_tab_controller.z_origin)
f_origin_minus = copy.deepcopy(channels_tab_controller.focus_origin)
start_position_minus = copy.deepcopy(
channels_tab_controller.stack_acq_vals["start_position"].get()
)
end_position_minus = copy.deepcopy(
channels_tab_controller.stack_acq_vals["end_position"].get()
)
start_focus_minus = copy.deepcopy(
channels_tab_controller.stack_acq_vals["start_focus"].get()
)
end_focus_minus = copy.deepcopy(
channels_tab_controller.stack_acq_vals["end_focus"].get()
)
print("back")
print(f"z: {z} z-shift: {z_shift} f: {f} f-shift: {f_shift}")
print(f'z-dict: {configuration["experiment"]["StageParameters"]["z"]}')
print(f'f-dict: {configuration["experiment"]["StageParameters"]["f"]}')
# Step forward
configuration["experiment"]["StageParameters"]["z"] = z - z_shift
configuration["experiment"]["StageParameters"]["f"] = f - f_shift
channels_tab_controller.z_origin = z + z_shift
channels_tab_controller.focus_origin = f + f_shift
channels_tab_controller.update_end_position()
print("forward")
print(f"z: {z} z-shift: {z_shift} f: {f} f-shift: {f_shift}")
print(f'z-dict: {configuration["experiment"]["StageParameters"]["z"]}')
print(f'f-dict: {configuration["experiment"]["StageParameters"]["f"]}')
# Ensure we achieve the same origin
assert channels_tab_controller.z_origin == z_origin_minus
assert channels_tab_controller.focus_origin == f_origin_minus
assert (
channels_tab_controller.stack_acq_vals["start_position"].get()
== start_position_minus
)
assert (
channels_tab_controller.stack_acq_vals["end_position"].get()
== end_position_minus
)
assert (
channels_tab_controller.stack_acq_vals["start_focus"].get() == start_focus_minus
)
assert channels_tab_controller.stack_acq_vals["end_focus"].get() == end_focus_minus
# test flip_z is True
microscope_name = (
channels_tab_controller.parent_controller.configuration_controller.microscope_name
)
stage_config = channels_tab_controller.parent_controller.configuration[
"configuration"
]["microscopes"][microscope_name]["stage"]
stage_config["flip_z"] = True
# forward
channels_tab_controller.z_origin = z
channels_tab_controller.focus_origin = f
configuration["experiment"]["StageParameters"]["z"] = z - 2 * z_shift
configuration["experiment"]["StageParameters"]["f"] = f - 2 * f_shift
channels_tab_controller.update_end_position()
assert channels_tab_controller.z_origin == z - z_shift
assert channels_tab_controller.focus_origin == f - f_shift
assert channels_tab_controller.stack_acq_vals["start_position"].get() == z_shift
assert channels_tab_controller.stack_acq_vals["end_position"].get() == -1 * z_shift
assert channels_tab_controller.stack_acq_vals["start_focus"].get() == f_shift
assert channels_tab_controller.stack_acq_vals["end_focus"].get() == -1 * f_shift
# backward
channels_tab_controller.z_origin = z
channels_tab_controller.focus_origin = f
configuration["experiment"]["StageParameters"]["z"] = z + 2 * z_shift
configuration["experiment"]["StageParameters"]["f"] = f + 2 * f_shift
channels_tab_controller.update_end_position()
assert channels_tab_controller.z_origin == z + z_shift
assert channels_tab_controller.focus_origin == f + f_shift
assert channels_tab_controller.stack_acq_vals["start_position"].get() == z_shift
assert channels_tab_controller.stack_acq_vals["end_position"].get() == -1 * z_shift
assert channels_tab_controller.stack_acq_vals["start_focus"].get() == f_shift
assert channels_tab_controller.stack_acq_vals["end_focus"].get() == -1 * f_shift
stage_config["flip_z"] = False
def test_update_start_update_end_position(channels_tab_controller):
configuration = channels_tab_controller.parent_controller.configuration
channels_tab_controller.microscope_state_dict = configuration["experiment"][
"MicroscopeState"
]
channels_tab_controller.in_initialization = False
# Initialize
z, f = random.randint(0, 1000), random.randint(0, 1000)
z_shift, f_shift = random.randint(1, 500), random.randint(1, 500)
configuration["experiment"]["StageParameters"]["z"] = z - z_shift
configuration["experiment"]["StageParameters"]["f"] = f - f_shift
channels_tab_controller.update_start_position()
print(f"z: {z} z-shift: {z_shift} f: {f} f-shift: {f_shift}")
print(f'z-dict: {configuration["experiment"]["StageParameters"]["z"]}')
print(f'f-dict: {configuration["experiment"]["StageParameters"]["f"]}')
# Step forward and record results
configuration["experiment"]["StageParameters"]["z"] = z + z_shift
configuration["experiment"]["StageParameters"]["f"] = f + f_shift
channels_tab_controller.update_end_position()
z_origin_minus = copy.deepcopy(channels_tab_controller.z_origin)
f_origin_minus = copy.deepcopy(channels_tab_controller.focus_origin)
start_position_minus = copy.deepcopy(
channels_tab_controller.stack_acq_vals["start_position"].get()
)
end_position_minus = copy.deepcopy(
channels_tab_controller.stack_acq_vals["end_position"].get()
)
start_focus_minus = copy.deepcopy(
channels_tab_controller.stack_acq_vals["start_focus"].get()
)
end_focus_minus = copy.deepcopy(
channels_tab_controller.stack_acq_vals["end_focus"].get()
)
print("back")
print(f"z: {z} z-shift: {z_shift} f: {f} f-shift: {f_shift}")
print(f'z-dict: {configuration["experiment"]["StageParameters"]["z"]}')
print(f'f-dict: {configuration["experiment"]["StageParameters"]["f"]}')
channels_tab_controller.update_start_position()
# Step back
configuration["experiment"]["StageParameters"]["z"] = z - z_shift
configuration["experiment"]["StageParameters"]["f"] = f - f_shift
channels_tab_controller.update_end_position()
print("forward")
print(f"z: {z} z-shift: {z_shift} f: {f} f-shift: {f_shift}")
print(f'z-dict: {configuration["experiment"]["StageParameters"]["z"]}')
print(f'f-dict: {configuration["experiment"]["StageParameters"]["f"]}')
# Ensure we achieve the same origin
assert channels_tab_controller.z_origin == z_origin_minus
assert channels_tab_controller.focus_origin == f_origin_minus
assert (
channels_tab_controller.stack_acq_vals["start_position"].get()
== start_position_minus
)
assert (
channels_tab_controller.stack_acq_vals["end_position"].get()
== end_position_minus
)
assert (
channels_tab_controller.stack_acq_vals["start_focus"].get() == start_focus_minus
)
assert channels_tab_controller.stack_acq_vals["end_focus"].get() == end_focus_minus
# test flip_z is true
microscope_name = (
channels_tab_controller.parent_controller.configuration_controller.microscope_name
)
stage_config = channels_tab_controller.parent_controller.configuration[
"configuration"
]["microscopes"][microscope_name]["stage"]
stage_config["flip_z"] = True
configuration = channels_tab_controller.parent_controller.configuration
z, f = random.randint(0, 1000), random.randint(0, 1000)
z_shift, f_shift = random.randint(1, 500), random.randint(1, 500)
configuration["experiment"]["StageParameters"]["z"] = z - z_shift
configuration["experiment"]["StageParameters"]["f"] = f - f_shift
channels_tab_controller.update_start_position()
configuration["experiment"]["StageParameters"]["z"] = z + z_shift
configuration["experiment"]["StageParameters"]["f"] = f + f_shift
channels_tab_controller.update_end_position()
assert channels_tab_controller.z_origin == z
assert channels_tab_controller.focus_origin == f
assert channels_tab_controller.stack_acq_vals["start_position"].get() == z_shift
assert channels_tab_controller.stack_acq_vals["end_position"].get() == -1 * z_shift
assert channels_tab_controller.stack_acq_vals["start_focus"].get() == f_shift
assert channels_tab_controller.stack_acq_vals["end_focus"].get() == -1 * f_shift
assert configuration["experiment"]["MicroscopeState"]["start_position"] == z_shift
assert (
configuration["experiment"]["MicroscopeState"]["end_position"] == -1 * z_shift
)
assert configuration["experiment"]["MicroscopeState"]["abs_z_start"] == z - z_shift
assert configuration["experiment"]["MicroscopeState"]["abs_z_end"] == z + z_shift
assert configuration["experiment"]["MicroscopeState"]["start_focus"] == f_shift
assert configuration["experiment"]["MicroscopeState"]["end_focus"] == -1 * f_shift
configuration["experiment"]["StageParameters"]["z"] = z + z_shift
configuration["experiment"]["StageParameters"]["f"] = f + f_shift
channels_tab_controller.update_start_position()
configuration["experiment"]["StageParameters"]["z"] = z - z_shift
configuration["experiment"]["StageParameters"]["f"] = f - f_shift
channels_tab_controller.update_end_position()
assert channels_tab_controller.z_origin == z
assert channels_tab_controller.focus_origin == f
assert channels_tab_controller.stack_acq_vals["start_position"].get() == z_shift
assert channels_tab_controller.stack_acq_vals["end_position"].get() == -1 * z_shift
assert channels_tab_controller.stack_acq_vals["start_focus"].get() == f_shift
assert channels_tab_controller.stack_acq_vals["end_focus"].get() == -1 * f_shift
assert configuration["experiment"]["MicroscopeState"]["start_position"] == z_shift
assert (
configuration["experiment"]["MicroscopeState"]["end_position"] == -1 * z_shift
)
assert configuration["experiment"]["MicroscopeState"]["abs_z_start"] == z - z_shift
assert configuration["experiment"]["MicroscopeState"]["abs_z_end"] == z + z_shift
assert configuration["experiment"]["MicroscopeState"]["start_focus"] == f_shift
assert configuration["experiment"]["MicroscopeState"]["end_focus"] == -1 * f_shift
stage_config["flip_z"] = False
@pytest.mark.parametrize("is_multiposition", [True, False])
def test_toggle_multiposition(channels_tab_controller, is_multiposition):
channels_tab_controller.populate_experiment_values()
channels_tab_controller.is_multiposition_val.set(is_multiposition)
with patch.object(channels_tab_controller, "update_timepoint_setting") as uts:
channels_tab_controller.toggle_multiposition()
assert channels_tab_controller.is_multiposition == is_multiposition
assert (
channels_tab_controller.microscope_state_dict["is_multiposition"]
== is_multiposition
)
uts.assert_called()

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 unittest
from unittest.mock import MagicMock, patch
import tkinter as tk
# Third Party Imports
import pytest
# Local Imports
from navigate.controller.sub_controllers.menus import (
MenuController,
FakeEvent,
)
class TestFakeEvent(unittest.TestCase):
def test_fake_event_creation(self):
fake_event = FakeEvent(char="a", keysym="A")
self.assertEqual(fake_event.char, "a")
self.assertEqual(fake_event.keysym, "A")
self.assertEqual(fake_event.state, 0)
class TestStageMovement(unittest.TestCase):
def setUp(self):
# Create a mock parent controller and view
self.root = tk.Tk()
self.parent_controller = MagicMock()
self.parent_controller.stage_controller = MagicMock()
self.view = MagicMock()
self.view.root = self.root
# Initialize the menu controller
self.mc = MenuController(self.view, self.parent_controller)
# Mock the histogram configuration entry.
self.parent_controller.configuration["gui"]["histogram"] = MagicMock()
self.parent_controller.configuration["gui"]["histogram"].get.return_value = True
def tearDown(self):
self.root.destroy()
def test_initialize_menus(self):
self.mc.initialize_menus()
def test_stage_movement_with_ttk_entry(self):
self.mc.parent_controller.view.focus_get.return_value = MagicMock(
widgetName="ttk::entry"
)
self.mc.stage_movement("a")
self.mc.parent_controller.stage_controller.stage_key_press.assert_not_called()
def test_stage_movement_with_ttk_combobox(self):
self.mc.parent_controller.view.focus_get.return_value = MagicMock(
widgetName="ttk::combobox"
)
self.mc.stage_movement("a")
self.mc.parent_controller.stage_controller.stage_key_press.assert_not_called()
def test_stage_movement_with_other_widget(self):
self.mc.parent_controller.view.focus_get.return_value = MagicMock(
widgetName="other_widget"
)
self.mc.stage_movement("a")
self.mc.parent_controller.stage_controller.stage_key_press.assert_called_with(
self.mc.fake_event
)
def test_stage_movement_with_key_error(self):
self.mc.parent_controller.view.focus_get.side_effect = KeyError
# Test that no exception is raised
try:
self.mc.stage_movement("a")
except KeyError:
self.fail("stage_movement() raised KeyError unexpectedly!")
def test_stage_movement_with_no_focus(self):
self.mc.parent_controller.view.focus_get.return_value = None
self.mc.stage_movement("a")
self.mc.parent_controller.stage_controller.stage_key_press.assert_called_with(
self.mc.fake_event
)
class TestMenuController(unittest.TestCase):
@pytest.fixture(autouse=True)
def setup_class(self, dummy_controller):
c = dummy_controller
v = dummy_controller.view
self.menu_controller = MenuController(v, c)
def test_attributes(self):
methods = dir(MenuController)
desired_methods = [
"initialize_menus",
"populate_menu",
"new_experiment",
"load_experiment",
"save_experiment",
"load_images",
"popup_camera_map_setting",
"popup_ilastik_setting",
"popup_help",
"toggle_stage_limits",
"popup_autofocus_setting",
"popup_waveform_setting",
"popup_microscope_setting",
"toggle_save",
"acquire_data",
"not_implemented",
"stage_movement",
"switch_tabs",
]
for method in desired_methods:
assert method in methods
def test_popup_camera_map_setting(self):
assert (
hasattr(
self.menu_controller.parent_controller, "camera_map_popup_controller"
)
is False
)
self.menu_controller.popup_camera_map_setting()
assert (
hasattr(
self.menu_controller.parent_controller, "camera_map_popup_controller"
)
is True
)
def test_autofocus_settings(self):
assert (
hasattr(self.menu_controller.parent_controller, "af_popup_controller")
is False
)
self.menu_controller.popup_autofocus_setting()
assert (
hasattr(self.menu_controller.parent_controller, "af_popup_controller")
is True
)
def test_popup_waveform_setting(self):
# TODO: Incomplete.
assert (
hasattr(self.menu_controller.parent_controller, "waveform_popup_controller")
is False
)
def test_popup_microscope_setting(self):
# TODO: Incomplete. DummyController has no attribute 'model'
assert (
hasattr(
self.menu_controller.parent_controller, "microscope_popup_controller"
)
is False
)
def test_toggle_save(self):
class MockWidget:
def __int__(self):
self.value = False
def set(self, value):
self.value = value
def get(self):
return self.value
channel_tab_controller = MagicMock()
self.menu_controller.parent_controller.channels_tab_controller = (
channel_tab_controller
)
channel_tab_controller.timepoint_vals = {"is_save": MockWidget()}
temp = self.menu_controller.view.settings.channels_tab.stack_timepoint_frame
temp.save_data.get = MagicMock(return_value=False)
self.menu_controller.toggle_save()
assert channel_tab_controller.timepoint_vals["is_save"].get() is True
temp = self.menu_controller.view.settings.channels_tab.stack_timepoint_frame
temp.save_data.get = MagicMock(return_value=True)
self.menu_controller.toggle_save()
assert channel_tab_controller.timepoint_vals["is_save"].get() is False
def test_stage_movement(self):
# TODO: DummyController does not have a stage controller.
pass
def test_switch_tabs(self):
for i in range(1, 4):
self.menu_controller.switch_tabs(window="left", tab=i)
assert (
self.menu_controller.parent_controller.view.settings.index("current")
== i - 1
)
@patch("src.navigate.controller.sub_controllers.menus.platform.system")
@patch("src.navigate.controller.sub_controllers.menus.subprocess.check_call")
def test_open_folder(self, mock_check_call, mock_system):
mock_system.return_value = "Darwin"
self.menu_controller.open_folder("test_path")
mock_check_call.assert_called_once_with(["open", "--", "test_path"])
mock_check_call.reset_mock()
mock_system.return_value = "Windows"
self.menu_controller.open_folder("test_path")
mock_check_call.assert_called_once_with(["explorer", "test_path"])
mock_check_call.reset_mock()
mock_system.return_value = "Linux"
self.menu_controller.open_folder("test_path")
self.assertEqual(mock_check_call.call_count, 0)
@patch("src.navigate.controller.sub_controllers.menus.os.path.join")
def test_open_log_files(self, mock_join):
with patch.object(self.menu_controller, "open_folder") as mock_open_folder:
mock_join.return_value = "joined_path"
self.menu_controller.open_log_files()
mock_open_folder.assert_called_once_with("joined_path")
@patch("src.navigate.controller.sub_controllers.menus.os.path.join")
def test_open_configuration_files(self, mock_join):
with patch.object(self.menu_controller, "open_folder") as mock_open_folder:
mock_join.return_value = "joined_path"
self.menu_controller.open_configuration_files()
mock_open_folder.assert_called_once_with("joined_path")

View File

@@ -0,0 +1,171 @@
# 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 unittest
from unittest.mock import MagicMock, patch
import pandas as pd
# Local application imports
from navigate.controller.sub_controllers.multiposition import MultiPositionController
@pytest.fixture
def multiposition_controller(dummy_controller):
# Create a copy/clone of the dummy_controller to avoid side effects
isolated_controller = MagicMock()
isolated_controller.configuration = dummy_controller.configuration
# Create a mock pt attribute for the multiposition_tab
isolated_controller.view.settings.multiposition_tab.pt = MagicMock()
isolated_controller.view.settings.multiposition_tab.pt.model = MagicMock()
isolated_controller.view.settings.multiposition_tab.pt.model.df = pd.DataFrame()
# Add other required mock attributes and methods
isolated_controller.view.settings.multiposition_tab.pt.redraw = MagicMock()
isolated_controller.view.settings.multiposition_tab.pt.tableChanged = MagicMock()
isolated_controller.view.settings.multiposition_tab.pt.resetColors = MagicMock()
isolated_controller.view.settings.multiposition_tab.pt.update_rowcolors = (
MagicMock()
)
# Mock the master and tiling buttons
isolated_controller.view.settings.multiposition_tab.master = MagicMock()
isolated_controller.view.settings.multiposition_tab.master.tiling_buttons = (
MagicMock()
)
isolated_controller.view.settings.multiposition_tab.master.tiling_buttons.buttons = {
"tiling": MagicMock(),
"save_data": MagicMock(),
"load_data": MagicMock(),
"eliminate_tiles": MagicMock(),
}
# This is the important part - configure the stage axes
isolated_controller.configuration_controller = MagicMock()
isolated_controller.configuration_controller.stage_axes = [
"x",
"y",
"z",
"theta",
"f",
]
return MultiPositionController(
isolated_controller.view.settings.multiposition_tab, isolated_controller
)
@patch("navigate.controller.sub_controllers.multiposition.filedialog.askopenfilenames")
@patch("navigate.controller.sub_controllers.multiposition.yaml.safe_load")
@patch("builtins.open", new_callable=unittest.mock.mock_open, read_data="dummy content")
def test_load_positions_yaml(
mock_file, mock_safe_load, mock_askopen, multiposition_controller
):
"""Test loading positions from YAML"""
controller = multiposition_controller
table = controller.table
mock_askopen.return_value = ("dummy_file.yml",)
mock_safe_load.return_value = [
["X", "Y", "Z", "THETA", "F"],
[0, 0, 0, 0, 0],
[100, 200, 300, 400, 500],
]
controller.load_positions()
mock_file.assert_called_once_with("dummy_file.yml", "r")
expected = pd.DataFrame(
[[0, 0, 0, 0, 0], [100, 200, 300, 400, 500]],
columns=["X", "Y", "Z", "THETA", "F"],
)
pd.testing.assert_frame_equal(table.model.df, expected)
@patch("navigate.controller.sub_controllers.multiposition.filedialog.askopenfilenames")
@patch("navigate.controller.sub_controllers.multiposition.pd.read_csv")
def test_load_positions_csv(mock_read_csv, mock_askopen, multiposition_controller):
"""Test loading positions from CSV"""
controller = multiposition_controller
table = controller.table
mock_askopen.return_value = ("dummy_file.csv",)
mock_read_csv.return_value = pd.DataFrame(
{"X": [1, 2], "Y": [3, 4], "Z": [5, 6], "THETA": [0, 0], "F": [0, 0]}
)
controller.load_positions()
expected = pd.DataFrame(
{"X": [1, 2], "Y": [3, 4], "Z": [5, 6], "THETA": [0, 0], "F": [0, 0]}
)
pd.testing.assert_frame_equal(table.model.df, expected)
@patch("navigate.controller.sub_controllers.multiposition.filedialog.asksaveasfilename")
@patch("navigate.controller.sub_controllers.multiposition.save_yaml_file")
def test_export_positions_yaml(mock_save_yaml, mock_asksave, multiposition_controller):
"""Test exporting positions to YAML"""
controller = multiposition_controller
table = controller.table
table.model.df = pd.DataFrame(
{"X": [1, 2], "Y": [3, 4], "Z": [5, 6], "THETA": [0, 0], "F": [0, 0]}
)
mock_asksave.return_value = "/tmp/output.yml"
controller.export_positions()
mock_save_yaml.assert_called_once()
@patch("navigate.controller.sub_controllers.multiposition.filedialog.asksaveasfilename")
def test_export_positions_csv(mock_asksave, multiposition_controller):
"""Test exporting positions to CSV"""
controller = multiposition_controller
table = controller.table
df = pd.DataFrame(
{"X": [1, 2], "Y": [3, 4], "Z": [5, 6], "THETA": [0, 0], "F": [0, 0]}
)
table.model.df = df
table.model.df.to_csv = MagicMock()
mock_asksave.return_value = "/tmp/output.csv"
controller.export_positions()
table.model.df.to_csv.assert_called_once_with("/tmp/output.csv", index=False)

View File

@@ -0,0 +1,376 @@
# 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
from unittest.mock import MagicMock, call
import numpy as np
AXES = ["x", "y", "z", "theta", "f"]
CAXES = ["xy", "z", "theta", "f"]
def pos_dict(v, axes=AXES):
return {k: v for k in axes}
@pytest.fixture
def stage_controller(dummy_controller):
from navigate.controller.sub_controllers.stages import StageController
dummy_controller.camera_view_controller = MagicMock()
stage_controller = StageController(
dummy_controller.view.settings.stage_control_tab,
dummy_controller,
)
dummy_controller.view.settings.stage_control_tab.focus_get = MagicMock(
return_value=True
)
return stage_controller
# test before set position variables to MagicMock()
def test_set_position(stage_controller):
widgets = stage_controller.view.get_widgets()
vals = {}
for axis in AXES:
widgets[axis].widget.trigger_focusout_validation = MagicMock()
vals[axis] = np.random.randint(0, 9)
stage_controller.view.get_widgets = MagicMock(return_value=widgets)
stage_controller.show_verbose_info = MagicMock()
position = {
"x": np.random.random(),
"y": np.random.random(),
"z": np.random.random(),
}
stage_controller.set_position(position)
for axis in position.keys():
assert float(stage_controller.widget_vals[axis].get()) == position[axis]
assert widgets[axis].widget.trigger_focusout_validation.called
assert stage_controller.stage_setting_dict[axis] == position.get(axis, 0)
stage_controller.show_verbose_info.assert_has_calls(
[call("Stage position changed"), call("Set stage position")]
)
def test_set_position_silent(stage_controller):
widgets = stage_controller.view.get_widgets()
vals = {}
for axis in AXES:
widgets[axis].widget.trigger_focusout_validation = MagicMock()
vals[axis] = np.random.randint(0, 9)
stage_controller.view.get_widgets = MagicMock(return_value=widgets)
stage_controller.show_verbose_info = MagicMock()
position = {
"x": np.random.random(),
"y": np.random.random(),
"z": np.random.random(),
}
stage_controller.set_position_silent(position)
for axis in position.keys():
assert float(stage_controller.widget_vals[axis].get()) == position[axis]
widgets[axis].widget.trigger_focusout_validation.assert_called_once()
assert stage_controller.stage_setting_dict[axis] == position.get(axis, 0)
stage_controller.show_verbose_info.assert_has_calls([call("Set stage position")])
assert (
call("Stage position changed")
not in stage_controller.show_verbose_info.mock_calls
)
@pytest.mark.parametrize(
"flip_x, flip_y",
[(False, False), (True, False), (True, True), (False, True), (True, True)],
)
def test_stage_key_press(stage_controller, flip_x, flip_y):
microscope_name = (
stage_controller.parent_controller.configuration_controller.microscope_name
)
stage_config = stage_controller.parent_controller.configuration["configuration"][
"microscopes"
][microscope_name]["stage"]
stage_config["flip_x"] = flip_x
stage_config["flip_y"] = flip_y
stage_controller.initialize()
x = round(np.random.random(), 1)
y = round(np.random.random(), 1)
increment = round(np.random.random() + 1, 1)
stage_controller.widget_vals["xy_step"].get = MagicMock(return_value=increment)
stage_controller.widget_vals["x"].get = MagicMock(return_value=x)
stage_controller.widget_vals["x"].set = MagicMock()
stage_controller.widget_vals["y"].get = MagicMock(return_value=y)
stage_controller.widget_vals["y"].set = MagicMock()
event = MagicMock()
axes_map = {"w": "y", "a": "x", "s": "y", "d": "x"}
for char, xs, ys in zip(
["w", "a", "s", "d"],
[0, -increment, 0, increment],
[increment, 0, -increment, 0],
):
event.char = char
# <a> instead of <Control+a>
event.state = 0
axis = axes_map[char]
if axis == "x":
temp = x + xs * (-1 if flip_x else 1)
else:
temp = y + ys * (-1 if flip_y else 1)
stage_controller.stage_key_press(event)
stage_controller.widget_vals[axis].set.assert_called_once_with(temp)
stage_controller.widget_vals[axis].set.reset_mock()
stage_controller.widget_vals[axis].get.reset_mock()
stage_controller.widget_vals["xy_step"].get.reset_mock()
stage_config["flip_x"] = False
stage_config["flip_y"] = False
def test_get_position(stage_controller):
import tkinter as tk
vals = {}
for axis in AXES:
vals[axis] = np.random.randint(0, 9)
stage_controller.widget_vals[axis].get = MagicMock(return_value=vals[axis])
step_vals = {}
for axis in CAXES:
step_vals[axis] = np.random.randint(1, 9)
stage_controller.widget_vals[axis + "_step"].get = MagicMock(
return_value=step_vals[axis]
)
stage_controller.position_min = pos_dict(0)
stage_controller.position_max = pos_dict(10)
position = stage_controller.get_position()
assert position == {k: vals[k] for k in AXES}
stage_controller.position_min = pos_dict(2)
vals = {}
for axis in AXES:
vals[axis] = np.random.choice(
np.concatenate((np.arange(-9, 0), np.arange(10, 20)))
)
stage_controller.widget_vals[axis].get = MagicMock(return_value=vals[axis])
position = stage_controller.get_position()
assert position is None
stage_controller.widget_vals["x"].get.side_effect = tk.TclError
position = stage_controller.get_position()
assert position is None
@pytest.mark.parametrize(
"flip_x, flip_y, flip_z",
[
(False, False, False),
(True, False, False),
(True, True, False),
(False, True, True),
(True, True, True),
],
)
def test_up_btn_handler(stage_controller, flip_x, flip_y, flip_z):
microscope_name = (
stage_controller.parent_controller.configuration_controller.microscope_name
)
stage_config = stage_controller.parent_controller.configuration["configuration"][
"microscopes"
][microscope_name]["stage"]
stage_config["flip_x"] = flip_x
stage_config["flip_y"] = flip_y
stage_config["flip_z"] = flip_z
stage_controller.initialize()
flip_flags = (
stage_controller.parent_controller.configuration_controller.stage_flip_flags
)
vals = {}
for axis in AXES:
vals[axis] = np.random.randint(1, 9)
stage_controller.widget_vals[axis].get = MagicMock(return_value=vals[axis])
stage_controller.widget_vals[axis].set = MagicMock()
step_vals = {}
for axis in CAXES:
step_vals[axis] = np.random.randint(1, 9)
stage_controller.widget_vals[axis + "_step"].get = MagicMock(
return_value=step_vals[axis]
)
stage_controller.position_max = pos_dict(10)
# Test for each axis
for axis in AXES:
pos = stage_controller.widget_vals[axis].get()
if axis == "x" or axis == "y":
step = stage_controller.widget_vals["xy_step"].get()
else:
step = stage_controller.widget_vals[axis + "_step"].get()
temp = pos + step * (-1 if flip_flags[axis] else 1)
if temp > stage_controller.position_max[axis]:
temp = stage_controller.position_max[axis]
stage_controller.up_btn_handler(axis)()
stage_controller.widget_vals[axis].set.assert_called_once_with(temp)
# Test for out of limit condition
for axis in AXES:
stage_controller.widget_vals[axis].set.reset_mock()
stage_controller.widget_vals[axis].get.return_value = 10
stage_controller.up_btn_handler(axis)()
if flip_flags[axis] is False:
stage_controller.widget_vals[axis].set.assert_not_called()
stage_config["flip_x"] = False
stage_config["flip_y"] = False
stage_config["flip_z"] = False
@pytest.mark.parametrize(
"flip_x, flip_y, flip_z",
[
(False, False, False),
(True, False, False),
(True, True, False),
(False, True, True),
(True, True, True),
],
)
def test_down_btn_handler(stage_controller, flip_x, flip_y, flip_z):
microscope_name = (
stage_controller.parent_controller.configuration_controller.microscope_name
)
stage_config = stage_controller.parent_controller.configuration["configuration"][
"microscopes"
][microscope_name]["stage"]
stage_config["flip_x"] = flip_x
stage_config["flip_y"] = flip_y
stage_config["flip_z"] = flip_z
stage_controller.initialize()
flip_flags = (
stage_controller.parent_controller.configuration_controller.stage_flip_flags
)
vals = {}
for axis in AXES:
vals[axis] = np.random.randint(1, 9)
stage_controller.widget_vals[axis].get = MagicMock(return_value=vals[axis])
stage_controller.widget_vals[axis].set = MagicMock()
step_vals = {}
for axis in CAXES:
step_vals[axis] = np.random.randint(1, 9)
stage_controller.widget_vals[axis + "_step"].get = MagicMock(
return_value=step_vals[axis]
)
stage_controller.position_min = pos_dict(0)
# Test for each axis
for axis in AXES:
pos = stage_controller.widget_vals[axis].get()
if axis == "x" or axis == "y":
step = stage_controller.widget_vals["xy_step"].get()
else:
step = stage_controller.widget_vals[axis + "_step"].get()
temp = pos - step * (-1 if flip_flags[axis] else 1)
if temp < stage_controller.position_min[axis]:
temp = stage_controller.position_min[axis]
stage_controller.down_btn_handler(axis)()
stage_controller.widget_vals[axis].set.assert_called_once_with(temp)
# Test for out of limit condition
for axis in ["x", "y", "z", "theta", "f"]:
stage_controller.widget_vals[axis].set.reset_mock()
stage_controller.widget_vals[axis].get.return_value = 0
stage_controller.down_btn_handler(axis)()
if flip_flags[axis] is False:
stage_controller.widget_vals[axis].set.assert_not_called()
stage_config["flip_x"] = False
stage_config["flip_y"] = False
stage_config["flip_z"] = False
def test_stop_button_handler(stage_controller):
stage_controller.view.after = MagicMock()
stage_controller.stop_button_handler()
stage_controller.view.after.assert_called_once()
def test_position_callback(stage_controller):
stage_controller.show_verbose_info = MagicMock()
stage_controller.view.after = MagicMock()
vals = {}
widgets = stage_controller.view.get_widgets()
for axis in AXES:
vals[axis] = np.random.randint(1, 9)
stage_controller.widget_vals[axis].get = MagicMock(return_value=vals[axis])
stage_controller.widget_vals[axis].set = MagicMock()
widgets[axis].widget.set(vals[axis])
widgets[axis].widget.trigger_focusout_validation = MagicMock()
stage_controller.position_min = pos_dict(0)
stage_controller.position_max = pos_dict(10)
stage_controller.stage_setting_dict = {}
for axis in AXES:
callback = stage_controller.position_callback(axis)
# Test case 1: Position variable is within limits
widgets[axis].widget.get = MagicMock(return_value=vals[axis])
callback()
stage_controller.view.after.assert_called()
stage_controller.view.after.reset_mock()
assert stage_controller.stage_setting_dict[axis] == vals[axis]
# Test case 2: Position variable is outside limits
widgets[axis].widget.get = MagicMock(return_value=11)
callback()
stage_controller.view.after.assert_called_once()
stage_controller.view.after.reset_mock()

View File

@@ -0,0 +1,215 @@
import random
import pytest
@pytest.fixture(scope="module")
def tiling_wizard_controller(dummy_view, dummy_controller):
from navigate.view.popups.tiling_wizard_popup2 import TilingWizardPopup
from navigate.controller.sub_controllers.tiling import (
TilingWizardController,
)
tiling_wizard = TilingWizardPopup(dummy_view)
class SubController:
def __init__(self):
self.parent_controller = dummy_controller
return TilingWizardController(tiling_wizard, SubController())
def test_traces(tiling_wizard_controller):
"""TODO: Find a way to access the actual lambda functions.
If we can, inspect.getsource(myfunc) should provide us the lambda definition.
"""
def assert_one_trace(var):
tinfo = var.trace_info()
assert len(tinfo) >= 1
assert tinfo[0][0][0] == "write"
assert "lambda" in tinfo[0][1]
for ax in ["x", "y", "z", "f"]:
# self.variables["x_start"], etc. should all be bound to two lambda functions
# calling calculate_distance() and update_fov()
for bound in ["start", "end"]:
tinfo = tiling_wizard_controller.variables[f"{ax}_{bound}"].trace_info()
assert len(tinfo) >= 1
for ti in tinfo:
assert ti[0][0] == "write"
assert "lambda" in ti[1]
# fov should be bound to one lambda, calling calculate_tiles()
assert_one_trace(tiling_wizard_controller.variables[f"{ax}_fov"])
# dist should be bound to one lambda, calling calculate_tiles()
assert_one_trace(tiling_wizard_controller.variables[f"{ax}_dist"])
# Special cases
assert_one_trace(tiling_wizard_controller.variables["percent_overlap"])
assert_one_trace(
tiling_wizard_controller.cam_settings_widgets["FOV_X"].get_variable()
)
assert_one_trace(
tiling_wizard_controller.cam_settings_widgets["FOV_Y"].get_variable()
)
assert_one_trace(
tiling_wizard_controller.stack_acq_widgets["start_position"].get_variable()
)
assert_one_trace(
tiling_wizard_controller.stack_acq_widgets["end_position"].get_variable()
)
# Channels tab controller binds these a bunch
# assert_one_trace(
# tiling_wizard_controller.stack_acq_widgets["start_focus"].get_variable()
# )
# assert_one_trace(
# tiling_wizard_controller.stack_acq_widgets["end_focus"].get_variable()
# )
def test_update_total_tiles(tiling_wizard_controller):
tiling_wizard_controller.update_total_tiles()
assert True
@pytest.mark.parametrize("axis", ["x", "y", "z", "f"])
def test_calculate_tiles(tiling_wizard_controller, axis):
from navigate.tools.multipos_table_tools import calc_num_tiles
ov, dist, fov = random.random(), random.random() * 100, random.random() * 10
tiling_wizard_controller._percent_overlap = ov * 100
tiling_wizard_controller.variables[f"{axis}_dist"].set(dist)
tiling_wizard_controller.variables[f"{axis}_fov"].set(fov)
tiling_wizard_controller.calculate_tiles(axis)
if axis == "x" or axis == "y":
dist += fov
assert int(
tiling_wizard_controller.variables[f"{axis}_tiles"].get()
) == calc_num_tiles(dist, ov, fov)
@pytest.mark.parametrize("axis", ["x", "y", "z", "f"])
def test_calculate_distance(tiling_wizard_controller, axis):
start, end = random.random() * 10, random.random() * 100
tiling_wizard_controller.variables[axis + "_start"].set(start)
tiling_wizard_controller.variables[axis + "_end"].set(end)
tiling_wizard_controller.calculate_distance(axis)
assert float(tiling_wizard_controller.variables[axis + "_dist"].get()) == abs(
start - end
)
def test_update_overlap(tiling_wizard_controller):
tiling_wizard_controller.variables["percent_overlap"].set("")
tiling_wizard_controller.update_overlap()
tiling_wizard_controller.variables["percent_overlap"].set("10")
tiling_wizard_controller.update_overlap()
assert True
@pytest.mark.parametrize("axis", ["x", "y", "z", "f"])
def test_update_fov(tiling_wizard_controller, axis):
import random
from navigate.tools.multipos_table_tools import sign
if axis == "y":
tiling_wizard_controller.cam_settings_widgets["FOV_X"].set(
int(random.random() * 1000)
)
tiling_wizard_controller.variables["x_start"].set(random.random() * 10)
tiling_wizard_controller.variables["x_end"].set(random.random() * 1000)
var = float(
tiling_wizard_controller.cam_settings_widgets["FOV_X"].get()
) * sign(
float(tiling_wizard_controller.variables["x_end"].get())
- float(tiling_wizard_controller.variables["x_start"].get())
)
elif axis == "x":
tiling_wizard_controller.cam_settings_widgets["FOV_Y"].set(
int(random.random() * 1000)
)
tiling_wizard_controller.variables["y_start"].set(random.random() * 10)
tiling_wizard_controller.variables["y_end"].set(random.random() * 1000)
var = float(
tiling_wizard_controller.cam_settings_widgets["FOV_Y"].get()
) * sign(
float(tiling_wizard_controller.variables["y_end"].get())
- float(tiling_wizard_controller.variables["y_start"].get())
)
elif axis == "z":
tiling_wizard_controller.stack_acq_widgets["start_position"].set(
random.random() * 10
)
tiling_wizard_controller.stack_acq_widgets["end_position"].set(
random.random() * 1000
)
var = float(
tiling_wizard_controller.stack_acq_widgets["end_position"].get()
) - float(tiling_wizard_controller.stack_acq_widgets["start_position"].get())
elif axis == "f":
tiling_wizard_controller.stack_acq_widgets["start_focus"].set(
random.random() * 10
)
tiling_wizard_controller.stack_acq_widgets["end_focus"].set(
random.random() * 1000
)
var = float(
tiling_wizard_controller.stack_acq_widgets["end_focus"].get()
) - float(tiling_wizard_controller.stack_acq_widgets["start_focus"].get())
tiling_wizard_controller.update_fov(axis)
assert float(tiling_wizard_controller.variables[f"{axis}_fov"].get()) == abs(var)
def test_set_table(tiling_wizard_controller):
# from navigate.tools.multipos_table_tools import compute_tiles_from_bounding_box
tiling_wizard_controller.set_table()
x_start = float(tiling_wizard_controller.variables["x_start"].get())
x_stop = float(tiling_wizard_controller.variables["x_end"].get())
y_start = float(tiling_wizard_controller.variables["y_start"].get())
y_stop = float(tiling_wizard_controller.variables["y_end"].get())
# shift z by coordinate origin of local z-stack
z_start = float(tiling_wizard_controller.variables["z_start"].get()) - float(
tiling_wizard_controller.stack_acq_widgets["start_position"].get()
)
z_stop = float(tiling_wizard_controller.variables["z_end"].get()) - float(
tiling_wizard_controller.stack_acq_widgets["end_position"].get()
)
# Default to fixed theta
r_start = tiling_wizard_controller.stage_position_vars["theta"].get()
r_stop = tiling_wizard_controller.stage_position_vars["theta"].get()
f_start = float(tiling_wizard_controller.variables["f_start"].get()) - float(
tiling_wizard_controller.stack_acq_widgets["start_focus"].get()
)
f_stop = float(tiling_wizard_controller.variables["f_end"].get()) - float(
tiling_wizard_controller.stack_acq_widgets["end_focus"].get()
)
# for consistency, always go from low to high
def sort_vars(a, b):
if a > b:
return b, a
return a, b
x_start, x_stop = sort_vars(x_start, x_stop)
y_start, y_stop = sort_vars(y_start, y_stop)
z_start, z_stop = sort_vars(z_start, z_stop)
r_start, r_stop = sort_vars(r_start, r_stop)
f_start, f_stop = sort_vars(f_start, f_stop)
assert tiling_wizard_controller.multipoint_table.model.df["X"].min() == x_start
assert tiling_wizard_controller.multipoint_table.model.df["Y"].min() == y_start
assert tiling_wizard_controller.multipoint_table.model.df["Z"].min() == z_start
assert tiling_wizard_controller.multipoint_table.model.df["THETA"].min() == r_start
assert tiling_wizard_controller.multipoint_table.model.df["F"].min() == f_start

View File

@@ -0,0 +1,178 @@
import pytest
import random
from unittest.mock import MagicMock
@pytest.fixture(scope="module")
def waveform_popup_controller(dummy_view, dummy_controller):
from navigate.controller.sub_controllers.waveform_popup import (
WaveformPopupController,
)
from navigate.view.popups.waveform_parameter_popup_window import (
WaveformParameterPopupWindow,
)
waveform_constants_popup = WaveformParameterPopupWindow(
dummy_view, dummy_controller.configuration_controller
)
return WaveformPopupController(
waveform_constants_popup,
dummy_controller,
dummy_controller.waveform_constants_path,
)
def test_populate_experiment_values(waveform_popup_controller):
exp_dict = waveform_popup_controller.parent_controller.configuration["experiment"][
"MicroscopeState"
]
resolution = exp_dict["microscope_name"]
zoom = exp_dict["zoom"]
waveform_constants = waveform_popup_controller.parent_controller.configuration[
"waveform_constants"
]
widgets = waveform_popup_controller.view.get_widgets()
def assert_widget_values():
resolution = exp_dict["microscope_name"]
zoom = exp_dict["zoom"]
assert widgets["Mode"].get() == resolution
assert widgets["Mag"].get() == zoom
# remote focus
remote_focus_dict = waveform_constants["remote_focus_constants"][resolution][
zoom
]
for k in remote_focus_dict.keys():
assert widgets[k + " Amp"].get() == remote_focus_dict[k]["amplitude"]
assert widgets[k + " Off"].get() == remote_focus_dict[k]["offset"]
# galvo
galvo_dict = waveform_constants["galvo_constants"]
for g in galvo_dict.keys():
if resolution in [galvo_dict[g].keys()]:
galvo_info = galvo_dict[g][resolution][zoom]
assert widgets[g + " Amp"].get() == galvo_info["amplitude"]
assert widgets[g + " Off"].get() == galvo_info["offset"]
# delay, fly back time, settle duraation, smoothing
assert widgets["Delay"].get() == str(
waveform_constants["other_constants"]["remote_focus_delay"]
)
assert widgets["Ramp_falling"].get() == str(
waveform_constants["other_constants"]["remote_focus_ramp_falling"]
)
assert widgets["Duty"].get() == str(
waveform_constants["other_constants"]["remote_focus_settle_duration"]
)
assert widgets["Smoothing"].get() == str(
waveform_constants["other_constants"]["percent_smoothing"]
)
# default values
waveform_popup_controller.populate_experiment_values()
assert_widget_values()
# change resolution and/or zoom
for microscope_name in waveform_constants["remote_focus_constants"].keys():
for z in waveform_constants["remote_focus_constants"][microscope_name].keys():
exp_dict["microscope_name"] = microscope_name
exp_dict["zoom"] = z
waveform_popup_controller.populate_experiment_values()
assert_widget_values()
exp_dict["microscope_name"] = resolution
exp_dict["zoom"] = zoom
waveform_popup_controller.populate_experiment_values()
assert_widget_values()
# update waveform_constants
for k in waveform_constants["remote_focus_constants"][resolution][zoom].keys():
amplitude = round(random.random() * 5, 2)
offset = round(random.random() * 5, 2)
temp = waveform_constants["remote_focus_constants"][resolution][zoom][k]
temp["amplitude"] = amplitude
temp["offset"] = offset
# update galvo
for g in waveform_constants["galvo_constants"].keys():
amplitude = round(random.random() * 5, 2)
offset = round(random.random() * 5, 2)
temp = waveform_constants["galvo_constants"][g][resolution][zoom]
temp["amplitude"] = amplitude
temp["offset"] = offset
for k in [
"remote_focus_ramp_falling",
"remote_focus_settle_duration",
"percent_smoothing",
"remote_focus_delay",
]:
waveform_constants["other_constants"][k] = round(random.random() * 100, 2)
waveform_popup_controller.populate_experiment_values(force_update=True)
assert_widget_values()
def test_show_laser_info(waveform_popup_controller):
waveform_popup_controller.show_laser_info()
assert True
def test_configure_widget_range(waveform_popup_controller):
waveform_popup_controller.configure_widget_range()
assert True
def test_estimate_galvo_setting_empty_string(waveform_popup_controller):
"""Test if the function returns without calling the camera setting controller."""
# Galvo name
galvo_name = "galvo_0"
# Mocked camera setting controller
waveform_popup_controller.parent_controller.camera_setting_controller = MagicMock()
waveform_popup_controller.parent_controller.camera_setting_controller.mode_widgets[
"Pixels"
].get = MagicMock(return_value="")
waveform_popup_controller.parent_controller.camera_setting_controller.framerate_widgets[
"exposure_time"
].get = MagicMock()
waveform_popup_controller.estimate_galvo_setting(galvo_name)
waveform_popup_controller.parent_controller.camera_setting_controller.framerate_widgets[
"exposure_time"
].get.assert_not_called()
def test_estimate_galvo_setting_with_string(waveform_popup_controller):
"""Test if the function calls the camera setting controller."""
# Galvo name
galvo_name = "galvo_0"
number_of_pixels = 50
# Mocked camera setting controller
waveform_popup_controller.parent_controller.camera_setting_controller = MagicMock()
waveform_popup_controller.parent_controller.camera_setting_controller.mode_widgets[
"Pixels"
].get = MagicMock(return_value=str(number_of_pixels))
waveform_popup_controller.parent_controller.camera_setting_controller.framerate_widgets[
"exposure_time"
].get = MagicMock()
# Mocked model
waveform_popup_controller.parent_controller.model = MagicMock()
mock_model = waveform_popup_controller.parent_controller.model
mock_model.get_camera_line_interval_and_exposure_time = MagicMock(
return_value=(0.05, 50, 500)
)
# Mocked view
waveform_popup_controller.view = MagicMock()
waveform_popup_controller.view.inputs[galvo_name].widget.set = MagicMock()
# Call the function
waveform_popup_controller.estimate_galvo_setting(galvo_name)
# Check to see what the view was called with.
waveform_popup_controller.view.inputs[galvo_name].widget.set.assert_called_once()

View File

@@ -0,0 +1,787 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, ANY
import pytest
import numpy
import multiprocessing as mp
import logging
import platform
from logging.handlers import QueueHandler
from navigate.log_files.log_functions import log_setup
class _NullQueue:
"""Minimal queue-like sink for logging; avoids mp feeder threads on Windows."""
def put(self, _): # QueueHandler calls .put()
pass
def close(self):
pass
def cancel_join_thread(self):
pass
class DummySplashScreen:
def destroy(self):
pass
def _normalize_log_setup(start_listener):
"""Call log_setup and normalize its return to (log_queue, log_listener)."""
from navigate.log_files.log_functions import log_setup
try:
res = log_setup("logging.yml", logging_path=None, start_listener=start_listener)
except Exception:
res = None
# Accept (queue, listener), or queue-only, or None.
if isinstance(res, tuple) and len(res) == 2:
return res[0], res[1]
if res is not None and hasattr(res, "put"):
return res, None
# Fallbacks:
if platform.system() == "Windows":
# Avoid mp.Queue on Windows to prevent hangs in CI.
return _NullQueue(), None
# Non-Windows: a real mp.Queue is fine without a listener.
try:
return mp.Queue(), None
except Exception:
return _NullQueue(), None
def _remove_queue_handlers(target_queue=None):
# Detach and close any QueueHandler that targets target_queue.
def strip_handlers(logger):
for h in list(getattr(logger, "handlers", [])):
if isinstance(h, QueueHandler) and (
target_queue is None or getattr(h, "queue", None) is target_queue
):
try:
logger.removeHandler(h)
except Exception:
pass
try:
h.close()
except Exception:
pass
try:
strip_handlers(logging.getLogger())
for name, obj in logging.root.manager.loggerDict.items():
if isinstance(obj, logging.Logger):
strip_handlers(logging.getLogger(name))
except Exception:
pass
@pytest.fixture(scope="module")
def controller(tk_root):
from navigate.controller.controller import Controller
base_directory = Path.joinpath(
Path(__file__).resolve().parent.parent.parent, "src", "navigate"
)
configuration_directory = Path.joinpath(base_directory, "config")
configuration_path = Path.joinpath(configuration_directory, "configuration.yaml")
experiment_path = Path.joinpath(configuration_directory, "experiment.yml")
waveform_constants_path = Path.joinpath(
configuration_directory, "waveform_constants.yml"
)
rest_api_path = Path.joinpath(configuration_directory, "rest_api_config.yml")
waveform_templates_path = Path.joinpath(
configuration_directory, "waveform_templates.yml"
)
gui_configuration_path = Path.joinpath(
configuration_directory, "gui_configuration.yml"
)
multi_positions_path = Path.joinpath(configuration_directory, "multi_positions.yml")
args = SimpleNamespace(synthetic_hardware=True)
start_listener = platform.system() != "Windows"
log_queue, log_listener = _normalize_log_setup(start_listener)
controller = Controller(
root=tk_root,
splash_screen=DummySplashScreen(),
configuration_path=configuration_path,
experiment_path=experiment_path,
waveform_constants_path=waveform_constants_path,
rest_api_path=rest_api_path,
waveform_templates_path=waveform_templates_path,
gui_configuration_path=gui_configuration_path,
multi_positions_path=multi_positions_path,
log_queue=log_queue,
args=args,
)
# To make sure the testcases won't hang on because of the model.event_queue
# The changes here won't affect other testcases,
# because the testcases from other files use DummyController
# and DummyModel instead of this controller fixture
controller.model = MagicMock()
controller.threads_pool = MagicMock()
controller.model.get_offset_variance_maps.return_value = (None, None)
yield controller
try:
controller.execute("exit")
except SystemExit:
pass
# Tear down the controller properly
q = getattr(controller, "event_queue", None)
if q is not None:
try:
q.close()
except Exception:
pass
try:
q.cancel_join_thread()
except Exception:
pass
# Close any Pipes
if getattr(controller, "show_img_pipe", None):
try:
controller.show_img_pipe.close()
except Exception:
pass
try:
controller.manager.shutdown()
except Exception:
pass
# Detach QueueHandlers first so no more puts go to log_queue
_remove_queue_handlers(log_queue)
# Stop the queue listener (only if started)
try:
if start_listener and log_listener:
try:
log_listener.enqueue_sentinel()
except Exception:
pass
try:
log_listener.stop()
except Exception:
pass
except Exception:
pass
# Close the logging queue and skip join on Windows
if platform.system() == "Windows":
try:
log_queue.close()
except Exception:
pass
try:
log_queue.cancel_join_thread()
except Exception:
pass
logging.shutdown()
# As a last resort on Windows, hard-terminate any alive mp children
if platform.system() == "Windows":
try:
children = list(mp.active_children())
for p in children:
try:
p.terminate()
except Exception:
pass
for p in children:
try:
p.join(timeout=5)
except Exception:
pass
except Exception:
pass
def test_update_buffer(controller):
camera_parameters = controller.configuration["experiment"]["CameraParameters"]
controller.update_buffer()
assert controller.img_width == camera_parameters["img_x_pixels"]
assert controller.img_height == camera_parameters["img_y_pixels"]
# Make sure that the get_data_buffer method is not called.
assert controller.model.get_data_buffer.called is False
# Change the buffer size
microscope_name = controller.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
controller.configuration["experiment"]["CameraParameters"][microscope_name][
"img_x_pixels"
] = 100
controller.configuration["experiment"]["CameraParameters"][microscope_name][
"img_y_pixels"
] = 100
controller.update_buffer()
# Make sure that the get_data_buffer method is called.
assert controller.model.get_data_buffer.called is True
# Confirm that the buffer size has been updated.
assert controller.img_width == 100
assert controller.img_height == 100
assert True
def test_change_microscope(controller):
# Get the microscopes from the configuration file
microscopes = controller.configuration["configuration"]["microscopes"]
# Iterate through the microscopes and change the microscope
for microscope_name in microscopes.keys():
# Patch the configuration_controller
controller.configuration_controller.change_microscope = MagicMock()
# Default zoom is '0.63x'
zoom = microscopes[microscope_name]["zoom"]["position"].keys()[0]
controller.configuration["experiment"]["MicroscopeState"]["zoom"] = zoom
# Change the microscope without passing the zoom
controller.change_microscope(microscope_name)
assert (
controller.configuration["experiment"]["MicroscopeState"]["microscope_name"]
== microscope_name
)
# Confirm that the zoom has not changed.
assert controller.configuration["experiment"]["MicroscopeState"]["zoom"] == zoom
# Call it and pass the zoom
zoom = microscopes[microscope_name]["zoom"]["position"].keys()[-1]
controller.change_microscope(microscope_name, zoom)
assert controller.configuration["experiment"]["MicroscopeState"]["zoom"] == zoom
# Make sure that the configuration_controller has been called.
assert controller.configuration_controller.change_microscope.called is True
# Have configuration controller return False
controller.configuration_controller.change_microscope.return_value = False
# Patch the stage controller, channels_tab_controller...
controller.stage_controller.initialize = MagicMock()
controller.channels_tab_controller.initialize = MagicMock()
camera_setting_controller = controller.camera_setting_controller
camera_setting_controller.update_camera_device_related_setting = MagicMock()
camera_setting_controller.calculate_physical_dimensions = MagicMock()
controller.camera_view_controller.update_snr = MagicMock()
# Call change microscope, assert patched methods are not called
controller.change_microscope(microscope_name)
assert controller.stage_controller.initialize.called is False
assert controller.channels_tab_controller.initialize.called is False
assert (
camera_setting_controller.update_camera_device_related_setting.called
is False
)
assert camera_setting_controller.calculate_physical_dimensions.called is False
assert controller.camera_view_controller.update_snr.called is False
# Have configuration controller return True
controller.configuration_controller.change_microscope.return_value = True
# Call change microscope, assert patched methods are called
controller.change_microscope(microscope_name)
assert controller.stage_controller.initialize.called is True
assert controller.channels_tab_controller.initialize.called is True
assert (
camera_setting_controller.update_camera_device_related_setting.called
is True
)
assert camera_setting_controller.calculate_physical_dimensions.called is True
assert controller.camera_view_controller.update_snr.called is True
# Test waveform popup controller.
controller.waveform_popup_controller = MagicMock()
controller.change_microscope(microscope_name)
assert (
controller.waveform_popup_controller.populate_experiment_values.called
is True
)
assert True
def test_initialize_cam_view(controller):
minmax_values = [0, 2**16 - 1]
image_metrics = [1, 0, 0]
controller.initialize_cam_view()
assert (
controller.camera_view_controller.image_palette["Min"].get() == minmax_values[0]
)
assert (
controller.camera_view_controller.image_palette["Max"].get() == minmax_values[1]
)
assert (
controller.camera_view_controller.image_metrics["Frames"].get()
== image_metrics[0]
)
assert True
def test_populate_experiment_setting(controller):
controller.populate_experiment_setting(in_initialize=False)
assert True
def test_prepare_acquire_data(controller):
# Test without warning message
controller.set_mode_of_sub = MagicMock()
assert controller.prepare_acquire_data() is True
assert controller.set_mode_of_sub.called is True
# Test with warning message. Challenging since controller is local.
# with patch('controller.tkinter.messagebox.showerror') as mock_showerror:
# controller.update_experiment_setting.return_value = "Warning!"
# assert controller.prepare_acquire_data() is False
# mock_showerror.assert_called_once()
assert True
def test_set_mode_of_sub(controller):
modes = ["customized", "stop", "live"]
for mode in modes:
controller.set_mode_of_sub(mode=mode)
assert True
def test_execute_stop_stage(controller):
controller.threads_pool.createThread = MagicMock()
controller.execute(command="stop_stage")
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_move_stage_and_update_info(controller):
positions = {"x": 51, "y": 52.0, "z": -530.3, "theta": 1, "f": 0}
controller.execute("move_stage_and_update_info", positions)
for axis, value in positions.items():
assert (
float(controller.stage_controller.widget_vals[axis].get())
== positions[axis]
)
assert True
def test_execute_move_stage_and_acquire_image(controller):
positions = {"x": 51, "y": 52.0, "z": -530.3, "theta": 1, "f": 0}
controller.model.move_stage = MagicMock()
controller.threads_pool.createThread = MagicMock()
controller.execute("move_stage_and_acquire_image", positions)
assert controller.model.move_stage.called is True
for axis, value in positions.items():
assert (
float(controller.stage_controller.widget_vals[axis].get())
== positions[axis]
)
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_get_stage_position(controller):
# Set the positions in the GUI
set_positions = {"x": 51, "y": 52.0, "z": -530.3, "theta": 1, "f": 0}
controller.execute("move_stage_and_update_info", set_positions)
# Get the positions from the GUI
get_positions = controller.execute("get_stage_position")
assert type(get_positions) is dict
axes = ["x", "y", "z", "theta", "f"]
for axis in axes:
assert axis in get_positions.keys()
# assert that get_positions is equal to set_positions
assert get_positions == set_positions
assert True
def test_execute_mark_position(controller):
set_positions = {"x": 51, "y": 52.0, "z": -530.3, "theta": 1, "f": 0}
controller.execute("mark_position", set_positions)
# Get the positions from the multiposition table. Returns a list of lists.
get_positions = controller.multiposition_tab_controller.get_positions()
# Assert that the last position in get_positions is equal to set_positions
assert get_positions[-1] == [
set_positions["x"],
set_positions["y"],
set_positions["z"],
set_positions["theta"],
set_positions["f"],
]
assert True
def test_execute_resolution(controller):
pass
def test_execute_set_save(controller):
for save_data in [True, False]:
controller.execute("set_save", save_data)
assert controller.acquire_bar_controller.is_save == save_data
assert (
controller.configuration["experiment"]["MicroscopeState"]["is_save"]
== save_data
)
assert True
def test_execute_update_setting(controller):
controller.threads_pool.createThread = MagicMock()
args = ["resolution", {"resolution_mode", "1x"}]
controller.execute("update_setting", args)
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_stage_limits(controller):
controller.threads_pool.createThread = MagicMock()
for stage_limits in [True, False]:
controller.threads_pool.createThread.reset_mock()
controller.execute("stage_limits", stage_limits)
assert controller.stage_controller.stage_limits == stage_limits
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_autofocus(controller):
# Create mock objects
controller.threads_pool.createThread = MagicMock()
# Test non-acquiring case
controller.acquire_bar_controller.is_acquiring = False
controller.execute("autofocus")
controller.threads_pool.createThread.assert_called_with(
resourceName="camera",
target=controller.capture_image,
args=("autofocus", "live"),
)
# Test the acquiring case
controller.acquire_bar_controller.mode = "live"
controller.acquire_bar_controller.is_acquiring = True
controller.threads_pool.createThread.reset_mock()
controller.execute("autofocus")
controller.threads_pool.createThread.assert_called_once()
controller.threads_pool.createThread.assert_any_call(
resourceName="model", target=ANY
)
args, kwargs = controller.threads_pool.createThread.call_args
assert kwargs["resourceName"] == "model"
# Confirm that the lambda is callable.
assert callable(kwargs["target"])
assert True
def test_execute_eliminate_tiles(controller):
controller.threads_pool.createThread = MagicMock()
# Populate Feature List
controller.menu_controller.feature_list_names = ["Remove Empty Tiles"]
# Set the mode to live
controller.acquire_bar_controller.set_mode("live")
# Execute the command
controller.execute("eliminate_tiles")
assert controller.acquire_bar_controller.get_mode() == "customized"
# Assert that the thread pool is called
assert controller.threads_pool.createThread.called is True
assert True
def test_execute_load_features(controller):
controller.threads_pool.createThread = MagicMock()
controller.execute("load_features")
controller.threads_pool.createThread.assert_any_call("model", ANY)
args, kwargs = controller.threads_pool.createThread.call_args
assert args[0] == "model"
assert callable(args[1])
assert True
def test_execute_acquire_and_save_return(controller):
# Prepare mock objects.
controller.prepare_acquire_data = MagicMock()
controller.acquire_bar_controller.stop_acquire = MagicMock()
controller.camera_setting_controller.calculate_physical_dimensions = MagicMock()
# Prepare mock returns
controller.prepare_acquire_data.return_value = False
# Test and make sure return is called
controller.execute("acquire_and_save")
assert controller.acquire_bar_controller.stop_acquire.called is True
assert (
controller.camera_setting_controller.calculate_physical_dimensions.called
is False
)
assert True
def test_execute_acquire_and_acquire_and_save(controller):
# The modes "customized" & "live" results in the thread not being called.
# TODO: Figure out why the thread is not being called.
# controller.plugin_acquisition_modes = {}
# controller.threads_pool.createThread = MagicMock()
#
# for statement in ["acquire", "acquire_and_save"]:
# for mode in ["z-stack", "single"]:
# controller.acquire_bar_controller.mode = mode
# controller.execute(statement)
# controller.threads_pool.createThread.assert_called_with(
# "camera",
# controller.capture_image,
# args=(
# "acquire",
# controller.acquire_bar_controller.mode,
# ),
# )
# controller.stop_acquisition_flag = True
# controller.threads_pool.createThread.reset_mock()
pass
def test_execute_stop_acquire(controller):
# TODO: Currently hangs indefinitely.
# Prepare mock objects.
# controller.show_img_pipe.poll = MagicMock()
# controller.show_img_pipe.recv = MagicMock()
# controller.sloppy_stop = MagicMock()
# controller.threads_pool.createThread = MagicMock()
#
# # Prepare mock returns
# controller.show_img_pipe.poll.return_value = True
#
# # Test and make sure return is called
# controller.stop_acquisition_flag = False
# controller.execute("acquire", "continuous")
# controller.execute("stop_acquire")
pass
def test_execute_exit(controller):
# Essentially already tested by teardown of controller fixture.
pass
def test_execute_adaptive_optics(controller):
controller.threads_pool.createThread = MagicMock()
for command in [
"flatten_mirror",
"zero_mirror",
"set_mirror",
"set_mirror_from_wcs",
]:
controller.execute(command)
controller.threads_pool.createThread.assert_called_with("model", ANY)
args, kwargs = controller.threads_pool.createThread.call_args
assert args[0] == "model"
assert callable(args[1])
controller.execute("tony_wilson")
controller.threads_pool.createThread.assert_called_with(
"camera", controller.capture_image, args=("tony_wilson", "live")
)
def test_execute_random(controller):
controller.threads_pool.createThread = MagicMock()
for command in ["random1", "random2"]:
controller.execute(command)
controller.threads_pool.createThread.assert_called_with("model", ANY)
args, kwargs = controller.threads_pool.createThread.call_args
assert args[0] == "model"
assert callable(args[1])
assert True
def test_capture_image(controller):
count = 0
def get_image_id():
nonlocal count
count += 1
if count >= 10:
return "stop"
return numpy.random.randint(0, 10)
microscope_name = controller.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
width = controller.configuration["experiment"]["CameraParameters"][microscope_name][
"img_x_pixels"
]
height = controller.configuration["experiment"]["CameraParameters"][
microscope_name
]["img_y_pixels"]
images = numpy.random.rand(10, width, height)
controller.data_buffer = images
work_thread = MagicMock()
work_thread.join = MagicMock()
controller.threads_pool.createThread = MagicMock()
controller.threads_pool.createThread.return_value = work_thread
controller.show_img_pipe.recv = get_image_id
controller.show_img_pipe.poll = MagicMock()
controller.show_img_pipe.poll.return_value = False
# Deal with stop_acquire
controller.sloppy_stop = MagicMock()
controller.menu_controller.feature_id_val.set = MagicMock()
# Deal with camera view controller trying to launch a thread
controller.camera_view_controller.is_displaying_image = MagicMock()
controller.camera_view_controller.is_displaying_image.return_value = True
for command in ["acquire"]: # "autofocus"
for mode in ["continuous", "live", "z-stack", "single"]:
controller.capture_image(command, mode)
# Evaluate calls
controller.threads_pool.createThread.assert_called_with("model", ANY)
args, kwargs = controller.threads_pool.createThread.call_args
assert args[0] == "model"
assert callable(args[1])
controller.stop_acquisition_flag = True
controller.threads_pool.createThread.reset_mock()
assert controller.acquire_bar_controller.framerate != 0
assert controller.camera_setting_controller.framerate_widgets[
"max_framerate"
].get() != str(0)
assert True
def test_launch_additional_microscope():
# This looks awful to test...
pass
def test_move_stage(controller):
pos_dict = {"x": 1, "y": 2.0, "z": 3.14, "theta": 400, "f": 5.01}
controller.model.move_stage = MagicMock()
controller.move_stage(pos_dict)
controller.model.move_stage.assert_called_with(pos_dict)
def test_stop_stage(controller):
controller.model.stop_stage = MagicMock()
controller.stop_stage()
controller.model.stop_stage.assert_called_with()
def test_update_stage_controller_silent(controller):
pos_dict = {"x": 1, "y": 2.0, "z": 3.14, "theta": 400, "f": 5.01}
controller.update_stage_controller_silent(pos_dict)
for axis, value in pos_dict.items():
assert (
float(controller.stage_controller.widget_vals[axis].get()) == pos_dict[axis]
)
@pytest.mark.parametrize(
"acquisition_mode, sensor_mode, readout_direction, template_name, "
"expected_template_name",
[
("live", "Normal", "", "Bidirectional", "Default"),
("z-stack", "Normal", "", "Bidirectional", "Default"),
("customized", "Normal", "", "Bidirectional", "Bidirectional"),
("live", "Light-Sheet", "Top-To-Bottom", "Bidirectional", "Default"),
("live", "Light-Sheet", "Bidirectional", "Bidirectional", "Bidirectional"),
(
"customized",
"Light-Sheet",
"Bidirectional",
"Bidirectional",
"Bidirectional",
),
("z-stack", "Light-Sheet", "Bidirectional", "Default", "Bidirectional"),
("z-stack", "Light-Sheet", "Top-To-Bottom", "Default", "Default"),
],
)
def test_waveform_template(
controller,
acquisition_mode,
sensor_mode,
readout_direction,
template_name,
expected_template_name,
):
controller.configuration["experiment"]["MicroscopeState"][
"waveform_template"
] = template_name
controller.configuration["experiment"]["MicroscopeState"][
"image_mode"
] = acquisition_mode
microscope_name = controller.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
controller.configuration["experiment"]["CameraParameters"][microscope_name][
"number_of_pixels"
] = 10
controller.populate_experiment_setting(in_initialize=True)
controller.camera_setting_controller.mode_widgets["Readout"].set(readout_direction)
controller.camera_setting_controller.mode_widgets["Sensor"].set(sensor_mode)
controller.update_experiment_setting()
assert (
controller.configuration["experiment"]["MicroscopeState"]["waveform_template"]
== expected_template_name
)
assert True

View File

@@ -0,0 +1,63 @@
# 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.
from pathlib import Path
import pytest
@pytest.mark.parametrize("logging_configuration", ["logging.yml"])
@pytest.mark.parametrize("logging_path", [None, Path("./")])
def test_log_setup(logging_configuration, logging_path):
from datetime import datetime
from navigate.log_files.log_functions import log_setup
from navigate.config.config import get_navigate_path
time = datetime.now()
time_stamp = Path(
"%s-%s-%s-%s%s"
% (
f"{time.year:04d}",
f"{time.month:02d}",
f"{time.day:02d}",
f"{time.hour:02d}",
f"{time.minute:02d}",
)
)
if logging_path is None:
logging_path = Path.joinpath(Path(get_navigate_path()), "logs")
todays_path = Path.joinpath(logging_path, time_stamp)
log_setup(logging_configuration, logging_path, queue=False)
assert Path.joinpath(todays_path, "debug.log").is_file()

0
test/model/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,112 @@
# 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 math
import numpy as np
def im_circ(r=1, N=128):
X, Y = np.meshgrid(range(-N // 2, N // 2), range(-N // 2, N // 2))
return (X * X + Y * Y) < r * r
def test_has_tissue():
from navigate.model.analysis.boundary_detect import has_tissue
for _ in range(100):
N = 2 ** np.random.randint(5, 9)
r = np.random.randint(math.ceil(0.2 * N), int(0.4 * N))
ds = np.random.randint(1, 6)
print(N, r, ds)
im = im_circ(r, N) * 1001
mu, sig = 100 * np.random.rand() + 1, 10 * np.random.rand() + 1
print(mu, sig)
offsets = [None, np.ones((N, N)) * mu]
variances = [None, np.ones((N, N)) * sig]
for off, var in zip(offsets, variances):
assert has_tissue(im, 0, 0, N, off, var) and not has_tissue(
im, 0, 0, N // 2 - r, off, var
)
def test_find_tissue_boundary_2d():
from skimage.transform import downscale_local_mean
from navigate.model.analysis.boundary_detect import find_tissue_boundary_2d
for _ in range(100):
N = 2 ** np.random.randint(5, 9)
r = np.random.randint(1, int(0.4 * N))
ds = np.random.randint(1, 6)
print(N, r, ds)
im = im_circ(r, N)
b = find_tissue_boundary_2d(im, ds)
b = np.vstack([x for x in b if x is not None])
idx_x, idx_y = np.where(downscale_local_mean(im, (ds, ds)))
iixy = (np.unique(idx_x)[:, None] == idx_x[None, :]) * idx_y
low, high = idx_y[np.argmax(iixy != 0, 1)], np.max(iixy, 1)
np.testing.assert_equal(b, np.vstack([low, high]).T)
def test_binary_detect():
from navigate.model.analysis.boundary_detect import (
find_tissue_boundary_2d,
binary_detect,
)
for _ in range(100):
N = 2 ** np.random.randint(5, 9)
r = np.random.randint(1, int(0.4 * N))
ds = np.random.randint(1, 6)
print(N, r, ds)
im = im_circ(r, N)
b = find_tissue_boundary_2d(im, ds)
assert binary_detect(im * 1001, b, ds) == b
def test_map_boundary():
from navigate.model.analysis.boundary_detect import map_boundary
assert map_boundary([[1, 2]]) == [(0, 1), (0, 2)]
assert map_boundary([None, [1, 2]]) == [(1, 1), (1, 2)]
assert map_boundary([None, [1, 2], None]) == [(1, 1), (1, 2)]

View File

@@ -0,0 +1,56 @@
import numpy as np
import pytest
@pytest.mark.skip("volatile")
def test_compute_scmos_offset_and_variance_map():
from navigate.model.analysis.camera import compute_scmos_offset_and_variance_map
mu, sig = 100 * np.random.rand() + 1, 100 * np.random.rand() + 1
im = sig * np.random.randn(256, 256, 256) + mu
offset, variance = compute_scmos_offset_and_variance_map(im)
print(mu, sig)
# TODO: 1 is a bit high?
np.testing.assert_allclose(offset, mu, rtol=1)
np.testing.assert_allclose(variance, sig * sig, rtol=1)
@pytest.mark.parametrize("local", [True, False])
def test_compute_flatfield_map(local):
from navigate.model.analysis.camera import compute_flatfield_map
image = np.ones((256, 256))
offset = np.zeros((256, 256))
ffmap = compute_flatfield_map(image, offset, local)
np.testing.assert_allclose(ffmap, 0.5)
def test_compute_noise_sigma():
from navigate.model.analysis.camera import compute_noise_sigma
Fn = np.random.rand()
qe = np.random.rand()
S = np.random.rand(256, 256)
Ib = np.random.rand()
Nr = np.random.rand()
M = np.random.rand()
sigma = compute_noise_sigma(Fn=Fn, qe=qe, S=S, Ib=Ib, Nr=Nr, M=M)
sigma_true = np.sqrt(Fn * Fn * qe * (S + Ib) + (Nr / M) ** 2)
np.testing.assert_allclose(sigma, sigma_true)
def test_compute_signal_to_noise():
from navigate.model.analysis.camera import compute_signal_to_noise
A = np.random.rand() * 100 + 10
image = A * np.ones((256, 256))
offset = np.zeros((256, 256))
variance = 3 * A * A * np.ones((256, 256))
snr = compute_signal_to_noise(image, offset, variance)
np.testing.assert_allclose(snr, 0.5, rtol=0.2)

View File

View File

@@ -0,0 +1,758 @@
# Standard Library Imports
import threading
from multiprocessing import shared_memory
# Third Party Imports
import numpy as np
import pytest
# Local Imports
from navigate.model.concurrency.concurrency_tools import (
ObjectInSubprocess,
ResultThread,
CustodyThread,
_WaitingList,
SharedNDArray,
)
def time_it(
n_loops, func, args=None, kwargs=None, fail=True, timeout_us=None, name=None
):
"""Useful for testing the performance of a specific function.
Args:
- n_loops <int> | number of loops to test
- func <callable> | function/method to test
- args/kwargs | arguments to the function
- fail <bool> | Allow the method to raise an exception?
- timeout_us <int/float> | If the average duration exceeds this
limit, raise a TimeoutError.
- name <str> | formatted name for the progress bar.
"""
import time
try:
from tqdm import tqdm
except ImportError:
tqdm = None # No progress bars :(
if args is None:
args = ()
if kwargs is None:
kwargs = {}
if tqdm is not None:
f = "{desc: <38}{n: 7d}-{bar:17}|[{rate_fmt}]"
pb = tqdm(total=n_loops, desc=name, bar_format=f)
start = time.perf_counter()
for i in range(n_loops):
if tqdm is not None:
pb.update(1)
try:
func(*args, **kwargs)
except Exception as e:
if fail:
raise e
else:
pass
end = time.perf_counter()
if tqdm is not None:
pb.close()
time_per_loop_us = ((end - start) / n_loops) * 1e6
if timeout_us is not None:
if time_per_loop_us > timeout_us:
name = func.__name__ if name is None else name
raise TimeoutError(
f"Timed out on {name}\n"
f" args:{args}\n"
f" kwargs: {kwargs}\n"
f" Each loop took {time_per_loop_us:.2f} \u03BCs"
f" (Allowed: {timeout_us:.2f} \u03BCs)"
)
return time_per_loop_us
def test_subclassed_threading_types():
r_th = ResultThread(target=lambda: 1)
c_th = CustodyThread(target=lambda custody: 1)
assert isinstance(r_th, threading.Thread)
assert isinstance(c_th, threading.Thread)
assert isinstance(r_th, ResultThread)
assert isinstance(c_th, ResultThread)
assert isinstance(c_th, CustodyThread)
def test_threadlike_behavior():
th = ResultThread(target=lambda: 1)
th.start()
th.join()
assert not th.is_alive()
def test_new_start_behavior():
th = ResultThread(target=lambda: 1)
_th = th.start()
assert isinstance(_th, ResultThread)
assert th is _th
def test_getting_result():
th = ResultThread(target=lambda: 1).start()
assert hasattr(th, "_return")
th.join()
assert th.get_result() == 1
assert th.get_result() == 1, "Couldn't get result twice!"
def test_passing_args_and_kwargs():
def mirror(*args, **kwargs):
return args, kwargs
a = (1,)
k = dict(a=1)
th = ResultThread(target=mirror, args=a, kwargs=k).start()
_a, _k = th.get_result()
assert a == _a, f"{a} != {_a}"
assert k == _k, f"{k} != {_k}"
# def test_catching_exception():
# def e():
# raise ValueError("Don't worry, this exception occurred on purpose!")
# th = ResultThread(target=e).start()
# th.join() # join won't reraise exception in main thread
# assert hasattr(th, 'exc_value')
# try:
# th.get_result()
# except ValueError:
# pass
# else:
# raise AssertionError("We didn't get the exception we expected...")
# # We should be able to reraise this exception as long as we have
# # a reference to it:
# try:
# th.get_result()
# except ValueError:
# pass
# else:
# raise AssertionError("We didn't get the exception we expected...")
def test_custody_thread_target_args():
# CustodyThread accepts a target with a kwarg 'custody'
def custody_f(custody=None):
return 1
CustodyThread(target=custody_f, first_resource=None).start()
# CustodyThread accepts a target with a positional arg 'custody'
def custody_f(custody):
return 1
CustodyThread(target=custody_f, first_resource=None).start()
# CustodyThread will otherwise raise a ValueError
def f():
return 1
try:
CustodyThread(target=f, first_resource=None).start()
except ValueError:
pass # We expect this
else:
raise AssertionError("We didn't get the exception we expected...")
def f(a):
return 1
try:
CustodyThread(target=f, first_resource=None).start()
except ValueError:
pass # We expect this
else:
raise AssertionError("We didn't get the exception we expected...")
def f(a=1):
return 1
try:
CustodyThread(target=f, first_resource=None).start()
except ValueError:
pass # We expect this
else:
raise AssertionError("We didn't get the exception we expected...")
def test_providing_first_resource():
resource = _WaitingList()
mutable_variables = {"step": 0, "progress": 0}
def f(custody):
while mutable_variables["step"] == 0:
pass
custody.switch_from(None, resource)
mutable_variables["progress"] += 1
while mutable_variables["step"] == 1:
pass
custody.switch_from(resource, None)
mutable_variables["progress"] += 1
return
try:
th = CustodyThread(target=f, first_resource=resource).start()
assert hasattr(th, "custody"), "Should have a custody attribute."
assert not th.custody.has_custody, "Should not have custody yet."
assert th.custody.target_resource is resource, "Should be in line."
# Make target thread progress one step and acquire custody
mutable_variables["step"] = 1
while mutable_variables["progress"] == 0:
pass # Wait for thread
assert th.custody.has_custody, "Should have gotten custody."
assert th.custody.target_resource is resource
# Make target thread progress one step, release custody, and exit
mutable_variables["step"] = 2
while mutable_variables["progress"] == 1:
pass # Wait for thread
assert not th.custody.has_custody
assert th.custody.target_resource is None
th.join()
finally: # if anything goes wrong, make sure the thread exits
mutable_variables["step"] = -1
def test_subclassed_numpy_array_types():
a = SharedNDArray(shape=(1,), dtype="uint8")
assert isinstance(a, SharedNDArray)
assert isinstance(a, np.ndarray)
assert type(a) is SharedNDArray, type(a)
assert type(a) is not np.ndarray
assert hasattr(a, "shared_memory")
assert isinstance(a.shared_memory, shared_memory.SharedMemory)
del a
def test_ndarraylike_behavior():
"""Testing if we broke how an ndarray is supposed to behave."""
ri = np.random.randint # Just to get short lines
original_dimensions = (3, 3, 3, 256, 256)
a = SharedNDArray(shape=original_dimensions, dtype="uint8")
c = ri(0, 255, original_dimensions, dtype="uint8")
a[:] = c # Fill 'a' with 'c's random values
# A slice should still share memory
view_by_slice = a[:1, 2:3, ..., :10, 100:-100]
assert isinstance(a, SharedNDArray)
assert type(a) is type(view_by_slice)
assert np.may_share_memory(a, view_by_slice)
assert a.shared_memory is view_by_slice.shared_memory
# Some functions should not return a SharedNDArray
b = a.sum(axis=-1)
assert isinstance(b, np.ndarray), type(b)
assert not isinstance(b, SharedNDArray)
b = a + 1
assert isinstance(b, np.ndarray), type(b)
assert not isinstance(b, SharedNDArray), type(b)
b = a.sum()
assert np.isscalar(b)
assert not isinstance(b, SharedNDArray)
del a
def test_serialization():
"""Testing serializing/deserializing a SharedNDArray"""
import pickle
ri = np.random.randint # Just to get short lines
original_dimensions = (3, 3, 3, 256, 256)
a = SharedNDArray(shape=original_dimensions, dtype="uint8")
c = ri(0, 255, original_dimensions, dtype="uint8")
a[:] = c # Fill 'a' with 'c's random values
view_by_slice = a[:1, 2:3, ..., :10, 100:-100]
view_of_a_view = view_by_slice[..., 1:, 10:-10:3]
_a = pickle.loads(pickle.dumps(a))
assert _a.sum() == a.sum()
assert np.array_equal(a, _a)
_view_by_slice = pickle.loads(pickle.dumps(view_by_slice))
assert _view_by_slice.sum() == view_by_slice.sum()
assert np.array_equal(_view_by_slice, view_by_slice)
_view_of_a_view = pickle.loads(pickle.dumps(view_of_a_view))
assert _view_of_a_view.sum() == view_of_a_view.sum()
assert np.array_equal(_view_of_a_view, view_of_a_view)
del a
def test_viewcasting():
a = SharedNDArray(shape=(1,))
v = a.view(np.ndarray)
assert isinstance(v, np.ndarray), type(v)
assert not isinstance(v, SharedNDArray), type(v)
a = np.zeros(shape=(1,))
try:
v = a.view(SharedNDArray)
del a
except ValueError:
pass # we expected this
else:
raise AssertionError("We didn't raise the correct exception!")
def test_auto_unlinking_memory():
import gc
a = SharedNDArray(shape=(1,))
name = str(a.shared_memory.name) # Really make sure we don't get a ref
del a
gc.collect() # Now memory should be unlinked
try:
shared_memory.SharedMemory(name=name)
except FileNotFoundError:
pass # This is the error we expected if the memory was unlinked.
else:
raise AssertionError("We didn't raise the correct exception!")
# Views should prevent deallocation
a = SharedNDArray(shape=(10,))
v = a[:5]
name = str(a.shared_memory.name) # Really make sure we don't get a ref
del a
gc.collect()
v.sum() # Should still be able to interact with 'v'
shared_memory.SharedMemory(name=name) # Memory not unlinked yet
del v
gc.collect() # Now memory should be unlinked
try:
shared_memory.SharedMemory(name=name)
except FileNotFoundError:
pass # This is the error we expected if the memory was unlinked.
else:
raise AssertionError("We didn't raise the correct exception!")
def test_accessing_unlinked_memory_during_deserialization():
import pickle
original_dimensions = (3, 3, 3, 256, 256)
a = SharedNDArray(shape=original_dimensions, dtype="uint8")
string_of_a = pickle.dumps(a)
del a
try:
_a = pickle.loads(string_of_a)
except FileNotFoundError:
pass # We expected this error
else:
raise AssertionError("Did not get the error we expected")
def test_accessing_unlinked_memory_in_subprocess():
p = ObjectInSubprocess(TestClass)
original_dimensions = (3, 3, 3, 256, 256)
a = SharedNDArray(shape=original_dimensions, dtype="uint8")
p.store_array(a)
p.a.sum()
try:
# close and unlink the memory
del a
# try to access the memory
p.a.sum()
except FileNotFoundError:
pass # we expected this error
else:
import os
if os.name == "nt":
# This is allowed on Windows. Windows will keep memory
# allocated until all references have been lost from every
# process.
pass
else:
# However, on posix systems, we expect the system to unlink
# the memory once the process that originally allocated it
# loses all references to the array.
raise AssertionError("Did not get the error we expected")
@pytest.mark.flaky(reruns=3, reruns_delay=2)
def test_serializing_and_deserializing():
"""Test serializing/deserializing arrays with random shapes, dtypes, and
slicing operators.
"""
for i in range(500):
_trial_slicing_of_shared_array()
def _trial_slicing_of_shared_array():
import pickle
ri = np.random.randint # Just to get short lines
dtype = np.dtype(
np.random.choice([int, np.uint8, np.uint16, float, np.float32, np.float64])
)
original_dimensions = tuple(ri(2, 100) for d in range(ri(2, 5)))
slicer = tuple(
slice(ri(0, a // 2), ri(0, a // 2) * -1, ri(1, min(6, a)))
for a in original_dimensions
)
a = SharedNDArray(shape=original_dimensions, dtype=dtype)
a.fill(0)
b = a[slicer] # Should be a view
b.fill(1)
expected_total = int(b.sum())
reloaded_total = int(pickle.loads(pickle.dumps(b)).sum())
assert (
expected_total == reloaded_total
), f"Failed {dtype.name}/{original_dimensions}/{slicer}"
del a
# class TestObjectInSubprocess(unittest.TestCase):
class TestClass:
"""Toy class that can be put in a subprocess for testing."""
def __init__(self, *args, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
for i, a in enumerate(args):
setattr(self, f"arg_{i}", a)
def printing_method(self, *args, **kwargs):
print(*args, **kwargs)
def get_attribute(self, attr):
return getattr(self, attr, None)
def mirror(self, *args, **kwargs):
return args, kwargs
def black_hole(self, *args, **kwargs):
return None
def get_shape_of_numpy_array(self, ndarray):
return ndarray.shape
def fill_and_return_array(self, a, i=1):
a.fill(i)
return a
def sleep(self, seconds):
import time
time.sleep(seconds)
def return_slice(self, a, *args):
return a[args]
def sum(self, a):
return a.sum()
def store_array(self, a):
self.a = a
def nested_method(self, crash=False):
self._nested_method(crash)
def _nested_method(self, crash):
if crash:
raise ValueError("This error was supposed to be raised")
# def test_create_and_close_object_in_subprocess():
# import gc
# p = ObjectInSubprocess(TestClass)
# child_process = p._.child_process
# del p
# gc.collect()
# child_process.join(timeout=1)
# assert not child_process.is_alive()
# # Other objects to finalize can cause some strange behavior
# weakref.finalize({1,}, _dummy_function)
# p = ObjectInSubprocess(TestClass)
# child_process = p._.child_process
# # Trigger ref count increase (ref in handled exception tb)
# hasattr(p, 'attribute_that_does_not_exist')
# del p
# gc.collect()
# child_process.join(timeout=1)
# assert not child_process.is_alive()
def test_create_and_close_object_in_subprocess():
import gc
p = ObjectInSubprocess(TestClass)
dummy_namespace = p._
del p
gc.collect()
dummy_namespace.child_process.join(timeout=1)
assert not dummy_namespace.child_process.is_alive()
def test_passing_normal_numpy_array():
a = np.zeros((3, 3), dtype=int)
p = ObjectInSubprocess(TestClass)
(_a,), _ = p.mirror(a)
assert np.array_equal(a, _a), f"{a} != {_a} ({a.dtype}|{_a.dtype}"
del p
def test_passing_modifying_and_retrieving_shared_array():
a = SharedNDArray(shape=(10, 10), dtype=int)
p = ObjectInSubprocess(TestClass)
b = p.fill_and_return_array(a, 1)
assert np.array_equal(a, b)
del a
del p
def test_attribute_access():
p = ObjectInSubprocess(TestClass, "attribute", x=4)
assert p.x == 4
assert getattr(p, "arg_0") == "attribute"
try:
p.z
del p
except Exception as e: # Get __this__ specific error
print("Expected attribute error handled by parent process:\n ", e)
else:
raise AssertionError("Did not get the error we expected")
def test_printing_in_child_processes():
a = ObjectInSubprocess(TestClass)
b = ObjectInSubprocess(TestClass)
expected_output = ""
b.printing_method("Hello")
expected_output += "Hello\n"
a.printing_method("Hello from subprocess a.")
expected_output += "Hello from subprocess a.\n"
b.printing_method("Hello from subprocess b.")
expected_output += "Hello from subprocess b.\n"
a.printing_method("Hello world", end=", ", flush=True)
expected_output += "Hello world, "
b.printing_method("Hello world!", end="", flush=True)
expected_output += "Hello world!"
del a
del b
return expected_output
def test_setting_attribute_of_object_in_subprocess():
p = ObjectInSubprocess(TestClass)
try:
hasattr(p, "z")
except Exception:
pass
else:
# We already have z
assert False
p.z = 10
assert hasattr(p, "z")
assert p.z == 10
setattr(p, "z", 100)
assert p.z == 100
assert p.get_attribute("z") == 100
del p
def test_array_values_after_passing_to_subprocess():
p = ObjectInSubprocess(TestClass)
a = SharedNDArray(shape=(10, 1))
a[:] = 1
assert a.sum() == p.sum(a)
del p
del a
def test_object_in_subprocess_overhead():
"""Test the overhead of accessing ObjectInSubprocess methods/attributes.
TODO: 200 is supposed to be 100 and 400 is supposed to be 200 us. Why is
this so slow?
"""
print("Performance summary:")
n_loops = 10000
p = ObjectInSubprocess(TestClass, x=4)
t = time_it(n_loops, lambda: p.x, timeout_us=200, name="Attribute access") # noqa
print(f" {t:.2f} \u03BCs per get-attribute.")
t = time_it(
n_loops,
lambda: setattr(p, "x", 5), # noqa
timeout_us=200,
name="Attribute setting",
)
print(f" {t:.2f} \u03BCs per set-attribute.")
t = time_it(
n_loops, lambda: p.z, fail=False, timeout_us=400, name="Attribute error" # noqa
)
print(f" {t:.2f} \u03BCs per parent-handled exception.")
t = time_it(n_loops, p.mirror, timeout_us=200, name="Trivial method call")
print(f" {t:.2f} \u03BCs per trivial method call.")
_test_passing_array_performance()
del p
def _test_passing_array_performance():
"""Test the performance of passing random arrays to/from
ObjectInSubprocess.
"""
from itertools import product
pass_by = ["reference", "serialization"]
methods = ["black_hole", "mirror"]
shapes = [(10, 10), (1000, 1000)]
for s, pb, m in product(shapes, pass_by, methods):
_test_array_passing(s, pb, m, "uint8", 1000)
def _test_array_passing(shape, pass_by, method_name, dtype, n_loops):
dtype = np.dtype(dtype)
direction = "<->" if method_name == "mirror" else "->"
name = f"{shape} array {direction} {pass_by}"
shm_obj = ObjectInSubprocess(TestClass)
if pass_by == "reference":
a = SharedNDArray(shape, dtype=dtype)
timeout_us = 5e3
elif pass_by == "serialization":
a = np.zeros(shape=shape, dtype=dtype)
timeout_us = 1e6
func = getattr(shm_obj, method_name)
t_per_loop = time_it(n_loops, func, (a,), timeout_us=timeout_us, name=name)
print(f" {t_per_loop:.2f} \u03BCs per {name}")
def test_lock_with_waitlist():
"""Test that CustodyThreads stay in order while using resources.
ObjectsInSubprocess are just mocked as _WaitingList objects.
"""
import time
try:
from tqdm import tqdm
except ImportError:
tqdm = None # No progress bars :(
camera_lock = _WaitingList()
display_lock = _WaitingList()
num_snaps = 100
usage_record = {"camera": [], "display": []}
if tqdm is not None:
pbars = {
resource: tqdm(
total=num_snaps,
bar_format="{desc: <30}{n: 3d}-{bar:45}|",
desc=f"Threads waiting on {resource}",
)
for resource in usage_record.keys()
}
def snap(i, custody):
if tqdm is not None:
pbars["camera"].update(1)
if tqdm is not None:
pbars["camera"].refresh()
# We're already in line for the camera; wait until we're first
custody.switch_from(None, camera_lock)
# Pretend to use the resource
time.sleep(0.02)
usage_record["camera"].append(i)
custody.switch_from(camera_lock, display_lock, wait=False)
if tqdm is not None:
pbars["camera"].update(-1)
if tqdm is not None:
pbars["camera"].refresh()
if tqdm is not None:
pbars["display"].update(1)
if tqdm is not None:
pbars["display"].refresh()
custody._wait_in_line()
# Pretend to use the resource
time.sleep(0.05)
usage_record["display"].append(i)
# Move to the next resource
custody.switch_from(display_lock, None)
if tqdm is not None:
pbars["display"].update(-1)
if tqdm is not None:
pbars["display"].refresh()
return None
threads = []
for i in range(num_snaps):
threads.append(
CustodyThread(target=snap, first_resource=camera_lock, args=(i,)).start()
)
for th in threads:
th.get_result()
if tqdm is not None:
for pb in pbars.values():
pb.close()
assert usage_record["camera"] == list(range(num_snaps))
assert usage_record["display"] == list(range(num_snaps))
def test_incorrect_thread_management():
"""Test accessing an object in a subprocess from multiple threads
without using a custody object. This is expected to raise a
RunTimeError.
"""
p = ObjectInSubprocess(TestClass)
exceptions = []
def unsafe_fn():
try:
p.sleep(0.1) # noqa
except RuntimeError: # Should raise this sometimes
exceptions.append(1)
threads = [threading.Thread(target=unsafe_fn) for i in range(20)]
for th in threads:
th.start()
for th in threads:
th.join()
assert len(exceptions) == 19, "This should have raised some exceptions."
del p
def test_sending_shared_arrays():
"""Testing sending a SharedNDArray to a ObjectInSubprocess."""
p = ObjectInSubprocess(TestClass)
original_dimensions = (3, 3, 3, 256, 256)
a = SharedNDArray(shape=original_dimensions, dtype="uint8")
(_a,), _ = p.mirror(a)
assert isinstance(_a, SharedNDArray)
assert _a.shared_memory.name == a.shared_memory.name
assert _a.offset == a.offset
assert _a.strides == a.strides
_a = p.fill_and_return_array(a, 1)
assert isinstance(_a, SharedNDArray)
assert _a.shared_memory.name == a.shared_memory.name
assert _a.offset == a.offset
assert _a.strides == a.strides
_a = p.return_slice(a, slice(1, -1), ..., slice(3, 100, 10))
assert isinstance(_a, SharedNDArray)
assert _a.shared_memory.name == a.shared_memory.name
assert _a.offset != a.offset
assert _a.strides != a.strides
del p
del a

View File

@@ -0,0 +1,269 @@
import os
import pytest
import numpy as np
import h5py
from navigate.tools.file_functions import delete_folder
def recurse_dtype(group):
for key, subgroup in group.items():
subgroup_type = type(subgroup)
if subgroup_type == h5py._hl.group.Group:
recurse_dtype(subgroup)
elif subgroup_type == h5py._hl.dataset.Dataset:
if key == "resolutions":
assert subgroup.dtype == "float64"
elif key == "subdivisions":
assert subgroup.dtype == "int32"
elif key == "cells":
assert subgroup.dtype == "uint16"
else:
print("Unknown how to handle:", key, subgroup_type)
def bdv_ds(fn, multiposition, per_stack, z_stack, stop_early, size):
from test.model.dummy import DummyModel
from navigate.model.data_sources.bdv_data_source import BigDataViewerDataSource
print(
f"Conditions are multiposition: {multiposition} per_stack: {per_stack} "
f"z_stack: {z_stack} stop_early: {stop_early}"
)
# Set up model with a random number of z-steps to modulate the shape
model = DummyModel()
z_steps = np.random.randint(1, 3)
timepoints = np.random.randint(1, 3)
x_size, y_size = size
model.configuration["experiment"]["CameraParameters"]["x_pixels"] = x_size
model.configuration["experiment"]["CameraParameters"]["y_pixels"] = y_size
model.img_width = x_size
model.img_height = y_size
model.configuration["experiment"]["MicroscopeState"]["image_mode"] = (
"z-stack" if z_stack else "single"
)
model.configuration["experiment"]["MicroscopeState"]["number_z_steps"] = z_steps
model.configuration["experiment"]["MicroscopeState"][
"is_multiposition"
] = multiposition
model.configuration["experiment"]["MicroscopeState"]["timepoints"] = timepoints
model.configuration["experiment"]["BDVParameters"] = {
"shear": {
"shear_data": True,
"shear_dimension": "YZ",
"shear_angle": 45,
},
"rotate": {
"rotate_data": False,
"X": 0,
"Y": 0,
"Z": 0,
},
"down_sample": {
"down_sample": False,
"axial_down_sample": 1,
"lateral_down_sample": 1,
},
}
if per_stack:
model.configuration["experiment"]["MicroscopeState"][
"stack_cycling_mode"
] = "per_stack"
else:
model.configuration["experiment"]["MicroscopeState"][
"stack_cycling_mode"
] = "per_slice"
# Establish a BDV data source
ds = BigDataViewerDataSource(fn)
ds.set_metadata_from_configuration_experiment(model.configuration)
# Populate one image per channel per timepoint
n_images = ds.shape_c * ds.shape_z * ds.shape_t * ds.positions
print(
f"x: {ds.shape_x} y: {ds.shape_y} z: {ds.shape_z} c: {ds.shape_c} "
f"t: {ds.shape_t} positions: {ds.positions} per_stack: {ds.metadata.per_stack}"
)
data = (np.random.rand(n_images, ds.shape_y, ds.shape_x) * 2**16).astype("uint16")
dbytes = np.sum(
ds.shapes.prod(1) * ds.shape_t * ds.shape_c * ds.positions * 2
) # 2 bytes per pixel (16-bit)
assert dbytes == ds.nbytes
data_positions = (np.random.rand(n_images, 5) * 50e3).astype(float)
for i in range(n_images):
ds.write(
data[i, ...].squeeze(),
x=data_positions[i, 0],
y=data_positions[i, 1],
z=data_positions[i, 2],
theta=data_positions[i, 3],
f=data_positions[i, 4],
)
if stop_early and np.random.rand() > 0.5:
break
return ds
def close_bdv_ds(ds, file_name=None):
ds.close()
if file_name is None:
file_name = ds.file_name
# Delete
try:
xml_fn = os.path.splitext(file_name)[0] + ".xml"
if os.path.isdir(file_name):
# n5 is a directory
delete_folder(file_name)
else:
os.remove(file_name)
os.remove(xml_fn)
except PermissionError:
# Windows seems to think these files are still open
pass
@pytest.mark.parametrize("multiposition", [True, False])
@pytest.mark.parametrize("per_stack", [True, False])
@pytest.mark.parametrize("z_stack", [True, False])
@pytest.mark.parametrize("stop_early", [True, False])
@pytest.mark.parametrize("size", [(1024, 2048), (2048, 1024), (2048, 2048)])
@pytest.mark.parametrize("ext", ["h5", "n5"])
def test_bdv_write(multiposition, per_stack, z_stack, stop_early, size, ext):
fn = f"test.{ext}"
ds = bdv_ds(fn, multiposition, per_stack, z_stack, stop_early, size)
file_name = ds.file_name
ds.close()
# check datatypes
# todo: extend to n5
if ext == "h5":
ds = h5py.File(f"test.{ext}", "r")
for key in ds.keys():
recurse_dtype(ds[key])
close_bdv_ds(ds, file_name=file_name)
assert True
@pytest.mark.parametrize("multiposition", [True, False])
@pytest.mark.parametrize("per_stack", [True, False])
@pytest.mark.parametrize("z_stack", [True, False])
@pytest.mark.parametrize("size", [(1024, 2048), (2048, 1024), (2048, 2048)])
def test_bdv_getitem(multiposition, per_stack, z_stack, size):
ds = bdv_ds("test.h5", multiposition, per_stack, z_stack, False, size)
# Check indexing
assert ds[0, ...].shape == (
ds.positions,
ds.shape_t,
ds.shape_z,
ds.shape_c,
ds.shape_y,
1,
)
assert ds[:, 0, ...].shape == (
ds.positions,
ds.shape_t,
ds.shape_z,
ds.shape_c,
1,
ds.shape_x,
)
assert ds[:, :, 0, ...].shape == (
ds.positions,
ds.shape_t,
ds.shape_z,
1,
ds.shape_y,
ds.shape_x,
)
assert ds[:, :, :, 0, ...].shape == (
ds.positions,
ds.shape_t,
1,
ds.shape_c,
ds.shape_y,
ds.shape_x,
)
assert ds[:, :, :, :, 0, ...].shape == (
ds.positions,
1,
ds.shape_z,
ds.shape_c,
ds.shape_y,
ds.shape_x,
)
assert ds[:, :, :, :, :, 0].shape == (
1,
ds.shape_t,
ds.shape_z,
ds.shape_c,
ds.shape_y,
ds.shape_x,
)
# Check slicing
sx = 5
assert ds[:sx, ...].shape == (
ds.positions,
ds.shape_t,
ds.shape_z,
ds.shape_c,
ds.shape_y,
min(ds.shape_x, sx),
)
assert ds[:, :sx, ...].shape == (
ds.positions,
ds.shape_t,
ds.shape_z,
ds.shape_c,
min(ds.shape_y, sx),
ds.shape_x,
)
assert ds[:, :, :sx, ...].shape == (
ds.positions,
ds.shape_t,
ds.shape_z,
min(ds.shape_c, sx),
ds.shape_y,
ds.shape_x,
)
assert ds[:, :, :, :sx, ...].shape == (
ds.positions,
ds.shape_t,
min(ds.shape_z, sx),
ds.shape_c,
ds.shape_y,
ds.shape_x,
)
assert ds[:, :, :, :, :sx, ...].shape == (
ds.positions,
min(ds.shape_t, sx),
ds.shape_z,
ds.shape_c,
ds.shape_y,
ds.shape_x,
)
assert ds[:, :, :, :, :, :sx].shape == (
min(ds.positions, sx),
ds.shape_t,
ds.shape_z,
ds.shape_c,
ds.shape_y,
ds.shape_x,
)
close_bdv_ds(ds)
assert True

View File

@@ -0,0 +1,95 @@
import numpy as np
import pytest
def test_data_source_mode():
from navigate.model.data_sources.data_source import DataSource
ds = DataSource()
# set read and write
with pytest.raises(NotImplementedError):
ds.mode = "r"
assert ds.mode == "r"
ds.mode = "w"
assert ds.mode == "w"
# set unknown mode, default to read
with pytest.raises(NotImplementedError):
ds.mode = "goblin"
assert ds.mode == "r"
def test_data_source_cztp_indices():
import itertools
from navigate.model.data_sources.data_source import DataSource
MAX = 25
ds = DataSource()
ds.shape_c = np.random.randint(1, MAX)
ds.shape_z = 1
ds.shape_t = np.random.randint(1, MAX)
ds.positions = np.random.randint(1, MAX)
n_inds = ds.shape_c * ds.shape_z * ds.shape_t * ds.positions
print(f"n_inds : {n_inds}")
cztp_inds = itertools.product(
range(ds.positions), range(ds.shape_z), range(ds.shape_t), range(ds.shape_c)
)
for i, inds in zip(range(n_inds), cztp_inds):
c, z, t, p = ds._cztp_indices(i, False)
pt, zt, tt, ct = inds
assert c == ct
assert z == zt
assert t == tt
assert p == pt
print(
f"Shape (XYCZTP): {ds.shape} {ds.positions} "
f"Final (CZTP): {ds._cztp_indices(n_inds-1, False)}"
)
ds.shape_z = np.random.randint(2, MAX)
n_inds = ds.shape_c * ds.shape_z * ds.shape_t * ds.positions
cztp_inds = itertools.product(
range(ds.positions), range(ds.shape_t), range(ds.shape_z), range(ds.shape_c)
)
for i, inds in zip(range(n_inds), cztp_inds):
c, z, t, p = ds._cztp_indices(i, False)
pt, tt, zt, ct = inds
assert c == ct
assert z == zt
assert t == tt
assert p == pt
print(
f"Shape (XYCZTP): {ds.shape} {ds.positions} "
f"Final (CZTP): {ds._cztp_indices(n_inds-1, False)}"
)
cztp_inds = itertools.product(
range(ds.positions), range(ds.shape_t), range(ds.shape_c), range(ds.shape_z)
)
for i, inds in zip(range(n_inds), cztp_inds):
c, z, t, p = ds._cztp_indices(i, True)
pt, tt, ct, zt = inds
assert c == ct
assert z == zt
assert t == tt
assert p == pt
print(
f"Shape (XYCZTP): {ds.shape} {ds.positions} "
f"Final (CZTP): {ds._cztp_indices(n_inds-1, False)}"
)
# assert False

View File

@@ -0,0 +1,108 @@
import os
import pytest
from navigate.tools.file_functions import delete_folder
@pytest.mark.parametrize("is_ome", [True, False])
@pytest.mark.parametrize("multiposition", [True, False])
@pytest.mark.parametrize("per_stack", [True, False])
@pytest.mark.parametrize("z_stack", [True, False])
@pytest.mark.parametrize("stop_early", [True, False])
def test_tiff_write_read(is_ome, multiposition, per_stack, z_stack, stop_early):
import numpy as np
from test.model.dummy import DummyModel
from navigate.model.data_sources.tiff_data_source import TiffDataSource
print(
f"Conditions are is_ome: {is_ome} multiposition: {multiposition} "
f"per_stack: {per_stack} z_stack: {z_stack} stop_early: {stop_early}"
)
# Set up model with a random number of z-steps to modulate the shape
model = DummyModel()
z_steps = np.random.randint(1, 3)
timepoints = np.random.randint(1, 3)
model.configuration["experiment"]["MicroscopeState"]["image_mode"] = (
"z-stack" if z_stack else "single"
)
model.configuration["experiment"]["MicroscopeState"]["number_z_steps"] = z_steps
model.configuration["experiment"]["MicroscopeState"][
"is_multiposition"
] = multiposition
model.configuration["experiment"]["MicroscopeState"]["timepoints"] = timepoints
if per_stack:
model.configuration["experiment"]["MicroscopeState"][
"stack_cycling_mode"
] == "per_stack"
else:
model.configuration["experiment"]["MicroscopeState"][
"stack_cycling_mode"
] == "per_slice"
if not os.path.exists("test_save_dir"):
os.mkdir("test_save_dir")
# Establish a TIFF data source
if is_ome:
fn = "./test_save_dir/test.ome.tif"
else:
fn = "./test_save_dir/test.tif"
ds = TiffDataSource(fn)
ds.set_metadata_from_configuration_experiment(model.configuration)
# Populate one image per channel per timepoint per position
n_images = ds.shape_c * ds.shape_z * ds.shape_t * ds.positions
data = (np.random.rand(n_images, ds.shape_y, ds.shape_x) * 2**16).astype(
np.uint16
)
file_names_raw = []
for i in range(n_images):
ds.write(data[i, ...].squeeze())
file_names_raw.extend(ds.file_name)
if stop_early and np.random.rand() > 0.5:
break
ds.close()
# Cannot use list(set()) trick here because ordering is important
file_names = []
for fn in file_names_raw:
if fn not in file_names:
file_names.append(fn)
# print(file_names)
try:
# For each file...
for i, fn in enumerate(file_names):
ds2 = TiffDataSource(fn, "r")
# Make sure XYZ size is correct (and C and T are each of size 1)
assert (
(ds2.shape_x == ds.shape_x)
and (ds2.shape_y == ds.shape_y)
and (ds2.shape_c == 1)
and (ds2.shape_t == 1)
and (ds2.shape_z == ds.shape_z)
)
# Make sure the data copied properly
np.testing.assert_equal(
ds2.data, data[i * ds.shape_z : (i + 1) * ds.shape_z, ...].squeeze()
)
ds2.close()
except IndexError as e:
if stop_early:
# This file was not written
pass
else:
raise e
except AssertionError as e:
if stop_early:
# This file has an underfilled axes
pass
else:
raise e
except Exception as e:
raise e
finally:
delete_folder("test_save_dir")

View File

@@ -0,0 +1,152 @@
import os
import pytest
import numpy as np
try:
from pydantic import ValidationError
from pydantic_ome_ngff.v04.multiscale import Group
pydantic = True
except (ImportError, TypeError):
pydantic = False
from navigate.tools.file_functions import delete_folder
def zarr_ds(fn, multiposition, per_stack, z_stack, stop_early, size):
from test.model.dummy import DummyModel
from navigate.model.data_sources.zarr_data_source import OMEZarrDataSource
print(
f"Conditions are multiposition: {multiposition} per_stack: {per_stack} "
f"z_stack: {z_stack} stop_early: {stop_early}"
)
# Set up model with a random number of z-steps to modulate the shape
model = DummyModel()
z_steps = np.random.randint(1, 3)
timepoints = np.random.randint(1, 3)
x_size, y_size = size
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
model.configuration["experiment"]["CameraParameters"][microscope_name][
"x_pixels"
] = x_size
model.configuration["experiment"]["CameraParameters"][microscope_name][
"y_pixels"
] = y_size
model.img_width = x_size
model.img_height = y_size
model.configuration["experiment"]["MicroscopeState"]["image_mode"] = (
"z-stack" if z_stack else "single"
)
model.configuration["experiment"]["MicroscopeState"]["number_z_steps"] = z_steps
model.configuration["experiment"]["MicroscopeState"][
"is_multiposition"
] = multiposition
model.configuration["experiment"]["MicroscopeState"]["timepoints"] = timepoints
model.configuration["experiment"]["BDVParameters"] = {
"shear": {
"shear_data": True,
"shear_dimension": "YZ",
"shear_angle": 45,
},
"rotate": {
"rotate_data": False,
"X": 0,
"Y": 0,
"Z": 0,
},
"down_sample": {
"down_sample": False,
"axial_down_sample": 1,
"lateral_down_sample": 1,
},
}
if per_stack:
model.configuration["experiment"]["MicroscopeState"][
"stack_cycling_mode"
] = "per_stack"
else:
model.configuration["experiment"]["MicroscopeState"][
"stack_cycling_mode"
] = "per_slice"
# Establish a BDV data source
ds = OMEZarrDataSource(fn)
ds.set_metadata_from_configuration_experiment(model.configuration)
# Populate one image per channel per timepoint
n_images = ds.shape_c * ds.shape_z * ds.shape_t * ds.positions
print(
f"x: {ds.shape_x} y: {ds.shape_y} z: {ds.shape_z} c: {ds.shape_c} "
f"t: {ds.shape_t} positions: {ds.positions} per_stack: {ds.metadata.per_stack}"
)
data = (np.random.rand(n_images, ds.shape_y, ds.shape_x) * 2**16).astype("uint16")
dbytes = np.sum(
ds.shapes.prod(1) * ds.shape_t * ds.shape_c * ds.positions * 2
) # 2 bytes per pixel (16-bit)
assert dbytes == ds.nbytes
data_positions = (np.random.rand(n_images, 5) * 50e3).astype(float)
for i in range(n_images):
ds.write(
data[i, ...].squeeze(),
x=data_positions[i, 0],
y=data_positions[i, 1],
z=data_positions[i, 2],
theta=data_positions[i, 3],
f=data_positions[i, 4],
)
if stop_early and np.random.rand() > 0.5:
break
return ds
def close_zarr_ds(ds, file_name=None):
ds.close()
if file_name is None:
file_name = ds.file_name
# Delete
try:
if os.path.isdir(file_name):
# zarr is a directory
delete_folder(file_name)
else:
os.remove(file_name)
except PermissionError:
# Windows seems to think these files are still open
pass
@pytest.mark.parametrize("multiposition", [True, False])
@pytest.mark.parametrize("per_stack", [True, False])
@pytest.mark.parametrize("z_stack", [True, False])
@pytest.mark.parametrize("stop_early", [True, False])
@pytest.mark.parametrize("size", [(1024, 2048), (2048, 1024), (2048, 2048)])
def test_zarr_write(multiposition, per_stack, z_stack, stop_early, size):
fn = "test.zarr"
ds = zarr_ds(fn, multiposition, per_stack, z_stack, stop_early, size)
if pydantic:
try:
Group.from_zarr(ds.image)
except ValidationError as e:
print(e)
assert False
file_name = ds.file_name
close_zarr_ds(ds, file_name=file_name)
assert True

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()

647
test/model/dummy.py Normal file
View File

@@ -0,0 +1,647 @@
# 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.
from pathlib import Path
import multiprocessing as mp
from multiprocessing import Manager
import threading
import time
# Third Party Imports
import numpy as np
import random
# Local Imports
from navigate.config.config import (
load_configs,
verify_experiment_config,
verify_waveform_constants,
verify_configuration,
verify_positions_config,
)
from navigate.model.devices.camera.synthetic import (
SyntheticCamera,
SyntheticCameraController,
)
from navigate.model.features.feature_container import (
load_features,
)
from navigate.tools.file_functions import load_yaml_file
class DummyController:
"""Dummy Controller"""
def __init__(self, view):
"""Initialize the Dummy controller.
Parameters
----------
view : DummyView
The view to be controlled by this controller.
Example
-------
>>> controller = DummyController(view)
"""
from navigate.controller.configuration_controller import ConfigurationController
from navigate.controller.sub_controllers import MenuController
from navigate.controller.sub_controllers.multiposition import (
MultiPositionController,
)
from navigate.controller.sub_controllers.channels_tab import (
ChannelsTabController,
)
#: dict: The configuration dictionary.
self.configuration = DummyModel().configuration
#: list: The list of commands.
self.commands = []
#: dict: The custom events
self.event_listeners = {}
self.manager = Manager()
#: DummyView: The view to be controlled by this controller.
self.view = view
#: ConfigurationController: The configuration controller.
self.configuration_controller = ConfigurationController(self.configuration)
#: MenuController: The menu controller.
self.menu_controller = MenuController(view=self.view, parent_controller=self)
#: ChannelsTabController: The channels tab controller.
self.channels_tab_controller = ChannelsTabController(
self.view.settings.channels_tab, self
)
#: MultiPositionController: The multiposition tab controller.
self.multiposition_tab_controller = MultiPositionController(
self.view.settings.multiposition_tab.multipoint_list, self
)
#: dict: The stage positions.
self.stage_pos = {}
#: dict: The stage offset positions.
self.off_stage_pos = {}
base_directory = Path.joinpath(Path(__file__).resolve().parent.parent)
configuration_directory = Path.joinpath(base_directory, "config")
self.waveform_constants_path = Path.joinpath(
configuration_directory, "waveform_constants.yml"
)
#: bool: Flag to indicate if the resize is ready.
self.resize_ready_flag = True
def execute(self, str, *args, sec=None):
"""Execute a command.
Appends commands sent via execute,
first element is oldest command/first to pop off
Parameters
----------
str : str
The command to be executed.
sec : float
The time to wait before executing the command.
Example
-------
>>> controller.execute('move_stage', 1)
"""
self.commands.append(str)
if str in ["move_stage_and_acquire_image", "move_stage_and_update_info"]:
self.commands.append(*args)
if sec is not None:
self.commands.append(sec)
if str == "get_stage_position":
self.stage_pos["x"] = int(random.random())
self.stage_pos["y"] = int(random.random())
return self.stage_pos
def pop(self):
"""Pop the oldest command.
Use this method in testing code to grab the next command.
Returns
-------
str
The oldest command.
Example
-------
>>> controller.pop()
"""
if len(self.commands) > 0:
return self.commands.pop(0)
else:
return "Empty command list"
def clear(self):
"""Clear command list"""
self.commands = []
class DummyModel:
"""Dummy Model - This class is used to test the controller and view."""
def __init__(self):
"""Initialize the Dummy model."""
# Set up the model, experiment, waveform dictionaries
base_directory = Path(__file__).resolve().parent.parent.parent
configuration_directory = Path.joinpath(
base_directory, "src", "navigate", "config"
)
config = Path.joinpath(configuration_directory, "configuration.yaml")
experiment = Path.joinpath(configuration_directory, "experiment.yml")
waveform_constants = Path.joinpath(
configuration_directory, "waveform_constants.yml"
)
gui_configuration_path = Path.joinpath(
configuration_directory, "gui_configuration.yml"
)
multi_positions_path = Path.joinpath(
configuration_directory, "multi_positions.yml"
)
#: Manager: The manager.
self.manager = Manager()
#: dict: The configuration dictionary.
self.configuration = load_configs(
self.manager,
configuration=config,
experiment=experiment,
waveform_constants=waveform_constants,
gui=gui_configuration_path,
)
verify_configuration(self.manager, self.configuration)
verify_experiment_config(self.manager, self.configuration)
verify_waveform_constants(self.manager, self.configuration)
positions = load_yaml_file(multi_positions_path)
positions = verify_positions_config(positions)
self.configuration["multi_positions"] = positions
#: DummyDevice: The device.
self.device = DummyDevice()
#: Pipe: The pipe for sending signals.
self.signal_pipe, self.data_pipe = None, None
#: DummyMicroscope: The microscope.
self.active_microscope = DummyMicroscope(
"Mesoscale", self.configuration, devices_dict={}, is_synthetic=True
)
#: Object: The signal container.
self.signal_container = None
#: Object: The data container.
self.data_container = None
#: Thread: The signal thread.
self.signal_thread = None
#: Thread: The data thread.
self.data_thread = None
#: bool: The flag for stopping the model.
self.stop_flag = False
#: int: The frame id.
self.frame_id = 0 # signal_num
#: list: The list of data.
self.data = []
#: list: The list of signal records.
self.signal_records = []
#: list: The list of data records.
self.data_records = []
#: int: The image width.
self.img_width = int(
self.configuration["experiment"]["CameraParameters"]["x_pixels"]
)
#: int: The image height.
self.img_height = int(
self.configuration["experiment"]["CameraParameters"]["y_pixels"]
)
#: int: The number of frames in the data buffer.
self.number_of_frames = 10
#: ndarray: The data buffer.
self.data_buffer = np.zeros(
(self.number_of_frames, self.img_width, self.img_height)
)
#: ndarray: The data buffer positions.
self.data_buffer_positions = np.zeros(
shape=(self.number_of_frames, 5), dtype=float
) # z-index, x, y, z, theta, f
#: dict: The camera dictionary.
self.camera = {}
#: str: The active microscope name.
self.active_microscope_name = self.configuration["experiment"][
"MicroscopeState"
]["microscope_name"]
for k in self.configuration["configuration"]["microscopes"].keys():
self.camera[k] = SyntheticCamera(
self.active_microscope_name,
SyntheticCameraController(),
self.configuration,
)
self.camera[k].initialize_image_series(
self.data_buffer, self.number_of_frames
)
def signal_func(self):
"""Perform signal-related functionality.
This method is responsible for signal processing operations. It resets the
signal container and continues processing signals until the end flag is set.
During each iteration, it runs the signal container and communicates with
a separate process using a signal pipe. The `frame_id` is incremented after
each signal processing step.
Note
----
- The function utilizes a signal container and a signal pipe for communication.
- It terminates when the `end_flag` is set and sends a "shutdown" signal.
"""
self.signal_container.reset()
while not self.signal_container.end_flag:
if self.signal_container:
self.signal_container.run()
self.signal_pipe.send("signal")
self.signal_pipe.recv()
if self.signal_container:
self.signal_container.run(wait_response=True)
self.frame_id += 1 # signal_num
self.signal_pipe.send("shutdown")
self.stop_flag = True
def data_func(self):
"""The function responsible for sending and processing data.
This method continuously sends data requests using a data pipe and receives
corresponding frame IDs. It appends the received frame IDs to the data storage
and runs data processing operations if a data container is available.
Notes
-----
- The function operates in a loop until the `stop_flag` is set.
- It communicates with a separate process using a data pipe for data retrieval.
- Received frame IDs are appended to the data storage and processed if
applicable.
- The method terminates by sending a "shutdown" signal.
"""
while not self.stop_flag:
self.data_pipe.send("getData")
frame_ids = self.data_pipe.recv()
print("receive: ", frame_ids)
if not frame_ids:
continue
self.data.append(frame_ids)
if self.data_container:
self.data_container.run(frame_ids)
self.data_pipe.send("shutdown")
def start(self, feature_list):
"""Start the model.
Parameters
----------
feature_list : list
The list of features to be used.
Returns
-------
bool
True if the model is started successfully, False otherwise.
Example
-------
>>> model.start(['signal', 'data'])
"""
if feature_list is None:
return False
self.data = []
self.signal_records = []
self.data_records = []
self.stop_flag = False
self.frame_id = 0 # signal_num
self.signal_pipe, self.data_pipe = self.device.setup()
self.signal_container, self.data_container = load_features(self, feature_list)
self.signal_thread = threading.Thread(target=self.signal_func, name="signal")
self.data_thread = threading.Thread(target=self.data_func, name="data")
self.signal_thread.start()
self.data_thread.start()
self.signal_thread.join()
self.stop_flag = True
self.data_thread.join()
return True
class DummyDevice:
"""Dummy Device - class is used to test the controller and view."""
def __init__(self, timecost=0.2):
"""Initialize the Dummy device.
Parameters
----------
timecost : float
The time cost for generating a message.
"""
#: int: The message count.
self.msg_count = mp.Value("i", 0)
#: int: The sendout message count.
self.sendout_msg_count = 0
#: Pipe: The pipe for sending signals.
self.out_port = None
#: Pipe: The pipe for receiving signals.
self.in_port = None
#: float: The time cost for generating a message.
self.timecost = timecost
#: bool: The flag for stopping the device.
self.stop_flag = False
def setup(self):
"""Set up the pipes.
Returns
-------
Pipe
The pipe for sending signals.
Pipe
The pipe for receiving signals.
Example
-------
>>> device.setup()
"""
signalPort, self.in_port = mp.Pipe()
dataPort, self.out_port = mp.Pipe()
in_process = mp.Process(target=self.listen)
out_process = mp.Process(target=self.sendout)
in_process.start()
out_process.start()
self.sendout_msg_count = 0
self.msg_count.value = 0
self.stop_flag = False
return signalPort, dataPort
def generate_message(self):
"""Generate a message.
Example
-------
>>> device.generate_message()
"""
time.sleep(self.timecost)
self.msg_count.value += 1
def clear(self):
"""Clear the pipes.
Example
-------
>>> device.clear()
"""
self.msg_count.value = 0
def listen(self):
"""Listen to the pipe.
Example
-------
>>> device.listen()
"""
while not self.stop_flag:
signal = self.in_port.recv()
if signal == "shutdown":
self.stop_flag = True
self.in_port.close()
break
self.generate_message()
self.in_port.send("done")
def sendout(self, timeout=100):
"""Send out the message.
Parameters
----------
timeout : int
The timeout for sending out the message.
Example
-------
>>> device.sendout()
"""
while not self.stop_flag:
msg = self.out_port.recv()
if msg == "shutdown":
self.out_port.close()
break
c = 0
while self.msg_count.value == self.sendout_msg_count and c < timeout:
time.sleep(0.01)
c += 1
self.out_port.send(
list(range(self.sendout_msg_count, self.msg_count.value))
)
self.sendout_msg_count = self.msg_count.value
class DummyMicroscope:
"""Dummy Microscope - Class is used to test the controller and view."""
def __init__(self, name, configuration, devices_dict, is_synthetic=False):
"""Initialize the Dummy microscope.
Parameters
----------
name : str
The microscope name.
configuration : dict
The configuration dictionary.
devices_dict : dict
The dictionary of devices.
is_synthetic : bool
The flag for using a synthetic microscope.
"""
#: str: The microscope name.
self.microscope_name = name
#: dict: The configuration dictionary.
self.configuration = configuration
#: np.ndarray: The data buffer.
self.data_buffer = None
#: dict: The stage dictionary.
self.stages = {}
#: dict: The lasers dictionary.
self.lasers = {}
#: dict: The galvo dictionary.
self.galvo = {}
#: dict: The DAQ dictionary.
self.daq = devices_dict.get("daq", None)
#: int: The current channel.
self.current_channel = 0
self.camera = SyntheticCamera(
self.configuration["experiment"]["MicroscopeState"]["microscope_name"],
SyntheticCameraController(),
self.configuration,
)
def calculate_exposure_sweep_times(self):
"""Get the exposure and sweep times for all channels.
Returns
-------
dict
The dictionary of exposure times.
dict
The dictionary of sweep times.
"""
exposure_times = {}
sweep_times = {}
microscope_state = self.configuration["experiment"]["MicroscopeState"]
waveform_constants = self.configuration["waveform_constants"]
camera_delay = (
self.configuration["configuration"]["microscopes"][self.microscope_name][
"camera"
]["delay"]
/ 1000
)
camera_settle_duration = (
self.configuration["configuration"]["microscopes"][self.microscope_name][
"camera"
].get("settle_duration", 0)
/ 1000
)
remote_focus_ramp_falling = (
float(waveform_constants["other_constants"]["remote_focus_ramp_falling"])
/ 1000
)
duty_cycle_wait_duration = (
float(waveform_constants["other_constants"]["remote_focus_settle_duration"])
/ 1000
)
ps = float(waveform_constants["other_constants"].get("percent_smoothing", 0.0))
readout_time = 0
readout_mode = self.configuration["experiment"]["CameraParameters"][
"sensor_mode"
]
if readout_mode == "Normal":
readout_time = self.camera.calculate_readout_time()
elif self.configuration["experiment"]["CameraParameters"][
"readout_direction"
] in ["Bidirectional", "Rev. Bidirectional"]:
remote_focus_ramp_falling = 0
# set readout out time
self.configuration["experiment"]["CameraParameters"]["readout_time"] = (
readout_time * 1000
)
for channel_key in microscope_state["channels"].keys():
channel = microscope_state["channels"][channel_key]
if channel["is_selected"] is True:
exposure_time = channel["camera_exposure_time"] / 1000
if readout_mode == "Light-Sheet":
(
_,
_,
updated_exposure_time,
) = self.camera.calculate_light_sheet_exposure_time(
exposure_time,
int(
self.configuration["experiment"]["CameraParameters"][
"number_of_pixels"
]
),
)
if updated_exposure_time != exposure_time:
print(
f"*** Notice: The actual exposure time of the camera for "
f"{channel_key} is {round(updated_exposure_time*1000, 1)}"
f"ms, not {exposure_time*1000}ms!"
)
exposure_time = round(updated_exposure_time, 4)
# update the experiment file
channel["camera_exposure_time"] = round(
updated_exposure_time * 1000, 1
)
self.output_event_queue.put(
(
"exposure_time",
(channel_key, channel["camera_exposure_time"]),
)
)
sweep_time = (
exposure_time
+ readout_time
+ camera_delay
+ max(
remote_focus_ramp_falling + duty_cycle_wait_duration,
camera_settle_duration,
camera_delay,
)
- camera_delay
)
# TODO: should we keep the percent_smoothing?
if ps > 0:
sweep_time = (1 + ps / 100) * sweep_time
exposure_times[channel_key] = exposure_time + readout_time
sweep_times[channel_key] = sweep_time
return exposure_times, sweep_times

View File

@@ -0,0 +1,176 @@
import pytest
import time
import threading
import multiprocessing as mp
from navigate.model.features.feature_container import load_features
class DummyDevice:
def __init__(self, timecost=0.2):
self.msg_count = mp.Value("i", 0)
self.sendout_msg_count = 0
self.out_port = None
self.in_port = None
self.timecost = timecost
self.stop_acquisition = False
def setup(self):
signalPort, self.in_port = mp.Pipe()
dataPort, self.out_port = mp.Pipe()
in_process = mp.Process(target=self.listen)
out_process = mp.Process(target=self.sendout)
in_process.start()
out_process.start()
self.sendout_msg_count = 0
self.msg_count.value = 0
self.stop_acquisition = False
return signalPort, dataPort
def generate_message(self):
time.sleep(self.timecost)
self.msg_count.value += 1
def clear(self):
self.msg_count.value = 0
def listen(self):
while not self.stop_acquisition:
signal = self.in_port.recv()
if signal == "shutdown":
self.stop_acquisition = True
self.in_port.close()
break
self.generate_message()
self.in_port.send("done")
def sendout(self, timeout=100):
while not self.stop_acquisition:
msg = self.out_port.recv()
if msg == "shutdown":
self.out_port.close()
break
c = 0
while self.msg_count.value == self.sendout_msg_count and c < timeout:
time.sleep(0.01)
c += 1
self.out_port.send(
list(range(self.sendout_msg_count, self.msg_count.value))
)
self.sendout_msg_count = self.msg_count.value
class RecordObj:
def __init__(self, name_list, record_list, frame_id, frame_id_completed=-1):
self.name_list = name_list
self.record_list = record_list
self.frame_id = frame_id
self.frame_id_completed = frame_id_completed
def __getattr__(self, __name: str):
self.name_list += "." + __name
return self
def __call__(self, *args, **kwargs):
kwargs["__test_frame_id"] = self.frame_id
kwargs["__test_frame_id_completed"] = self.frame_id_completed
if self.name_list == "active_microscope.stages.keys":
return ["x", "y", "z", "theta", "f"]
print("* calling", self.name_list, args, kwargs)
self.record_list.append((self.name_list, args, kwargs))
class DummyModelToTestFeatures:
def __init__(self, configuration):
self.configuration = configuration
self.device = DummyDevice()
self.signal_pipe, self.data_pipe = None, None
self.signal_container = None
self.data_container = None
self.signal_thread = None
self.data_thread = None
self.stop_acquisition = False
self.frame_id = 0 # signal_num
self.frame_id_completed = -1
self.data = []
self.signal_records = []
self.data_records = []
def signal_func(self):
self.signal_container.reset()
while not self.signal_container.end_flag:
if self.signal_container:
self.signal_container.run()
self.signal_pipe.send("signal")
self.signal_pipe.recv()
self.frame_id_completed += 1
if self.signal_container:
self.signal_container.run(wait_response=True)
self.frame_id += 1 # signal_num
self.signal_pipe.send("shutdown")
self.stop_acquisition = True
def data_func(self):
while not self.stop_acquisition:
self.data_pipe.send("getData")
frame_ids = self.data_pipe.recv()
print("receive: ", frame_ids)
if not frame_ids:
continue
self.data.append(frame_ids)
if self.data_container:
self.data_container.run(frame_ids)
self.data_pipe.send("shutdown")
def start(self, feature_list):
if feature_list is None:
return False
self.data = []
self.signal_records = []
self.data_records = []
self.stop_acquisition = False
self.frame_id = 0 # signal_num
self.frame_id_completed = -1
self.signal_pipe, self.data_pipe = self.device.setup()
self.signal_container, self.data_container = load_features(self, feature_list)
self.signal_thread = threading.Thread(target=self.signal_func, name="signal")
self.data_thread = threading.Thread(target=self.data_func, name="data")
self.signal_thread.start()
self.data_thread.start()
self.signal_thread.join()
self.stop_acquisition = True
self.data_thread.join()
return True
def get_stage_position(self):
axes = ["x", "y", "z", "theta", "f"]
stage_pos = self.configuration["experiment"]["StageParameters"]
return dict(map(lambda axis: (axis + "_pos", stage_pos[axis]), axes))
def __getattr__(self, __name: str):
return RecordObj(
__name, self.signal_records, self.frame_id, self.frame_id_completed
)
@pytest.fixture(scope="module")
def dummy_model_to_test_features(dummy_model):
model = DummyModelToTestFeatures(dummy_model.configuration)
return model

View File

@@ -0,0 +1,970 @@
# 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
import random
import threading
from navigate.model.features.feature_container import (
SignalNode,
DataNode,
DataContainer,
load_features,
)
from navigate.model.features.common_features import WaitToContinue, LoopByCount
from navigate.model.features.feature_container import dummy_True
from test.model.dummy import DummyModel
class DummyFeature:
def __init__(self, *args):
"""
args:
0: model
1: name
2: with response (True/False) (1/0)
3: device related (True/False) (1/0)
4: multi step (integer >= 1)
5: has data function? There could be no data functions when node_type is 'multi-step'
"""
self.init_times = 0
self.running_times_main_func = 0
self.running_times_response_func = 0
self.running_times_cleanup_func = 0
self.is_end = False
self.is_closed = False
self.model = None if len(args) == 0 else args[0]
self.feature_name = args[1] if len(args) > 1 else "none"
self.config_table = {
"signal": {
"name-for-test": self.feature_name,
"init": self.signal_init_func,
"main": self.signal_main_func,
},
"data": {
"name-for-test": self.feature_name,
"init": self.data_init_func,
"main": self.data_main_func,
},
"node": {},
}
if len(args) > 2 and args[2]:
self.config_table["signal"]["main-response"] = self.signal_wait_func
self.has_response_func = True
else:
self.has_response_func = False
if len(args) > 3:
self.config_table["node"]["device_related"] = args[3] == 1
if len(args) > 4 and args[4] > 1:
self.config_table["node"]["node_type"] = "multi-step"
self.multi_steps = args[4]
self.config_table["signal"]["end"] = self.signal_end_func
self.config_table["data"]["end"] = self.data_end_func
else:
self.multi_steps = 1
if len(args) > 5 and args[4] > 1 and args[2] == False and args[5] == False:
self.config_table["data"] = {}
self.target_frame_id = 0
self.response_value = 0
self.current_signal_step = 0
self.current_data_step = 0
self.wait_lock = threading.Lock()
def init_func(self):
self.init_times += 1
def main_func(self, value=None):
self.running_times_main_func += 1
return value
def response_func(self, value=None):
self.running_times_response_func += 1
return value
def end_func(self):
return self.is_end
def close(self):
self.is_closed = True
self.running_times_cleanup_func += 1
def clear(self):
self.init_times = 0
self.running_times_main_func = 0
self.running_times_response_func = 0
self.running_times_cleanup_func = 0
self.is_end = False
self.is_closed = False
def signal_init_func(self, *args):
self.target_frame_id = -1
self.current_signal_step = 0
if self.wait_lock.locked():
self.wait_lock.release()
def signal_main_func(self, *args):
self.target_frame_id = self.model.frame_id # signal_num
if self.feature_name.startswith("node"):
self.model.signal_records.append((self.target_frame_id, self.feature_name))
if self.has_response_func:
self.wait_lock.acquire()
print(
self.feature_name, ": wait lock is acquired!!!!", self.target_frame_id
)
return True
def signal_wait_func(self, *args):
self.wait_lock.acquire()
self.wait_lock.release()
print(self.feature_name, ": wait response!(signal)", self.response_value)
return self.response_value
def signal_end_func(self):
self.current_signal_step += 1
return self.current_signal_step >= self.multi_steps
def data_init_func(self):
self.current_data_step = 0
pass
def data_pre_main_func(self, frame_ids):
return self.target_frame_id in frame_ids
def data_main_func(self, frame_ids):
# assert self.target_frame_id in frame_ids, 'frame is not ready'
if self.feature_name.startswith("node"):
self.model.data_records.append((frame_ids[0], self.feature_name))
if self.has_response_func and self.wait_lock.locked():
# random Yes/No
self.response_value = random.randint(0, 1)
print(
self.feature_name,
": wait lock is released!(data)",
frame_ids,
self.response_value,
)
self.wait_lock.release()
return self.response_value
return True
def data_end_func(self):
self.current_data_step += 1
return self.current_data_step >= self.multi_steps
def generate_random_feature_list(
has_response_func=False, multi_step=False, with_data_func=True, loop_node=False
):
feature_list = []
m = random.randint(1, 10)
node_count = 0
for i in range(m):
n = random.randint(1, 10)
temp = []
for j in range(n):
has_response = random.randint(0, 1) if has_response_func else 0
device_related = random.randint(0, 1)
steps = random.randint(1, 10) if multi_step else 1
steps = 1 if steps < 5 else steps
if with_data_func == False:
no_data_func = random.randint(0, 1)
else:
no_data_func = 0
if steps >= 5 and no_data_func:
has_response = False
feature = {
"name": DummyFeature,
"args": (
f"multi-step{node_count}",
has_response,
1,
steps,
False,
),
}
temp.append(feature)
temp.append({"name": WaitToContinue})
else:
feature = {
"name": DummyFeature,
"args": (
f"node{node_count}",
has_response,
device_related,
steps,
),
}
if has_response:
feature["node"] = {"need_response": True}
temp.append(feature)
node_count += 1
# has response function means that node can only have child node
if has_response:
break
turn_to_loop_flag = random.randint(0, 1) if loop_node else 0
if turn_to_loop_flag:
temp.append({"name": LoopByCount, "args": (3,)})
node_count += 1
temp = tuple(temp)
feature_list.append(temp)
return feature_list
def print_feature_list(feature_list):
result = []
for features in feature_list:
temp = []
for node in features:
if "args" in node:
temp.append(node["args"])
else:
temp.append(node["name"].__name__)
if type(features) is tuple:
temp = tuple(temp)
result.append(temp)
print("--------feature list-------------")
print(result)
print("---------------------------------")
return str(result)
def convert_to_feature_list(feature_str):
result = []
for features in feature_str:
temp = []
for feature in features:
if type(feature) == str:
node = {"name": WaitToContinue}
else:
node = {"name": DummyFeature, "args": (*feature,)}
temp.append(node)
result.append(temp)
return result
class TestFeatureContainer(unittest.TestCase):
def setUp(self):
print("-------------new test case-----------------")
@unittest.skip("takes long time to finish the test")
def test_feature_container(self):
model = DummyModel()
print("# test signal and data are synchronous")
print("--all function nodes are single step")
print("----all signal nodes are without waiting function")
for i in range(10):
feature_list = generate_random_feature_list()
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
print("----some signal nodes have waiting function")
for i in range(10):
# feature_list = convert_to_feature_list([[('node0', 1, 1, 1)], [('node1', 1, 0, 1)], [('node2', 1, 1, 1)], [('node3', 1, 0, 1)], [('node4', 1, 0, 1)], [('node5', 1, 0, 1)], [('node6', 1, 0, 1)], [('node7', 1, 1, 1)], [('node8', 0, 1, 1), ('node9', 0, 1, 1), ('node10', 1, 0, 1)], [('node11', 1, 1, 1)]])
feature_list = generate_random_feature_list(has_response_func=True)
print_feature_list(feature_list)
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
print("--Some function nodes are multi-step")
print(
"----multi-step nodes have both signal and data functions, and without waiting function"
)
for i in range(10):
feature_list = generate_random_feature_list(multi_step=True)
# feature_list = convert_to_feature_list([[('node0', 0, 0, 10), ('node1', 0, 1, 5), ('node2', 0, 0, 1), ('node3', 0, 0, 9)], [('node4', 0, 1, 8), ('node5', 0, 1, 10), ('node6', 0, 1, 6), ('node7', 0, 0, 1), ('node8', 0, 0, 6), ('node9', 0, 1, 1), ('node10', 0, 0, 6)], [('node11', 0, 0, 1), ('node12', 0, 0, 1), ('node13', 0, 0, 1), ('node14', 0, 1, 7), ('node15', 0, 0, 1)], [('node16', 0, 0, 1), ('node17', 0, 1, 1), ('node18', 0, 1, 7), ('node19', 0, 1, 7), ('node20', 0, 1, 10), ('node21', 0, 0, 1), ('node22', 0, 1, 9), ('node23', 0, 1, 1), ('node24', 0, 0, 10), ('node25', 0, 0, 6)], [('node26', 0, 1, 1), ('node27', 0, 1, 7), ('node28', 0, 1, 8), ('node29', 0, 1, 7), ('node30', 0, 0, 5), ('node31', 0, 1, 1), ('node32', 0, 0, 10)]])
# feature_list = convert_to_feature_list([[('node0', 0, 1, 2), ('node1', 0, 0, 3)]])
# feature_list = convert_to_feature_list([[('node0', 0, 0, 5)], [('node1', 0, 0, 5), ('node2', 0, 0, 10), ('node3', 0, 1, 7), ('node4', 0, 0, 1), ('node5', 0, 0, 9), ('node6', 0, 1, 9)], [('node7', 0, 0, 9), ('node8', 0, 0, 6), ('node9', 0, 0, 7), ('node10', 0, 1, 3), ('node11', 0, 1, 6), ('node12', 0, 1, 5), ('node13', 0, 0, 4), ('node14', 0, 0, 1), ('node15', 0, 0, 2)], [('node16', 0, 0, 5), ('node17', 0, 1, 2), ('node18', 0, 0, 6), ('node19', 0, 0, 3)], [('node20', 0, 0, 9), ('node21', 0, 0, 7), ('node22', 0, 0, 1), ('node23', 0, 0, 8), ('node24', 0, 0, 2), ('node25', 0, 1, 7), ('node26', 0, 0, 9)], [('node27', 0, 0, 2), ('node28', 0, 1, 3), ('node29', 0, 0, 3), ('node30', 0, 0, 8)], [('node31', 0, 0, 8), ('node32', 0, 0, 10), ('node33', 0, 1, 4), ('node34', 0, 1, 2), ('node35', 0, 1, 8), ('node36', 0, 1, 4), ('node37', 0, 0, 5), ('node38', 0, 0, 9)], [('node39', 0, 0, 9), ('node40', 0, 1, 8), ('node41', 0, 1, 4)], [('node42', 0, 0, 1), ('node43', 0, 0, 1), ('node44', 0, 1, 1), ('node45', 0, 0, 2), ('node46', 0, 1, 3)]])
print_feature_list(feature_list)
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
print(
"----multi-step nodes have both signal and data functions, and with waiting function"
)
for i in range(10):
# feature_list = convert_to_feature_list([[('node0', 0, 0, 1), ('node1', 1, 1, 1)], [('node2', 1, 1, 7)], [('node3', 0, 1, 1), ('node4', 0, 1, 9), ('node5', 1, 1, 1)], [('node6', 1, 0, 1)], [('node7', 0, 1, 6), ('node8', 1, 1, 1)], [('node9', 0, 1, 7), ('node10', 1, 0, 1)], [('node11', 0, 0, 7), ('node12', 0, 0, 6), ('node13', 0, 0, 5)], [('node14', 0, 1, 6), ('node15', 0, 0, 1), ('node16', 0, 1, 9), ('node17', 0, 1, 10), ('node18', 0, 0, 1), ('node19', 0, 0, 1), ('node20', 0, 0, 1), ('node21', 1, 1, 1)], [('node22', 1, 0, 1)]])
# feature_list = convert_to_feature_list([[('node0', 1, 1, 5)], [('node1', 0, 1, 5), ('node2', 1, 1, 1)], [('node3', 1, 1, 6)], [('node4', 1, 1, 9)], [('node5', 0, 1, 6), ('node6', 1, 0, 1)], [('node7', 1, 1, 6)], [('node8', 1, 1, 5)]])
# feature_list = convert_to_feature_list([[('node0', 0, 0, 6), ('node1', 0, 1, 1), ('node2', 0, 1, 8)], [('node3', 0, 1, 1), ('node4', 0, 1, 1), ('node5', 1, 0, 6)], [('node6', 0, 1, 1), ('node7', 1, 0, 1)]])
feature_list = generate_random_feature_list(
has_response_func=True, multi_step=True
)
print_feature_list(feature_list)
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
print("----some multi-step nodes don't have data functions")
for i in range(10):
# feature_list = convert_to_feature_list([[('node0', 0, 1, 1), ('multi-step1', False, 0, 6, False), 'WaitToContinue', ('multi-step2', False, 0, 8, False), 'WaitToContinue', ('node3', 0, 1, 1), ('multi-step4', False, 0, 9, False), 'WaitToContinue'], [('node5', 0, 0, 6)]])
# feature_list = convert_to_feature_list([[('node0', 1, 1, 1)], [('node1', 0, 1, 9), ('node2', 0, 1, 1), ('node3', 0, 0, 1), ('multi-step4', False, 0, 9, False), 'WaitToContinue', ('multi-step5', False, 0, 5, False), 'WaitToContinue', ('node6', 1, 1, 1)]])
# feature_list = convert_to_feature_list([[('multi-step0', False, 1, 9, False), 'WaitToContinue', ('multi-step1', False, 1, 8, False), 'WaitToContinue', ('multi-step2', False, 1, 7, False), 'WaitToContinue', ('multi-step3', False, 1, 7, False), 'WaitToContinue', ('multi-step4', False, 1, 7, False), 'WaitToContinue', ('node5', 1, 0, 5)], [('node6', 1, 1, 1)], [('node7', 0, 0, 1), ('node8', 0, 1, 1), ('node9', 1, 0, 1)], [('node10', 1, 1, 10)], [('multi-step11', False, 1, 6, False), 'WaitToContinue', ('node12', 1, 0, 1)], [('multi-step13', False, 1, 8, False), 'WaitToContinue', ('multi-step14', False, 1, 9, False), 'WaitToContinue', ('node15', 0, 1, 1), ('multi-step16', False, 1, 9, False), 'WaitToContinue', ('node17', 1, 0, 10)], [('multi-step18', False, 1, 9, False), 'WaitToContinue'], [('node19', 1, 1, 7)], [('node20', 1, 1, 1)]])
feature_list = generate_random_feature_list(
has_response_func=True, multi_step=True, with_data_func=False
)
print_feature_list(feature_list)
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
print("----with loop node")
# test case: (,)
feature_list = [
(
{
"name": DummyFeature,
"args": (
"node0",
0,
0,
1,
),
},
{"name": LoopByCount, "args": (3,)},
)
]
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
assert model.signal_records == [(0, "node0"), (1, "node0"), (2, "node0")]
# test case: random loop
for i in range(10):
# feature_list = [({'name': DummyFeature, 'args': ('node0', 0, 0, 1,),}, {'name': LoopByCount, 'args': (3,)}), [{'name': DummyFeature, 'args': ('node1', 0, 0, 1,),}, {'name': DummyFeature, 'args': ('node2', 0, 0, 1,),}], ({'name': DummyFeature, 'args': ('node3', 0, 0, 1,),}, {'name': LoopByCount, 'args': (3,)}), ({'name': DummyFeature, 'args': ('node4', 0, 0, 1,),}, {'name': DummyFeature, 'args': ('node5', 0, 0, 1,),}, {'name': LoopByCount, 'args': (3,)})]
feature_list = generate_random_feature_list(
has_response_func=True,
multi_step=True,
with_data_func=False,
loop_node=True,
)
print_feature_list(feature_list)
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
print("----nested loop nodes")
# test case: ((), , ), , ,
feature_list = [
(
(
generate_random_feature_list(
has_response_func=True,
multi_step=True,
with_data_func=False,
loop_node=True,
),
{"name": LoopByCount, "args": (3,)},
),
{
"name": DummyFeature,
"args": (
"node100",
0,
1,
1,
),
},
{"name": LoopByCount, "args": (3,)},
),
{
"name": DummyFeature,
"args": (
"node101",
0,
1,
1,
),
},
{
"name": DummyFeature,
"args": (
"node102",
0,
1,
1,
),
},
]
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
# test case: (,,(),), ,
feature_list = [
(
{
"name": DummyFeature,
"args": (
"node200",
0,
0,
1,
),
},
{
"name": DummyFeature,
"args": (
"node201",
0,
1,
1,
),
},
(
generate_random_feature_list(
has_response_func=True,
multi_step=True,
with_data_func=False,
loop_node=True,
),
{"name": LoopByCount, "args": (3,)},
),
{
"name": DummyFeature,
"args": (
"node100",
0,
1,
1,
),
},
{"name": LoopByCount, "args": (3,)},
),
{
"name": DummyFeature,
"args": (
"node101",
0,
1,
1,
),
},
{
"name": DummyFeature,
"args": (
"node102",
0,
0,
1,
),
},
]
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
# test case: (((((),),),),), ,
feature_list = [
(
(
(
(
generate_random_feature_list(loop_node=True),
{"name": LoopByCount, "args": (3,)},
),
{"name": LoopByCount, "args": (3,)},
),
{"name": LoopByCount, "args": (3,)},
),
{"name": LoopByCount, "args": (3,)},
),
{
"name": DummyFeature,
"args": (
"node101",
0,
1,
1,
),
},
{
"name": DummyFeature,
"args": (
"node102",
0,
0,
1,
),
},
]
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
# test case: (,(,(,(,(),),),),), ,
feature_list = [
{
"name": DummyFeature,
"args": (
"node200",
0,
0,
1,
),
},
(
{
"name": DummyFeature,
"args": (
"node300",
0,
0,
1,
),
},
(
{
"name": DummyFeature,
"args": (
"node400",
0,
0,
1,
),
},
(
{
"name": DummyFeature,
"args": (
"node500",
0,
0,
1,
),
},
(
generate_random_feature_list(loop_node=True),
{"name": LoopByCount, "args": (3,)},
),
{"name": LoopByCount, "args": (3,)},
),
{"name": LoopByCount, "args": (3,)},
),
{"name": LoopByCount, "args": (3,)},
),
{
"name": DummyFeature,
"args": (
"node101",
0,
1,
1,
),
},
{
"name": DummyFeature,
"args": (
"node102",
0,
0,
1,
),
},
]
model.start(feature_list)
print(model.signal_records)
print(model.data_records)
assert model.signal_records == model.data_records, print_feature_list(
feature_list
)
def test_load_feature(self):
def check(tnode1, tnode2):
if tnode1 is None and tnode2 is None:
return True
if tnode1 is None or tnode2 is None:
return False
return tnode1.node_name == tnode2.node_name
def is_isomorphic(tree1, tree2):
p, q = tree1, tree2
stack = []
visited = {}
while p or q or stack:
if not check(p, q):
return False
if p is None:
p, q = stack.pop()
elif p.node_name not in visited:
visited[p.node_name] = True
stack.append((p.sibling, q.sibling))
p, q = p.child, q.child
else:
p, q = None, None
return True
# generates 10 random feature lists and verify whether they are loaded correctly
for i in range(10):
feature_list = generate_random_feature_list(loop_node=True)
signal_container, data_container = load_features(self, feature_list)
assert is_isomorphic(signal_container.root, data_container.root)
print("-", i, "random feature list is correct!")
def test_signal_node(self):
feature = DummyFeature()
func_dict = {
"init": feature.init_func,
"main": feature.main_func,
"end": feature.end_func,
}
print("without waiting for a response:")
node = SignalNode("test_1", func_dict)
assert node.need_response == False
assert node.node_funcs["end"]() == False
feature.is_end = True
assert node.node_funcs["end"]() == True
result, is_end = node.run()
assert feature.init_times == 1
assert feature.running_times_main_func == 1
assert result == None
assert is_end == True
assert node.is_initialized == False
result, is_end = node.run(True)
assert feature.init_times == 2
assert feature.running_times_main_func == 2
assert result == True
assert is_end == True
assert node.is_initialized == False
assert node.wait_response == False
print("--running with waiting option")
feature.clear()
result, is_end = node.run(wait_response=True)
assert is_end == True
assert node.is_initialized == False
assert node.wait_response == False
assert feature.running_times_main_func == 1
assert feature.init_times == 1
print("--device related")
feature.clear()
node = SignalNode("test_1", func_dict, device_related=True)
print(node.node_type)
assert node.need_response == False
result, is_end = node.run()
assert is_end == True
assert node.wait_response == False
assert feature.running_times_main_func == 1
print("----running with waitint option")
feature.clear()
result, is_end = node.run(wait_response=True)
assert is_end == False
assert node.wait_response == False
assert feature.running_times_main_func == 0
assert node.is_initialized == True
print("----multi-step function")
feature.clear()
node = SignalNode(
"test_1", func_dict, device_related=True, node_type="multi-step"
)
# node.node_type = "multi-step"
func_dict["main-response"] = dummy_True
# assert func_dict.get("main-response", None) == None
assert node.need_response == True
steps = 5
for i in range(steps + 1):
feature.is_end = i == steps
if i == 0:
assert node.is_initialized == False
else:
assert node.is_initialized == True
result, is_end = node.run()
if i <= steps:
assert node.is_initialized == True
assert is_end == False
assert feature.running_times_main_func == i + 1
assert node.wait_response == True
result, is_end = node.run(wait_response=True)
if i < steps:
assert is_end == False
else:
assert is_end == True
print("--multi-step function")
feature.clear()
node = SignalNode(
"test_1", func_dict, node_type="multi-step", device_related=True
)
# assert func_dict.get("main-response") == None
assert node.need_response == True
assert node.device_related == True
steps = 5
for i in range(steps + 1):
feature.is_end = i == steps
result, is_end = node.run()
if i < steps:
assert is_end == False
assert is_end == False
assert feature.running_times_main_func == i + 1
assert node.is_initialized == True
assert node.wait_response == True
result, is_end = node.run(wait_response=True)
assert node.wait_response == False
assert node.is_initialized == False
print("wait for a response:")
feature.clear()
func_dict["main-response"] = feature.response_func
node = SignalNode("test_2", func_dict, need_response=True)
assert node.need_response == True
assert node.wait_response == False
print("--running without waiting option")
result, is_end = node.run()
assert result == None
assert is_end == False
assert node.is_initialized == True
assert node.wait_response == True
result, is_end = node.run(True)
assert result == True
assert is_end == False
assert feature.init_times == 1
assert feature.running_times_main_func == 2
assert node.wait_response == True
assert node.is_initialized == True
print("--running with waiting option")
result, is_end = node.run(wait_response=True)
assert feature.running_times_main_func == 2
assert feature.running_times_response_func == 1
assert node.wait_response == False
assert node.is_initialized == False
assert is_end == True
feature.clear()
result, is_end = node.run(wait_response=True)
assert is_end == False
assert feature.init_times == 1
assert feature.running_times_main_func == 0
assert feature.running_times_response_func == 0
assert node.is_initialized == True
print("----device related")
node.device_related = True
feature.clear()
result, is_end = node.run()
assert is_end == False
assert feature.running_times_main_func == 1
assert node.wait_response == True
assert node.is_initialized == True
result, is_end = node.run(wait_response=True)
assert is_end == True
assert feature.running_times_response_func == 1
assert feature.running_times_main_func == 1
assert node.wait_response == False
assert node.is_initialized == False
feature.clear()
result, is_end = node.run(wait_response=True)
assert is_end == False
assert feature.running_times_main_func == 0
assert node.wait_response == False
assert feature.init_times == 1
assert node.is_initialized == True
print("--multi-step function")
feature.clear()
node = SignalNode("test", func_dict, node_type="multi-step", need_response=True)
steps = 5
for i in range(steps + 1):
feature.is_end = i == steps
result, is_end = node.run()
assert is_end == False
assert feature.running_times_main_func == i + 1
assert node.is_initialized == True
result, is_end = node.run(wait_response=True)
if i < steps:
assert is_end == False
else:
assert is_end == True
assert feature.running_times_response_func == i + 1
assert node.wait_response == False
def test_node_cleanup(self):
def wrap_error_func(func):
def temp_func(raise_error=False):
if raise_error:
raise Exception
func()
return temp_func
feature = DummyFeature()
func_dict = {
"init": feature.init_func,
"pre-main": dummy_True,
"main": wrap_error_func(feature.main_func),
"end": feature.end_func,
}
# one-step node without response
print("- one-step node without response")
node = DataNode("cleanup_node", func_dict)
data_container = DataContainer(node)
assert data_container.root == node
data_container.run()
assert feature.running_times_main_func == 1, feature.running_times_main_func
assert data_container.end_flag == True
data_container.reset()
data_container.run(True)
assert node.is_marked == True
assert feature.running_times_main_func == 1, feature.running_times_main_func
feature.clear()
data_container.reset()
func_dict["cleanup"] = feature.close
node = DataNode("cleanup_node", func_dict)
data_container = DataContainer(node)
data_container.run()
data_container.reset()
data_container.run(True)
assert feature.is_closed == True
assert node.is_marked == True
assert feature.running_times_main_func == 1
data_container.run()
assert feature.running_times_main_func == 1
# node with response
print("- node with response")
feature.clear()
node = DataNode("cleanup_node", func_dict, need_response=True)
data_container = DataContainer(node, [node])
assert data_container.root == node
data_container.run()
assert feature.running_times_main_func == 1, feature.running_times_main_func
data_container.reset()
data_container.run(True)
assert feature.running_times_cleanup_func == 1
assert feature.is_closed == True
assert node.is_marked == False
assert feature.running_times_main_func == 1
assert data_container.end_flag == True
data_container.run()
assert feature.running_times_main_func == 1
# multiple nodes
print("- multiple nodes")
feature.clear()
node1 = DataNode("cleanup_node1", func_dict)
node2 = DataNode("cleanup_node2", func_dict, device_related=True)
node3 = DataNode(
"cleanup_node3", func_dict, need_response=True, device_related=True
)
node1.sibling = node2
node2.sibling = node3
cleanup_list = [node1, node2, node3]
data_container = DataContainer(node1, cleanup_list)
assert data_container.root == node1
assert feature.running_times_main_func == 0
for i in range(1, 4):
data_container.run()
assert feature.running_times_main_func == i, feature.running_times_main_func
# mark a single node
data_container.reset()
data_container.run(True)
assert feature.is_closed == True
assert feature.running_times_cleanup_func == 1
feature.is_closed = False
assert node1.is_marked == True
assert feature.running_times_main_func == 3
assert data_container.end_flag == False
data_container.run()
assert feature.running_times_main_func == 4
assert node2.is_marked == False
data_container.run()
assert feature.running_times_main_func == 5
assert node3.is_marked == False
# run node1 which is marked
data_container.reset()
data_container.run()
assert feature.running_times_main_func == 5
# run node2
data_container.run()
assert feature.running_times_main_func == 6
assert node2.is_marked == False
# run node3 and clean up all nodes
data_container.run(True)
assert feature.running_times_cleanup_func == 4
assert feature.running_times_main_func == 6
assert data_container.end_flag == True
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,134 @@
# 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 numpy as np
# Local imports
from navigate.model.features.autofocus import power_tent
from navigate.model.features.autofocus import Autofocus
from test.model.dummy import DummyModel
class TestPowerTentFunction(unittest.TestCase):
def test_power_tent(self):
# Test with known parameters and expected result
x = 2.0
x_offset = 1.0
y_offset = 0.0
amplitude = 2.0
sigma = 0.5
alpha = 2.0
# Calculate the expected result manually
expected_result = y_offset + amplitude * (
1 - np.abs(sigma * (x - x_offset)) ** alpha
)
# Call the function and check if the result is close to the expected result
result = power_tent(x, x_offset, y_offset, amplitude, sigma, alpha)
self.assertAlmostEqual(result, expected_result, places=6)
def test_power_tent_boundary_cases(self):
# Test some boundary cases
x_offset = 0.0
y_offset = 0.0
amplitude = 1.0
sigma = 1.0
alpha = 1.0
# Test at x = x_offset, should be y_offset + amplitude
result = power_tent(x_offset, x_offset, y_offset, amplitude, sigma, alpha)
self.assertAlmostEqual(result, y_offset + amplitude, places=6)
# Test at x = x_offset + 1, should be y_offset
result = power_tent(x_offset + 1, x_offset, y_offset, amplitude, sigma, alpha)
self.assertAlmostEqual(result, y_offset, places=6)
class TestAutofocusClass(unittest.TestCase):
def setUp(self):
# Initialize an instance of the Autofocus class for testing
model = DummyModel()
model.active_microscope_name = "Mesoscale"
self.autofocus = Autofocus(model=model, device="stage", device_ref="f")
def test_get_autofocus_frame_num(self):
# Test the get_autofocus_frame_num method
settings = {
"coarse_selected": True,
"coarse_range": 8.0,
"coarse_step_size": 2.0,
"fine_selected": True,
"fine_range": 5.0,
"fine_step_size": 1.0,
}
self.autofocus.model.configuration = {
"experiment": {
"AutoFocusParameters": {"Mesoscale": {"stage": {"f": settings}}}
}
}
# Both Fine and Coarse Selected
frames = self.autofocus.get_autofocus_frame_num()
self.assertEqual(frames, 11) # Expected number of frames
# Only Coarse Selected
self.autofocus.model.configuration["experiment"]["AutoFocusParameters"][
"Mesoscale"
]["stage"]["f"]["fine_selected"] = False
self.autofocus.model.configuration["experiment"]["AutoFocusParameters"][
"Mesoscale"
]["stage"]["f"]["coarse_selected"] = True
frames = self.autofocus.get_autofocus_frame_num()
self.assertEqual(frames, 5) # Expected number of frames
# Only Fine Selected
self.autofocus.model.configuration["experiment"]["AutoFocusParameters"][
"Mesoscale"
]["stage"]["f"]["fine_selected"] = True
self.autofocus.model.configuration["experiment"]["AutoFocusParameters"][
"Mesoscale"
]["stage"]["f"]["coarse_selected"] = False
frames = self.autofocus.get_autofocus_frame_num()
self.assertEqual(frames, 6) # Expected number of frames
def test_get_steps(self):
# Test the get_steps method
steps, pos_offset = self.autofocus.get_steps(10.0, 2.0)
self.assertEqual(steps, 6) # Expected number of steps
self.assertEqual(pos_offset, 8.0) # Expected position offset
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,476 @@
# 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 random
import pytest
from navigate.model.features.common_features import ZStackAcquisition
class TestZStack:
@pytest.fixture(autouse=True)
def _prepare_test(self, dummy_model_to_test_features):
self.model = dummy_model_to_test_features
self.model.virtual_microscopes = {}
self.config = self.model.configuration["experiment"]["MicroscopeState"]
self.record_num = 0
self.feature_list = [[{"name": ZStackAcquisition}]]
self.config["start_position"] = 0
self.config["end_position"] = 200
self.config["number_z_steps"] = 5
self.config["step_size"] = (
self.config["end_position"] - self.config["start_position"]
) / self.config["number_z_steps"]
position_list = self.model.configuration["multi_positions"]
if len(position_list) < 5:
for i in range(5):
pos = [0] * len(position_list[0])
for i in range(len(position_list[0])):
pos[i] = random.randint(1, 10000)
position_list.append(pos)
def get_next_record(self, record_prefix, idx):
idx += 1
while self.model.signal_records[idx][0] != record_prefix:
idx += 1
if idx >= self.record_num:
assert False, "Some device movements are missed!"
return idx
def exist_record(self, record_prefix, idx_start, idx_end):
for i in range(idx_start, idx_end + 1):
if self.model.signal_records[i][0] == record_prefix:
return True
return False
def z_stack_verification(self):
self.record_num = len(self.model.signal_records)
change_channel_func_str = "active_microscope.prepare_next_channel"
close_daq_tasks_str = "active_microscope.daq.stop_acquisition"
create_daq_tasks_str = "active_microscope.daq.prepare_acquisition"
# save all the selected channels
selected_channels = []
for channel_key in self.config["channels"].keys():
if self.config["channels"][channel_key]["is_selected"]:
selected_channels.append(dict(self.config["channels"][channel_key]))
selected_channels[-1]["id"] = int(channel_key[len("channel_") :])
# restore z and f
pos_dict = self.model.get_stage_position()
restore_z = pos_dict["z_pos"]
restore_f = pos_dict["f_pos"]
mode = self.config["stack_cycling_mode"] # per_z/pre_stack
is_multiposition = self.config["is_multiposition"]
if is_multiposition:
positions = self.model.configuration["multi_positions"][1:]
else:
pos_dict = self.model.configuration["experiment"]["StageParameters"]
positions = [
[
pos_dict["x"],
pos_dict["y"],
self.config.get("stack_z_origin", pos_dict["z"]),
pos_dict["theta"],
self.config.get("stack_focus_origin", pos_dict["f"]),
]
]
z_step = self.config["step_size"]
f_step = (self.config["end_focus"] - self.config["start_focus"]) / self.config[
"number_z_steps"
]
frame_id = 0
idx = -1
z_moved_times = 0
if mode == "per_z":
z_should_move_times = len(positions) * int(self.config["number_z_steps"])
else:
z_should_move_times = (
len(selected_channels)
* len(positions)
* int(self.config["number_z_steps"])
)
has_ni_galvo_stage = self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"]
prepared_next_channel = False
# prepare first channel in pre_signal_func
idx = self.get_next_record(change_channel_func_str, idx)
prepared_next_channel = True
pre_change_channel_idx = idx
assert (
self.model.signal_records[idx][2]["__test_frame_id_completed"] == -1
), "prepare first channel should happen before 0"
assert (
self.model.signal_records[idx][2]["__test_frame_id"] == 0
), "prepare first channel should happen for frame: 0"
for i, pos in enumerate(positions):
idx = self.get_next_record("move_stage", idx)
# x, y, theta
pos_moved = self.model.signal_records[idx][1][0]
for i, axis in [(0, "x"), (1, "y"), (3, "theta")]:
assert pos[i] == pos_moved[axis + "_abs"], (
f"should move to {axis}: {pos[i]}, "
f"but moved to {pos_moved[axis + '_abs']}"
)
# (x, y, z, theta, f)
z_pos = pos[2] + self.config["start_position"]
f_pos = pos[4] + self.config["start_focus"]
if mode == "per_z":
f_pos += selected_channels[0]["defocus"]
for j in range(self.config["number_z_steps"]):
idx = self.get_next_record("move_stage", idx)
pos_moved = self.model.signal_records[idx][1][0]
# z, f
assert pos_moved["z_abs"] == z_pos + j * z_step, (
f"should move to z: {z_pos + j * z_step}, "
f"but moved to {pos_moved['z_abs']}"
)
assert pos_moved["f_abs"] == f_pos + j * f_step, (
f"should move to f: {f_pos + j * f_step}, "
f"but moved to {pos_moved['f_abs']}"
)
z_moved_times += 1
# if the system has NIGalvo stage, should close the DAQ tasks and
# then create new tasks to override the new waveforms
if has_ni_galvo_stage and prepared_next_channel:
idx = self.get_next_record(close_daq_tasks_str, idx)
pre_change_channel_idx = idx
assert (
self.model.signal_records[idx][2]["__test_frame_id"]
== frame_id
), f"close DAQ tasks should happen before {frame_id}"
idx = self.get_next_record(create_daq_tasks_str, idx)
pre_change_channel_idx = idx
assert (
self.model.signal_records[idx][2]["__test_frame_id"]
== frame_id
), f"create DAQ tasks should happen before {frame_id}"
# channel
for k in range(len(selected_channels)):
idx = self.get_next_record(change_channel_func_str, idx)
prepared_next_channel = True
assert (
self.model.signal_records[idx][2]["__test_frame_id"]
== frame_id
), (
"prepare next channel (change channel) "
f"should happen after {frame_id}"
)
assert (
self.model.signal_records[idx][2][
"__test_frame_id_completed"
]
== self.model.signal_records[idx][2]["__test_frame_id"]
), (
"prepare next channel (change channel) "
"should happen inside signal_end_func()"
)
assert (
self.exist_record(
change_channel_func_str,
pre_change_channel_idx + 1,
idx - 1,
)
is False
), (
"prepare next channel (change channel) "
"should not happen more than once"
)
pre_change_channel_idx = idx
frame_id += 1
else: # per_stack
for k in range(len(selected_channels)):
# z
f_pos += selected_channels[k]["defocus"]
for j in range(self.config["number_z_steps"]):
idx = self.get_next_record("move_stage", idx)
pos_moved = self.model.signal_records[idx][1][0]
# z, f
assert pos_moved["z_abs"] == z_pos + j * z_step, (
f"should move to z: {z_pos + j * z_step}, "
f"but moved to {pos_moved['z_abs']}"
)
assert pos_moved["f_abs"] == f_pos + j * f_step, (
f"should move to f: {f_pos + j * f_step}, "
f"but moved to {pos_moved['f_abs']}"
)
z_moved_times += 1
frame_id += 1
f_pos -= selected_channels[k]["defocus"]
idx = self.get_next_record(change_channel_func_str, idx)
prepared_next_channel = True
assert (
self.model.signal_records[idx][2]["__test_frame_id"]
== frame_id - 1
), (
"prepare next channel (change channel) "
f"should happen at {frame_id - 1}"
)
# restore z, f
idx = self.get_next_record("move_stage", idx)
pos_moved = self.model.signal_records[idx][1][0]
assert (
pos_moved["z_abs"] == restore_z
), f"should restore z to {restore_z}, but moved to {pos_moved['z_abs']}"
assert (
pos_moved["f_abs"] == restore_f
), f"should restore f to {restore_f}, but moved to {pos_moved['f_abs']}"
assert z_moved_times == z_should_move_times, (
f"should verify all the stage movements! {z_moved_times} -- "
f"{z_should_move_times}"
)
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_single_position_one_channel_per_z(self, has_ni_galvo_stage):
# single position
self.config["is_multiposition"] = False
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 1 channel per_z
self.config["stack_cycling_mode"] = "per_z"
self.config["channels"]["channel_1"]["is_selected"] = True
self.config["channels"]["channel_2"]["is_selected"] = False
self.config["channels"]["channel_3"]["is_selected"] = False
self.model.start(self.feature_list)
print(self.model.signal_records)
self.z_stack_verification()
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_single_position_one_channel_per_stack(self, has_ni_galvo_stage):
# single position
self.config["is_multiposition"] = False
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 1 channel per_stack
self.config["stack_cycling_mode"] = "per_stack"
self.config["channels"]["channel_1"]["is_selected"] = True
self.config["channels"]["channel_2"]["is_selected"] = False
self.config["channels"]["channel_3"]["is_selected"] = False
self.model.start(self.feature_list)
self.z_stack_verification()
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_single_position_two_channels_per_z(self, has_ni_galvo_stage):
# single position
self.config["is_multiposition"] = False
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 2 channels per_z
self.config["stack_cycling_mode"] = "per_z"
for i in range(3):
for j in range(3):
self.config["channels"]["channel_" + str(j + 1)]["is_selected"] = True
self.config["channels"]["channel_" + str(i + 1)]["is_selected"] = False
self.model.start(self.feature_list)
self.z_stack_verification()
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_single_position_two_channels_per_stack(self, has_ni_galvo_stage):
# single position
self.config["is_multiposition"] = False
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 2 channels per_stack
self.config["stack_cycling_mode"] = "per_stack"
for i in range(3):
for j in range(3):
self.config["channels"]["channel_" + str(j + 1)]["is_selected"] = True
self.config["channels"]["channel_" + str(i + 1)]["is_selected"] = False
self.model.start(self.feature_list)
self.z_stack_verification()
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_single_position_three_channels_per_stack(self, has_ni_galvo_stage):
# single position
self.config["is_multiposition"] = False
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 3 channels per_stack
self.config["channels"]["channel_1"]["is_selected"] = True
self.config["channels"]["channel_2"]["is_selected"] = True
self.config["channels"]["channel_3"]["is_selected"] = True
self.config["stack_cycling_mode"] = "per_stack"
self.model.start(self.feature_list)
self.z_stack_verification()
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_single_position_three_channels_per_z(self, has_ni_galvo_stage):
# single position
self.config["is_multiposition"] = False
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 3 channels per_z
self.config["channels"]["channel_1"]["is_selected"] = True
self.config["channels"]["channel_2"]["is_selected"] = True
self.config["channels"]["channel_3"]["is_selected"] = True
self.config["stack_cycling_mode"] = "per_z"
self.model.start(self.feature_list)
self.z_stack_verification()
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_multi_position_one_channel_per_z(self, has_ni_galvo_stage):
# multi position
self.config["is_multiposition"] = True
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 1 channel per_z
self.config["stack_cycling_mode"] = "per_z"
self.config["channels"]["channel_1"]["is_selected"] = True
self.config["channels"]["channel_2"]["is_selected"] = False
self.config["channels"]["channel_3"]["is_selected"] = False
self.model.start(self.feature_list)
self.z_stack_verification()
self.config["is_multiposition"] = False
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_multi_position_one_channel_per_stack(self, has_ni_galvo_stage):
self.config["is_multiposition"] = True
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 1 channel per_stack
self.config["stack_cycling_mode"] = "per_stack"
self.config["channels"]["channel_1"]["is_selected"] = True
self.config["channels"]["channel_2"]["is_selected"] = False
self.config["channels"]["channel_3"]["is_selected"] = False
self.model.start(self.feature_list)
self.z_stack_verification()
self.config["is_multiposition"] = False
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_multi_position_two_channels_per_z(self, has_ni_galvo_stage):
self.config["is_multiposition"] = True
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 2 channels per_z
self.config["stack_cycling_mode"] = "per_z"
for i in range(3):
for j in range(3):
self.config["channels"]["channel_" + str(j + 1)]["is_selected"] = True
self.config["channels"]["channel_" + str(i + 1)]["is_selected"] = False
self.model.start(self.feature_list)
self.z_stack_verification()
self.config["is_multiposition"] = False
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_multi_position_two_channels_per_stack(self, has_ni_galvo_stage):
self.config["is_multiposition"] = True
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 2 channels per_stack
self.config["stack_cycling_mode"] = "per_stack"
for i in range(3):
for j in range(3):
self.config["channels"]["channel_" + str(j + 1)]["is_selected"] = True
self.config["channels"]["channel_" + str(i + 1)]["is_selected"] = False
self.model.start(self.feature_list)
self.z_stack_verification()
self.config["is_multiposition"] = False
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_multi_position_three_channels_per_stack(self, has_ni_galvo_stage):
self.config["is_multiposition"] = True
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 3 channels per_stack
self.config["channels"]["channel_1"]["is_selected"] = True
self.config["channels"]["channel_2"]["is_selected"] = True
self.config["channels"]["channel_3"]["is_selected"] = True
self.config["stack_cycling_mode"] = "per_stack"
self.model.start(self.feature_list)
self.z_stack_verification()
self.config["is_multiposition"] = False
@pytest.mark.parametrize("has_ni_galvo_stage", [False])
def test_multi_position_three_channels_per_z(self, has_ni_galvo_stage):
self.config["is_multiposition"] = True
self.model.configuration["configuration"]["microscopes"][
self.config["microscope_name"]
]["stage"]["has_ni_galvo_stage"] = has_ni_galvo_stage
# 3 channels per_z
self.config["channels"]["channel_1"]["is_selected"] = True
self.config["channels"]["channel_2"]["is_selected"] = True
self.config["channels"]["channel_3"]["is_selected"] = True
self.config["stack_cycling_mode"] = "per_z"
self.model.start(self.feature_list)
self.z_stack_verification()
self.config["is_multiposition"] = False

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.
# Standard library imports
# Third party imports
import pytest
# local imports
from navigate.model.features.feature_related_functions import (
convert_str_to_feature_list,
convert_feature_list_to_str,
)
from navigate.model.features.common_features import (
PrepareNextChannel,
LoopByCount,
ZStackAcquisition,
)
@pytest.mark.parametrize(
"feature_list_str, expected_list",
[
("", None),
("[]", []),
("[{'name': PrepareNextChannel}]", [{"name": PrepareNextChannel}]),
("[{'name': NonExistFeature}]", None),
(
"[({'name': PrepareNextChannel}, {'name': LoopByCount})]",
[({"name": PrepareNextChannel}, {"name": LoopByCount})],
),
(
"[({'name': PrepareNextChannel}, {'name': LoopByCount, 'args': (3,)})]",
[({"name": PrepareNextChannel}, {"name": LoopByCount, "args": (3,)})],
),
(
"[({'name': PrepareNextChannel}, {'name': LoopByCount, 'args': 3})]",
[({"name": PrepareNextChannel}, {"name": LoopByCount, "args": (3,)})],
),
(
"[({'name': PrepareNextChannel}, {'name': LoopByCount, 'args': (3)})]",
[({"name": PrepareNextChannel}, {"name": LoopByCount, "args": (3,)})],
),
(
"[(({'name': PrepareNextChannel}, {'name': LoopByCount, 'args': (3)}))]",
[({"name": PrepareNextChannel}, {"name": LoopByCount, "args": (3,)})],
),
(
"[{'name': ZStackAcquisition, 'args': (True, False, 'zstack',)}]",
[
{
"name": ZStackAcquisition,
"args": (
True,
False,
"zstack",
),
}
],
),
],
)
def test_convert_str_to_feature_list(feature_list_str, expected_list):
feature_list = convert_str_to_feature_list(feature_list_str)
assert feature_list == expected_list
@pytest.mark.parametrize(
"feature_list, expected_str",
[
(None, "[]"),
([], "[]"),
([{"name": PrepareNextChannel}], '[{"name": PrepareNextChannel,},]'),
(
[({"name": PrepareNextChannel}, {"name": LoopByCount})],
'[({"name": PrepareNextChannel,},{"name": LoopByCount,},),]',
),
(
[({"name": PrepareNextChannel}, {"name": LoopByCount, "args": (3,)})],
'[({"name": PrepareNextChannel,},{"name": LoopByCount,"args": (3,),},),]',
),
(
[
{
"name": ZStackAcquisition,
"args": (
True,
False,
"zstack",
),
}
],
'[{"name": ZStackAcquisition,"args": (True,False,"zstack",),},]',
),
],
)
def test_convert_feature_list_to_str(feature_list, expected_str):
feature_str = convert_feature_list_to_str(feature_list)
assert feature_str == expected_str
if feature_list:
assert convert_str_to_feature_list(feature_str) == feature_list

View File

@@ -0,0 +1,48 @@
import os
import pytest
from navigate.tools.file_functions import delete_folder
@pytest.fixture()
def image_writer(dummy_model):
from navigate.model.features.image_writer import ImageWriter
model = dummy_model
model.configuration["experiment"]["Saving"]["save_directory"] = "test_save_dir"
writer = ImageWriter(dummy_model)
yield writer
writer.close()
def test_image_write_fail(image_writer):
image_writer.save_image([-1, image_writer.model.data_buffer.shape[0]])
# make sure the directory is empty
ls = os.listdir("test_save_dir")
ls.remove("MIP")
assert not ls
delete_folder("test_save_dir")
def test_image_write(image_writer):
from numpy.random import rand
# Randomize the data buffer
for i in range(image_writer.model.data_buffer.shape[0]):
image_writer.model.data_buffer[i, ...] = rand(
image_writer.model.img_width, image_writer.model.img_height
)
image_writer.save_image(list(range(image_writer.model.number_of_frames)))
# make sure the directory isn't empty
ls = os.listdir("test_save_dir")
ls.remove("MIP")
assert ls
delete_folder("test_save_dir")

View File

@@ -0,0 +1,222 @@
# 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 Import
import base64
import unittest
import json
import logging
from unittest.mock import patch, Mock, MagicMock
from io import BytesIO
# Third Party Imports
import numpy as np
# Local Imports
from navigate.model.features.restful_features import (
prepare_service,
IlastikSegmentation,
)
class TestPrepareService(unittest.TestCase):
def setUp(self):
self.service_url = "http://example.com/ilastik"
self.project_file = "path/to/project.ilp"
self.expected_url = f"{self.service_url}/load?project={self.project_file}"
logging.basicConfig(level=logging.INFO)
self.logger = logging.getLogger(
"mymodule"
) # Replace with the actual logger name used
@patch("navigate.model.features.restful_features.requests.get")
def test_prepare_service_success(self, mock_get):
expected_response = {"status": "success", "data": "segmentation data"}
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(expected_response)
mock_get.return_value = mock_response
response = prepare_service(self.service_url, project_file=self.project_file)
self.assertEqual(response, expected_response)
mock_get.assert_called_once_with(self.expected_url)
@patch("navigate.model.features.restful_features.requests.get")
def test_prepare_service_failure(self, mock_get):
mock_response = Mock()
mock_response.status_code = 404
mock_response.content = "Error"
mock_get.return_value = mock_response
response = prepare_service(self.service_url, project_file=self.project_file)
self.assertIsNone(response)
mock_get.assert_called_once_with(self.expected_url)
def test_prepare_service_non_ilastik_url(self):
non_ilastik_url = "http://example.com/not_ilastik"
response = prepare_service(non_ilastik_url, project_file=self.project_file)
self.assertIsNone(response)
class TestIlastikSegmentation(unittest.TestCase):
def setUp(self):
# Set up a mock model object
shape = (2048, 2048)
self.mock_model = Mock()
self.mock_model.configuration = {
"rest_api_config": {"Ilastik": {"url": "http://example.com/ilastik"}},
"experiment": {
"MicroscopeState": {"microscope_name": "Nanoscale", "zoom": "1.0"},
"CameraParameters": {
"Nanoscale": {"x_pixels": "2048", "y_pixels": "2048"},
},
"StageParameters": {
"x": "100",
"y": "100",
"z": "50",
"theta": "0",
"f": "1.0",
},
},
"configuration": {
"microscopes": {
"Nanoscale": {"zoom": {"pixel_size": {"N/A": "1.0", "1.0": "1.0"}}}
}
},
}
self.mock_model.data_buffer = {
0: np.random.randint(0, 65536, size=shape, dtype=np.uint16),
1: np.random.randint(0, 65536, size=shape, dtype=np.uint16),
}
self.mock_model.img_height = shape[0]
self.mock_model.img_width = shape[1]
self.mock_model.display_ilastik_segmentation = True
self.mock_model.mark_ilastik_position = False
self.mock_model.event_queue = MagicMock()
self.mock_model.active_microscope_name = "Nanoscale"
self.ilastik_segmentation = IlastikSegmentation(self.mock_model)
@patch("requests.post")
def test_data_func_success(self, mock_post):
frame_ids = [0, 1]
expected_json_data = {
"dtype": "uint16",
"shape": (self.mock_model.img_height, self.mock_model.img_width),
"image": [
base64.b64encode(self.mock_model.data_buffer[0]).decode("utf-8"),
base64.b64encode(self.mock_model.data_buffer[1]).decode("utf-8"),
],
}
# Create a valid numpy array to simulate the response
array_data = np.array([np.zeros((2048, 2048, 1), dtype=np.uint16)])
buffer = BytesIO()
np.savez(buffer, *array_data)
buffer.seek(0)
mock_response = Mock()
mock_response.status_code = 200
mock_response.raw.read.return_value = buffer.read()
mock_post.return_value = mock_response
self.ilastik_segmentation.data_func(frame_ids)
mock_post.assert_called_once_with(
"http://example.com/ilastik/segmentation",
json=expected_json_data,
stream=True,
)
self.mock_model.event_queue.put.assert_called()
# Test with self.model.mark_ilastik_position set to True
self.mock_model.mark_ilastik_position = True
self.mock_model.event_queue.reset_mock()
self.mock_model.ilastik_target_labels = range(1)
self.ilastik_segmentation.update_setting()
self.ilastik_segmentation.data_func(frame_ids)
assert self.mock_model.event_queue.put.call_count == 2
# self.mock_model.event_queue.put.assert_called_with(("multiposition"))
called_args, _ = self.mock_model.event_queue.put.call_args
assert "multiposition" in called_args[0]
@patch("requests.post")
def test_data_func_failure(self, mock_post):
frame_ids = [0, 1]
mock_response = Mock()
mock_response.status_code = 404
mock_response.content = "Error"
mock_post.return_value = mock_response
with patch("builtins.print") as mocked_print:
self.ilastik_segmentation.data_func(frame_ids)
mocked_print.assert_called_once_with("There is something wrong!")
def test_update_setting(self):
self.ilastik_segmentation.update_setting()
self.assertEqual(self.ilastik_segmentation.resolution, "Nanoscale")
self.assertEqual(self.ilastik_segmentation.zoom, "1.0")
self.assertEqual(self.ilastik_segmentation.pieces_num, 1)
self.assertEqual(self.ilastik_segmentation.pieces_size, 2048)
self.assertEqual(self.ilastik_segmentation.posistion_step_size, 2048)
self.assertEqual(self.ilastik_segmentation.x_start, -924)
self.assertEqual(self.ilastik_segmentation.y_start, -924)
def test_init_func_update_settings(self):
with patch.object(
self.ilastik_segmentation, "update_setting"
) as mock_update_setting:
self.ilastik_segmentation.resolution = "DifferentResolution"
self.ilastik_segmentation.zoom = "DifferentZoom"
self.ilastik_segmentation.init_func()
mock_update_setting.assert_called_once()
def test_mark_position(self):
mask = np.zeros((2048, 2048, 1), dtype=np.uint16)
mask[0:1024, 0:1024, 0] = 1 # Mock some segmentation data
self.mock_model.ilastik_target_labels = [1]
self.ilastik_segmentation.update_setting()
self.ilastik_segmentation.mark_position(mask)
self.mock_model.event_queue.put.assert_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,239 @@
# 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 queue
import pytest
import numpy as np
from navigate.model.features.volume_search import VolumeSearch
class TestVolumeSearch:
@pytest.fixture(
autouse=True,
params=[[False, False], [True, False], [False, True], [True, True]],
)
def _prepare_test(self, request, dummy_model_to_test_features):
flipx, flipy = request.param
self.model = dummy_model_to_test_features
self.config = self.model.configuration["experiment"]["MicroscopeState"]
self.record_num = 0
self.overlap = 0.0 # TODO: Make this consistently work for
# overlap > 0.0 (add fudge factor)
self.feature_list = [
[
{
"name": VolumeSearch,
"args": ("Nanoscale", "N/A", flipx, flipy, self.overlap),
}
]
]
self.sinx = 1 if flipx else -1
self.siny = 1 if flipy else -1
self.model.active_microscope_name = self.config["microscope_name"]
curr_zoom = self.model.configuration["experiment"]["MicroscopeState"]["zoom"]
self.curr_pixel_size = float(
self.model.configuration["configuration"]["microscopes"][
self.model.active_microscope_name
]["zoom"]["pixel_size"][curr_zoom]
)
self.target_pixel_size = float(
self.model.configuration["configuration"]["microscopes"]["Nanoscale"][
"zoom"
]["pixel_size"]["N/A"]
)
self.N = 128
# The target image size in pixels
self.mag_ratio = int(self.curr_pixel_size / self.target_pixel_size)
self.target_grid_pixels = int(self.N // self.mag_ratio)
# The target image size in microns
self.target_grid_width = self.N * self.target_pixel_size * (1 - self.overlap)
self.model.event_queue = queue.Queue(10)
self.model.configuration["experiment"]["StageParameters"]["z"] = 100
self.model.configuration["experiment"]["StageParameters"]["f"] = 100
self.config["start_position"] = np.random.randint(-200, 0)
self.config["end_position"] = self.config["start_position"] + 200
self.config["number_z_steps"] = np.random.randint(5, 10)
self.config["step_size"] = (
self.config["end_position"] - self.config["start_position"]
) / self.config["number_z_steps"]
self.config["start_focus"] = -10
self.config["end_focus"] = 10
self.focus_step = (
self.config["end_focus"] - self.config["start_focus"]
) / self.config["number_z_steps"]
def get_next_record(self, record_prefix, idx):
idx += 1
while self.model.signal_records[idx][0] != record_prefix:
idx += 1
if idx >= self.record_num:
break
return idx
def verify_volume_search(self):
self.record_num = len(self.model.signal_records)
idx = -1
z_pos = (
self.model.configuration["experiment"]["StageParameters"]["z"]
+ self.config["number_z_steps"] // 2 * self.config["step_size"]
)
f_pos = (
self.model.configuration["experiment"]["StageParameters"]["f"]
+ self.config["number_z_steps"] // 2 * self.focus_step
)
for j in range(self.config["number_z_steps"]):
idx = self.get_next_record("move_stage", idx)
if idx >= self.record_num:
# volume search ended early
break
pos_moved = self.model.signal_records[idx][1][0]
fact = (
j
if j < (self.config["number_z_steps"] + 1) // 2
else (self.config["number_z_steps"] + 1) // 2 - j - 1
)
# z, f
assert (
pos_moved["z_abs"] - (z_pos + fact * self.config["step_size"])
) < 1e-6, (
f"should move to z: {z_pos + fact * self.config['step_size']}, "
f"but moved to {pos_moved['z_abs']}"
)
assert (pos_moved["f_abs"] - (f_pos + fact * self.focus_step)) < 1e-6, (
f"should move to f: {f_pos + fact * self.focus_step}, "
f"but moved to {pos_moved['f_abs']}"
)
for _ in range(100):
event, value = self.model.event_queue.get()
if event == "multiposition":
break
positions = np.vstack(value) # Columns: X, Y, Z, Theta, F
# Check the bounding box. TODO: Make exact.
min_x = (
self.model.configuration["experiment"]["StageParameters"]["x"]
- self.sinx * self.lxy
)
max_x = self.model.configuration["experiment"]["StageParameters"][
"x"
] + self.sinx * (self.lxy + self.N // 2 * self.curr_pixel_size) * (
1 - self.overlap
)
min_y = (
self.model.configuration["experiment"]["StageParameters"]["y"]
- self.siny * self.lxy
)
max_y = self.model.configuration["experiment"]["StageParameters"][
"y"
] + self.siny * (self.lxy + self.N // 2 * self.curr_pixel_size) * (
1 - self.overlap
)
if self.sinx == -1:
tmp = max_x
max_x = min_x
min_x = tmp
if self.siny == -1:
tmp = max_y
max_y = min_y
min_y = tmp
min_z = self.model.configuration["experiment"]["StageParameters"]["z"] - self.lz
max_z = self.model.configuration["experiment"]["StageParameters"]["z"] + (
self.lz + self.N // 2 * self.curr_pixel_size
) * (1 - self.overlap)
assert np.min(positions[:, 0]) >= min_x
assert np.max(positions[:, 0]) <= max_x
assert np.min(positions[:, 1]) >= min_y
assert np.max(positions[:, 1]) <= max_y
assert np.min(positions[:, 2]) >= min_z
assert np.max(positions[:, 2]) <= max_z
def test_box_volume_search(self):
from navigate.tools.sdf import volume_from_sdf, box
M = int(self.config["number_z_steps"])
microscope_name = self.model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
self.model.configuration["experiment"]["CameraParameters"][microscope_name][
"x_pixels"
] = self.N
self.lxy = (
np.random.randint(int(0.1 * self.N), int(0.4 * self.N))
* self.curr_pixel_size
)
self.lz = (
np.random.randint(int(0.1 * self.N), int(0.4 * self.N))
* self.curr_pixel_size
)
vol = volume_from_sdf(
lambda p: box(
p,
(
self.lxy,
self.lxy,
self.lz,
),
),
self.N,
pixel_size=self.curr_pixel_size,
subsample_z=self.N // M,
)
vol = (vol <= 0) * 100
vol = vol[np.r_[(M // 2) : M, 0 : (M // 2)]]
self.model.data_buffer = vol
def get_offset_variance_maps():
return np.zeros((self.N, self.N)), np.ones((self.N, self.N))
self.model.get_offset_variance_maps = get_offset_variance_maps
self.model.start(self.feature_list)
self.verify_volume_search()

View File

@@ -0,0 +1,185 @@
import os
import numpy as np
import pytest
@pytest.mark.parametrize("ext", ["h5", "n5", "tiff"])
def test_bdv_metadata(ext):
from navigate.model.metadata_sources.bdv_metadata import BigDataViewerMetadata
md = BigDataViewerMetadata()
views = []
for _ in range(10):
views.append(
{
"x": np.random.randint(-1000, 1000),
"y": np.random.randint(-1000, 1000),
"z": np.random.randint(-1000, 1000),
"theta": np.random.randint(-1000, 1000),
"f": np.random.randint(-1000, 1000),
}
)
for view in views:
arr = md.stage_positions_to_affine_matrix(**view)
assert arr[0, 3] == view["y"] / md.dy
assert arr[1, 3] == view["x"] / md.dx
assert arr[2, 3] == view["z"] / md.dz
md.write_xml(f"test_bdv.{ext}", views)
os.remove("test_bdv.xml")
# Test defaults for shear transform.
assert md.rotate_data is False
assert md.shear_data is False
assert np.shape(md.rotate_transform) == (3, 4)
assert np.shape(md.shear_transform) == (3, 4)
# Confirm that the shear/rotation transforms are identity matrices by default
assert np.all(md.shear_transform == np.eye(3, 4))
assert np.all(md.rotate_transform == np.eye(3, 4))
# Confirm that the shear/rotation transforms are identity matrices by default
# even after calling calculate_shear_transform and calculate_rotate_transform
md.bdv_shear_transform()
md.bdv_rotate_transform()
assert np.all(md.shear_transform == np.eye(3, 4))
assert np.all(md.rotate_transform == np.eye(3, 4))
# Test that the shear/rotation transforms are correctly calculated.
md.shear_data = True
md.shear_dimension = "XZ"
md.shear_angle = 15
md.dx, md.dy, md.dz = 1, 1, 1
md.bdv_shear_transform()
assert md.shear_transform[0, 2] == np.tan(np.deg2rad(15))
md.rotate_data = True
md.rotate_angle_x = 15
md.rotate_angle_y = 0
md.rotate_angle_z = 0
md.bdv_rotate_transform()
assert md.rotate_transform[1, 1] == np.cos(np.deg2rad(15))
assert md.rotate_transform[1, 2] == -np.sin(np.deg2rad(15))
assert md.rotate_transform[2, 1] == np.sin(np.deg2rad(15))
assert md.rotate_transform[2, 2] == np.cos(np.deg2rad(15))
# Make sure we can still write the data.
md.write_xml(f"test_bdv.{ext}", views)
os.remove("test_bdv.xml")
@pytest.mark.parametrize("stack_cycling_mode", ["per_stack", "per_z"])
def test_bdv_xml_dict(dummy_model, stack_cycling_mode):
from navigate.model.metadata_sources.bdv_metadata import BigDataViewerMetadata
md = BigDataViewerMetadata()
md.configuration = dummy_model.configuration.copy()
# set shape from configuration and experiment
md.configuration["experiment"]["MicroscopeState"]["image_mode"] = "z-stack"
# timepoints, channels, z-slices, positions
for tp in [1]: #, 2, 3, 5]:
for pos in [1, 3, 5]:
for ch in [1, 2, 3]:
for z in [1, 5, 10, 20]:
md.configuration["experiment"]["MicroscopeState"]["timepoints"] = tp
# channel settings
channel_dict = md.configuration["experiment"]["MicroscopeState"]["channels"]
for i in range(1, ch+1):
channel_name = f"channel_{i}"
if channel_name not in channel_dict:
channel_dict[channel_name] = {
"is_selected": True,
"laser": "488nm",
"laser_index": 0,
"camera_exposure_time": 200.0,
"laser_power": 20.0,
"interval_time": 1.0,
"defocus": 104.0,
"filter_wheel_0": "Empty-Alignment",
"filter_position_0": 6,
"filter_wheel_1": "Empty-Alignment",
"filter_position_1": 6
}
else:
channel_dict[channel_name]["is_selected"] = True
for i in range(ch+1, 5):
channel_name = f"channel_{i}"
if channel_name in channel_dict:
channel_dict[channel_name]["is_selected"] = False
# z-stack settings
start_z_position = md.configuration["experiment"]["MicroscopeState"]["start_position"]
md.configuration["experiment"]["MicroscopeState"]["number_z_steps"] = z
md.configuration["experiment"]["MicroscopeState"]["step_size"] = 0.2
md.configuration["experiment"]["MicroscopeState"]["end_position"] = z * 0.2 + start_z_position
# multiposition settings
md.configuration["experiment"]["MicroscopeState"]["is_multiposition"] = (
True if pos > 1 else False
)
multipositions = [
["X", "Y", "Z", "THETA", "F"]
]
for p in range(pos):
position = [
np.random.uniform(0, 100), # X
np.random.uniform(0, 100), # Y
np.random.uniform(0, 100), # Z
np.random.uniform(0, 360), # THETA
np.random.uniform(0, 10), # F
]
multipositions.append(position)
md.configuration["multi_positions"] = multipositions
md.set_from_configuration_experiment()
assert md.shape_t == tp
assert md.shape_c == ch
assert md.shape_z == z
assert md.positions == pos
if pos > 1:
assert md._multiposition is True
else:
assert md._multiposition is False
# view
views = []
for p in range(1, pos):
for c in range(ch):
position = md.configuration["multi_positions"][p + 1]
for _z in range(z):
views.append(
{
"x": position[0],
"y": position[1],
"z": position[2] + start_z_position + _z * 0.2,
"theta": position[3],
"f": position[4],
}
)
xml_dict = md.bdv_xml_dict("test_file.h5", views)
assert "ImageLoader" in xml_dict["SequenceDescription"]
assert "ViewSetups" in xml_dict["SequenceDescription"]
assert "Timepoints" in xml_dict["SequenceDescription"]
assert "ViewRegistrations" in xml_dict
# verify affine values are the same for a position
view_registrations = xml_dict["ViewRegistrations"]["ViewRegistration"]
for i in range(len(view_registrations)):
# assert view id
view_id = view_registrations[i]["setup"]
if ch > 1:
assert view_id == ((i % ch) * pos + (i // ch))
# assert affine position consistency between channels
if i % ch == 0:
affine = view_registrations[i]["ViewTransform"][0]["affine"]["text"]
else:
affine_c = view_registrations[i]["ViewTransform"][0]["affine"]["text"]
assert affine == affine_c

View File

@@ -0,0 +1,154 @@
import pytest
import random
def test_metadata_voxel_size(dummy_model):
from navigate.model.metadata_sources.metadata import Metadata
md = Metadata()
md.configuration = dummy_model.configuration
zoom = dummy_model.configuration["experiment"]["MicroscopeState"]["zoom"]
active_microscope = dummy_model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
pixel_size = float(
dummy_model.configuration["configuration"]["microscopes"][active_microscope][
"zoom"
]["pixel_size"][zoom]
)
dx, dy, dz = md.voxel_size
assert (
(dx == pixel_size)
and (dy == pixel_size)
and (
dz
== float(
dummy_model.configuration["experiment"]["MicroscopeState"]["step_size"]
)
)
)
def test_metadata_shape(dummy_model):
from navigate.model.metadata_sources.metadata import Metadata
dummy_model.configuration["experiment"]["MicroscopeState"]["image_mode"] = "z-stack"
md = Metadata()
md.configuration = dummy_model.configuration
microscope_name = dummy_model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
txs = dummy_model.configuration["experiment"]["CameraParameters"][microscope_name][
"img_x_pixels"
]
tys = dummy_model.configuration["experiment"]["CameraParameters"][microscope_name][
"img_y_pixels"
]
tzs = dummy_model.configuration["experiment"]["MicroscopeState"]["number_z_steps"]
tts = dummy_model.configuration["experiment"]["MicroscopeState"]["timepoints"]
tcs = sum(
[
v["is_selected"] is True
for k, v in dummy_model.configuration["experiment"]["MicroscopeState"][
"channels"
].items()
]
)
xs, ys, cs, zs, ts = md.shape
assert (xs == txs) and (ys == tys) and (zs == tzs) and (ts == tts) and (cs == tcs)
@pytest.mark.parametrize(
"image_mode",
[
"single",
"Confocal Projection",
"z-stack",
],
)
@pytest.mark.parametrize("stack_cycling_mode", ["per_stack", "per_z"])
@pytest.mark.parametrize("conpro_cycling_mode", ["per_stack", "per_plane"])
def test_metadata_set_stack_order_from_configuration_experiment(
dummy_model, image_mode, stack_cycling_mode, conpro_cycling_mode
):
from navigate.model.metadata_sources.metadata import Metadata
dummy_model.configuration["experiment"]["MicroscopeState"][
"image_mode"
] = image_mode
dummy_model.configuration["experiment"]["MicroscopeState"][
"stack_cycling_mode"
] = stack_cycling_mode
dummy_model.configuration["experiment"]["MicroscopeState"][
"conpro_cycling_mode"
] = conpro_cycling_mode
md = Metadata()
md.configuration = dummy_model.configuration
if image_mode == "z-stack" and stack_cycling_mode == "per_stack":
assert md._per_stack is True
elif image_mode == "Confocal Projection" and stack_cycling_mode == "per_stack":
assert md._per_stack is True
else:
assert md._per_stack is False
def set_shape_from_configuration_experiment(dummy_model):
from navigate.model.metadata_sources.metadata import Metadata
md = Metadata()
md.configuration = dummy_model.configuration.copy()
# set up experiment with multiposition
# no position
md.configuration["experiment"]["MicroscopeState"]["image_mode"] = "z-stack"
md.configuration["multi_positions"] = [
["X", "Y", "Z", "THETA", "F"]
]
md.configuration["expriment"]["MicroscopeState"]["is_multiposition"] = False
md._set_shape_from_configuration_experiment()
assert md._multiposition is False
assert md.positions == 1
# customized mode
md.configuration["experiment"]["MicroscopeState"]["image_mode"] = "customized"
assert md._multiposition is True
assert md.positions == 1
# random multiposition
md.configuration["experiment"]["MicroscopeState"]["image_mode"] = "z-stack"
for i in range(5):
num_positions = random.randint(2, 10)
md.configuration["multi_positions"] = [["X", "Y", "Z", "THETA", "F"]]
for p in range(num_positions):
pos = [
random.uniform(0, 100), # X
random.uniform(0, 100), # Y
random.uniform(0, 100), # Z
random.uniform(0, 360), # THETA
random.uniform(0, 10), # F
]
md.configuration["multi_positions"].append(pos)
md.configuration["experiment"]["MicroscopeState"]["is_multiposition"] = True
md._set_shape_from_configuration_experiment()
assert md._multiposition is True
assert md.positions == num_positions

View File

@@ -0,0 +1,46 @@
import urllib.request
import os
import platform
from navigate.tools.file_functions import delete_folder
def test_ome_metadata_valid(dummy_model):
from navigate.model.metadata_sources.ome_tiff_metadata import OMETIFFMetadata
# First, download OME-XML validation tools
# new_path = https://downloads.openmicroscopy.org/bio-formats/8.1.0/artifacts/bftools.zip
# old_path = https://downloads.openmicroscopy.org/bio-formats/6.0.1/artifacts/bftools.zip
urllib.request.urlretrieve(
"https://downloads.openmicroscopy.org/bio-formats/8.1.0/artifacts/bftools.zip",
"bftools.zip",
)
# Unzip
_ = os.popen("tar -xzvf bftools.zip").read()
# Create metadata
md = OMETIFFMetadata()
md.configuration = dummy_model.configuration
# Write metadata to file
md.write_xml("test.xml")
# Validate the XML
if platform.system() == "Windows":
output = os.popen("bftools\\xmlvalid.bat test.xml").read()
else:
output = os.popen("./bftools/xmlvalid test.xml").read()
print(output)
# Delete bftools
delete_folder("./bftools")
os.remove("bftools.zip")
# Delete XML
os.remove("test.xml")
assert "No validation errors found." in output

View File

@@ -0,0 +1,189 @@
import pytest
SPACE_UNITS = [
"angstrom",
"attometer",
"centimeter",
"decimeter",
"exameter",
"femtometer",
"foot",
"gigameter",
"hectometer",
"inch",
"kilometer",
"megameter",
"meter",
"micrometer",
"mile",
"millimeter",
"nanometer",
"parsec",
"petameter",
"picometer",
"terameter",
"yard",
"yoctometer",
"yottameter",
"zeptometer",
"zettameter",
]
TIME_UNITS = [
"attosecond",
"centisecond",
"day",
"decisecond",
"exasecond",
"femtosecond",
"gigasecond",
"hectosecond",
"hour",
"kilosecond",
"megasecond",
"microsecond",
"millisecond",
"minute",
"nanosecond",
"petasecond",
"picosecond",
"second",
"terasecond",
"yoctosecond",
"yottasecond",
"zeptosecond",
"zettasecond",
]
@pytest.fixture
def dummy_metadata(dummy_model):
from navigate.model.metadata_sources.zarr_metadata import OMEZarrMetadata
# Create metadata
md = OMEZarrMetadata()
md.configuration = dummy_model.configuration
return md
def test_axes(dummy_metadata):
axes = dummy_metadata._axes
# Check length
assert (len(axes) > 1) and (len(axes) < 6)
# Check list types and count
time_count = 0
space_count = 0
channel_count = 0
custom_count = 0
for d in axes:
if d["type"] == "time":
assert d["unit"] in TIME_UNITS
time_count += 1
elif d["type"] == "space":
assert d["unit"] in SPACE_UNITS
space_count += 1
elif d["type"] == "channel":
channel_count += 1
else:
custom_count += 1
assert (space_count > 1) and (space_count < 4)
assert time_count < 2
assert ((channel_count < 2) and (custom_count == 0)) or (
(channel_count == 0) and (custom_count < 2)
)
# Check order
order_type = [x["type"] for x in axes]
if "time" in order_type:
# Time must be first, if present
assert order_type.index("time") == 0
if "channel" in order_type:
# Channel must be before all the space axes, if present
ci = order_type.index("channel")
for i, el in enumerate(order_type):
if el == "space":
assert i > ci
# Skip zyx order spec as the naming of axes is not enforcable.
def test_stage_positions_to_translation_transform(dummy_metadata):
import random
pos = [random.random() for _ in range(5)]
translation = dummy_metadata._stage_positions_to_translation_transform(*pos)
axes = dummy_metadata._axes
assert len(translation) == len(axes)
def test_scale_transform(dummy_metadata):
scale = dummy_metadata._scale_transform()
axes = dummy_metadata._axes
assert len(scale) == len(axes)
def test_coordinate_transformations(dummy_metadata):
import random
pos = [random.random() for _ in range(5)]
translation = dummy_metadata._stage_positions_to_translation_transform(*pos)
scale = dummy_metadata._scale_transform()
assert len(dummy_metadata._coordinate_transformations(scale)) == 1
combo = dummy_metadata._coordinate_transformations(scale, translation)
assert len(combo) == 2
assert combo[0]["type"] == "scale" and combo[1]["type"] == "translation"
with pytest.raises(UserWarning):
dummy_metadata._coordinate_transformations(translation=translation)
def test_multiscale_metadata(dummy_metadata):
"""https://ngff.openmicroscopy.org/0.4/#multiscale-md"""
import numpy as np
import random
resolutions = np.array([[1, 1, 1], [2, 2, 1], [4, 4, 1], [8, 8, 1]], dtype=int)
paths = [f"path{i}" for i in range(resolutions.shape[0])]
view = {k: random.random() for k in ["x", "y", "z", "theta", "f"]}
msd = dummy_metadata.multiscales_dict("test", paths, resolutions, view)
# Each "multiscales" dictionary MUST contain the field "axes"
assert "axes" in msd.keys()
# Each "multiscales" dictionary MUST contain the field "datasets"
assert "datasets" in msd.keys()
# Each dictionary in "datasets" MUST contain the field "path",
# whose value contains the path to the array for this resolution
# relative to the current zarr group. The "path"s MUST be ordered
# from largest (i.e. highest resolution) to smallest.
# Each "datasets" dictionary MUST have the same number of dimensions
# and MUST NOT have more than 5 dimensions.
# Each "multiscales" dictionary SHOULD contain the field "name"
assert "name" in msd.keys()
# Each "multiscales" dictionary MAY contain the field "coordinateTransformations"
assert "coordinateTransformations" in msd.keys()
# It SHOULD contain the field "version"
assert "version" in msd.keys()
# Each "multiscales" dictionary SHOULD contain the field "type", which gives
# the type of downscaling method used to generate the multiscale image pyramid.
# It SHOULD contain the field "metadata", which contains a dictionary with
# additional information about the downscaling method.

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.
# Standard Library Imports
import unittest
# Third Party Imports
import numpy as np
import pytest
# Local Imports
# sys.path.append('../../../')
def box(size):
x = np.linspace(0, 1, 100)
X, Y = np.meshgrid(x, x)
l = (1 - size) / 2 # noqa
u = l + size
image = (X > l) & (X < u) & (Y > l) & (Y < u)
return image.astype(float)
def power_tent(r, off, scale, sigma, alpha):
return off + scale * (1 - np.abs(sigma * r) ** alpha)
def power_tent_res(x, r, val):
return power_tent(r, *x) - val
def rsq(res_func, x, r, val):
ss_err = (res_func(x, r, val) ** 2).sum()
ss_tot = ((val - val.mean()) ** 2).sum()
rsq = 1 - (ss_err / ss_tot)
return rsq
def test_fast_normalized_dct_shannon_entropy_tent():
from scipy.ndimage import gaussian_filter
from scipy.optimize import least_squares
from navigate.model.analysis.image_contrast import (
fast_normalized_dct_shannon_entropy,
)
im = box(0.5)
r = range(0, 60)
points = np.zeros((len(r),))
for i in r:
points[i] = fast_normalized_dct_shannon_entropy(gaussian_filter(im, i), 1)[0]
res = least_squares(
power_tent_res, [np.min(points), np.max(points), 1, 0.5], args=(r, points)
)
assert rsq(power_tent_res, res.x, r, points) > 0.9
def test_fast_normalized_dct_shannon_entropy():
from navigate.model.analysis.image_contrast import (
fast_normalized_dct_shannon_entropy,
)
# image_array = np.ones((np.random.randint(1,4),128,128)).squeeze()
image_array = np.ones((128, 128)).squeeze()
psf_support_diameter_xy = np.random.randint(3, 10)
entropy = fast_normalized_dct_shannon_entropy(image_array, psf_support_diameter_xy)
assert np.all(entropy == 0)
"""
Delete the below assert once the calculate entropy function is found
"""
def test_entropy():
assert True
try:
# from navigate.model.navigate_analysis import Analysis as navigate_analysis
from navigate.model.navigate_debug_model import calculate_entropy
class TestNavigateAnalysis(unittest.TestCase):
"""
Unit Tests for the Navigate Analysis Module
"""
@pytest.mark.skip(reason="file path not found")
def test_calculate_entropy_on(self):
"""
Test the calculation of the Shannon Entropy
"""
dct_array = np.ones((128, 128))
otf_support_x = 3
otf_support_y = 3
# This trys to call from the navigate_analysis module however its only
# located in the navigate_debug_model
# entropy = navigate_analysis.calculate_entropy()
entropy = calculate_entropy(
self,
dct_array=dct_array,
otf_support_x=otf_support_x,
otf_support_y=otf_support_y,
)
self.assertEqual(entropy, 0)
except ImportError as e:
print(e)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,84 @@
# 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 MagicMock
# Third party imports
# Local application imports
from navigate.model.device_startup_functions import auto_redial
class TestAutoRedial(unittest.TestCase):
"""Test the auto_redial function."""
def test_successful_connection_first_try(self):
"""Test successful connection on the first try."""
mock_func = MagicMock(return_value="success")
result = auto_redial(mock_func, ())
self.assertEqual(result, "success")
def test_successful_connection_after_failures(self):
"""Test successful connection after a few failures."""
mock_func = MagicMock(
side_effect=[
Exception("fail"),
Exception("fail"),
"success",
Exception("fail"),
"success",
]
)
result = auto_redial(mock_func, (), n_tries=5)
self.assertEqual(result, "success")
assert mock_func.call_count == 3
def test_failure_after_all_retries(self):
"""Test failure after all retries."""
mock_func = MagicMock(side_effect=Exception("fail"))
with self.assertRaises(Exception):
auto_redial(mock_func, (), n_tries=3)
assert mock_func.call_count == 3
def test_exception_type_handling(self):
"""Test that only the specified exception type is caught."""
mock_func = MagicMock(side_effect=[ValueError("wrong exception"), "success"])
with self.assertRaises(ValueError):
auto_redial(mock_func, (), n_tries=3, exception=TypeError)
assert mock_func.call_count == 1
def test_arguments_passing(self):
"""Test that arguments and keyword arguments are correctly passed."""
mock_func = MagicMock()
auto_redial(mock_func, (1, 2), n_tries=1, kwarg1="test")
mock_func.assert_called_with(1, 2, kwarg1="test")

View File

@@ -0,0 +1,242 @@
# 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
import random
@pytest.fixture(scope="module")
def dummy_microscope(dummy_model):
from navigate.model.microscope import Microscope
from navigate.model.device_startup_functions import load_devices
devices_dict = load_devices(
dummy_model.active_microscope_name, dummy_model.configuration, is_synthetic=True
)
return Microscope(
dummy_model.active_microscope_name,
dummy_model.configuration,
devices_dict,
is_synthetic=True,
is_virtual=False,
)
def test_prepare_acquisition(dummy_microscope):
waveform_dict = dummy_microscope.prepare_acquisition()
channels = dummy_microscope.configuration["experiment"]["MicroscopeState"][
"channels"
]
assert dummy_microscope.current_channel == 0
assert dummy_microscope.central_focus is None
assert dummy_microscope.available_channels == list(
map(
lambda c: int(c[len("channel_") :]),
filter(lambda k: channels[k]["is_selected"], channels.keys()),
)
)
assert dummy_microscope.camera.is_acquiring is True
assert dummy_microscope.shutter.shutter_state is True
assert isinstance(waveform_dict, dict)
assert [
k in waveform_dict.keys()
for k in ["camera_waveform", "remote_focus_waveform", "galvo_waveform"]
]
def test_move_stage(dummy_microscope):
import numpy as np
acquisition_mode = dummy_microscope.configuration["experiment"]["MicroscopeState"][
"image_mode"
]
expected_device_flag = {
"continous": True,
"single": True,
"z-stack": False,
"customized": False,
}
for mode in expected_device_flag:
dummy_microscope.configuration["experiment"]["MicroscopeState"][
"image_mode"
] = mode
# move stage to random position
axes = ["x", "y", "z", "theta", "f"]
for i in range(5):
test_axes = random.sample(axes, i+1)
pos_dict = {
f"{k}_abs": v
for k, v in zip(test_axes, np.random.rand(len(test_axes)) * 100)
}
dummy_microscope.move_stage(pos_dict, wait_until_done=True)
assert dummy_microscope.ask_stage_for_position == expected_device_flag[mode]
if expected_device_flag[mode] == False:
# assert position is cached
for axis in test_axes:
assert round(dummy_microscope.ret_pos_dict[axis + "_pos"], 2) == round(
pos_dict[f"{axis}_abs"], 2
)
# set back acquisition mode
dummy_microscope.configuration["experiment"]["MicroscopeState"][
"image_mode"
] = acquisition_mode
def test_get_stage_position(dummy_microscope):
import numpy as np
acquisition_mode = dummy_microscope.configuration["experiment"]["MicroscopeState"][
"image_mode"
]
report_position_funcs = {}
axes_dict = {}
for stage, axes in dummy_microscope.stages_list:
for axis in axes:
axes_dict[axis] = axes
report_position_funcs[axis] = stage.report_position
is_called = dict([(axis, False) for axis in dummy_microscope.stages])
def report_position_mock(axis):
def func():
for a in axes_dict[axis]:
is_called[a] = True
return report_position_funcs[axis]()
return func
for axis in dummy_microscope.stages:
dummy_microscope.stages[axis].report_position = report_position_mock(axis)
expected_device_flag = {
"continous": True,
"single": True,
"z-stack": False,
"customized": False,
}
for mode in expected_device_flag:
dummy_microscope.configuration["experiment"]["MicroscopeState"][
"image_mode"
] = mode
# move stage to random position
pos_dict = {
f"{k}_abs": v
for k, v in zip(["x", "y", "z", "theta", "f"], np.random.rand(5) * 100)
}
dummy_microscope.move_stage(pos_dict, wait_until_done=True)
assert dummy_microscope.ask_stage_for_position == expected_device_flag[mode]
for axis in is_called:
is_called[axis] = False
stage_dict = dummy_microscope.get_stage_position()
# verify if report_position is called according to mode
for axis in is_called:
assert is_called[axis] == expected_device_flag[mode]
ret_pos_dict = {}
for axis in dummy_microscope.stages:
pos_axis = axis + "_pos"
temp_pos = dummy_microscope.stages[axis].report_position()
ret_pos_dict[pos_axis] = round(temp_pos[pos_axis], 2)
assert isinstance(stage_dict, dict)
assert ret_pos_dict == stage_dict
# Check caching
assert dummy_microscope.ask_stage_for_position is False
for axis in is_called:
is_called[axis] = False
stage_dict = dummy_microscope.get_stage_position()
assert ret_pos_dict == stage_dict
assert dummy_microscope.ask_stage_for_position is False
for axis in is_called:
assert is_called[axis] is False
# set back acquisition mode
dummy_microscope.configuration["experiment"]["MicroscopeState"][
"image_mode"
] = acquisition_mode
# restore report position functions
for axis in dummy_microscope.stages:
dummy_microscope.stages[axis].report_position = report_position_funcs[axis]
def test_prepare_next_channel(dummy_microscope):
dummy_microscope.prepare_acquisition()
current_channel = dummy_microscope.available_channels[0]
channel_key = f"channel_{current_channel}"
channel_dict = dummy_microscope.configuration["experiment"]["MicroscopeState"][
"channels"
][channel_key]
channel_dict["defocus"] = random.randint(1, 10)
dummy_microscope.prepare_next_channel()
assert dummy_microscope.current_channel == current_channel
assert dummy_microscope.get_stage_position()["f_pos"] == (
dummy_microscope.central_focus + channel_dict["defocus"]
)
def test_calculate_all_waveform(dummy_microscope):
# set waveform template to default
dummy_microscope.configuration["experiment"]["MicroscopeState"][
"waveform_template"
] = "Default"
waveform_dict = dummy_microscope.calculate_all_waveform()
# verify the waveform lengths
sweep_times = dummy_microscope.sweep_times
sample_rate = dummy_microscope.configuration["configuration"]["microscopes"][
dummy_microscope.microscope_name
]["daq"]["sample_rate"]
for channel_key in sweep_times:
waveform_length = int(sweep_times[channel_key] * sample_rate)
assert waveform_dict["camera_waveform"][channel_key].shape == (waveform_length,)
assert waveform_dict["remote_focus_waveform"][channel_key].shape == (
waveform_length,
)
for i in range(len(waveform_dict["galvo_waveform"])):
assert waveform_dict["galvo_waveform"][i][channel_key].shape == (
waveform_length,
)

443
test/model/test_model.py Normal file
View File

@@ -0,0 +1,443 @@
# 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
import pytest
import os
from multiprocessing import Manager
from unittest.mock import MagicMock
import multiprocessing
# Third Party Imports
# Local Imports
IN_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true"
@pytest.fixture(scope="module")
def model():
from types import SimpleNamespace
from pathlib import Path
from navigate.model.model import Model
from navigate.config.config import (
load_configs,
verify_experiment_config,
verify_waveform_constants,
verify_configuration,
verify_positions_config,
)
from navigate.tools.file_functions import load_yaml_file
with Manager() as manager:
# Use configuration files that ship with the code base
configuration_directory = Path.joinpath(
Path(__file__).resolve().parent.parent.parent, "src", "navigate", "config"
)
configuration_path = Path.joinpath(
configuration_directory, "configuration.yaml"
)
experiment_path = Path.joinpath(configuration_directory, "experiment.yml")
waveform_constants_path = Path.joinpath(
configuration_directory, "waveform_constants.yml"
)
rest_api_path = Path.joinpath(configuration_directory, "rest_api_config.yml")
multi_positions_path = Path.joinpath(
configuration_directory, "multi_positions.yml"
)
event_queue = MagicMock()
configuration = load_configs(
manager,
configuration=configuration_path,
experiment=experiment_path,
waveform_constants=waveform_constants_path,
rest_api_config=rest_api_path,
)
verify_configuration(manager, configuration)
verify_experiment_config(manager, configuration)
verify_waveform_constants(manager, configuration)
positions = load_yaml_file(multi_positions_path)
positions = verify_positions_config(positions)
configuration["multi_positions"] = positions
queue = multiprocessing.Queue()
model = Model(
args=SimpleNamespace(synthetic_hardware=True),
configuration=configuration,
event_queue=event_queue,
log_queue=queue,
)
model.__test_manager = manager
yield model
while not queue.empty():
queue.get()
queue.close()
queue.join_thread()
def test_single_acquisition(model):
state = model.configuration["experiment"]["MicroscopeState"]
state["image_mode"] = "single"
state["is_save"] = False
n_frames = len(
list(filter(lambda channel: channel["is_selected"], state["channels"].values()))
)
show_img_pipe = model.create_pipe("show_img_pipe")
model.run_command("acquire")
image_id = show_img_pipe.recv()
n_images = 0
max_iters = 10
while image_id != "stop" and max_iters > 0:
image_id = show_img_pipe.recv()
n_images += 1
max_iters -= 1
assert n_images == n_frames
model.data_thread.join()
model.release_pipe("show_img_pipe")
def test_live_acquisition(model):
state = model.configuration["experiment"]["MicroscopeState"]
state["image_mode"] = "live"
n_images = 0
pre_channel = 0
show_img_pipe = model.create_pipe("show_img_pipe")
model.run_command("acquire")
while True:
image_id = show_img_pipe.recv()
if image_id == "stop":
break
channel_id = model.active_microscope.current_channel
assert channel_id != pre_channel
pre_channel = channel_id
n_images += 1
if n_images >= 30:
model.run_command("stop")
model.data_thread.join()
model.release_pipe("show_img_pipe")
def test_autofocus_live_acquisition(model):
state = model.configuration["experiment"]["MicroscopeState"]
state["image_mode"] = "live"
n_images = 0
pre_channel = 0
autofocus = False
show_img_pipe = model.create_pipe("show_img_pipe")
model.run_command("acquire")
while True:
image_id = show_img_pipe.recv()
if image_id == "stop":
break
channel_id = model.active_microscope.current_channel
if not autofocus:
assert channel_id != pre_channel
pre_channel = channel_id
n_images += 1
if n_images >= 100:
model.run_command("stop")
elif n_images >= 70:
autofocus = False
elif n_images == 30:
autofocus = True
model.run_command("autofocus")
model.data_thread.join()
model.release_pipe("show_img_pipe")
@pytest.mark.skipif(IN_GITHUB_ACTIONS, reason="Test hangs entire workflow on GitHub.")
def test_multiposition_acquisition(model):
"""Test that the multiposition acquisition works as expected.
This test is meant to confirm that if the multi position check box is set,
but there aren't actually any positions in the multi-position table, that the
acquisition proceeds as if it is not a multi position acquisition.
Sleep statements are used to ensure that the event queue has ample opportunity to
be populated with the disable_multiposition event. This is because the event queue
is a multiprocessing.Queue, which is not thread safe.
"""
# from time import sleep
from navigate.config.config import update_config_dict
# def check_queue(event, event_queue):
# """Check if the event queue contains the event. If it does, return True.
# Otherwise, return False.
# Parameters
# ----------
# event : str
# The event to check for in the event queue.
# event_queue : multiprocessing.Queue
# The event queue to check.
# """
# while not event_queue.empty():
# ev, _ = event_queue.get()
# if ev == event:
# return True
# return False
_ = model.create_pipe("show_img_pipe")
# Multiposition is selected and actually is True
model.configuration["experiment"]["MicroscopeState"]["is_multiposition"] = True
update_config_dict(
model.__test_manager, # noqa
model.configuration,
"multi_positions",
[["X", "Y", "Z", "THETA", "F"],[10.0, 10.0, 10.0, 10.0, 10.0]],
)
model.configuration["experiment"]["MicroscopeState"]["image_mode"] = "z-stack"
model.configuration["experiment"]["MicroscopeState"]["number_z_steps"] = 10
model.configuration["experiment"]["MicroscopeState"]["step_size"] = 5.0
model.configuration["experiment"]["MicroscopeState"]["end_position"] = (
model.configuration["experiment"]["MicroscopeState"]["start_position"] + 15.0
)
model.run_command("acquire")
# sleep(1)
# assert (
# check_queue(event="disable_multiposition", event_queue=model.event_queue)
# is False
# )
assert (
model.configuration["experiment"]["MicroscopeState"]["is_multiposition"] is True
)
model.data_thread.join()
# Multiposition is selected but not actually True
update_config_dict(
model.__test_manager,
model.configuration,
"multi_positions",
[], # noqa
)
model.run_command("acquire")
# sleep(1)
# # Check that the event queue is called with the disable_multiposition statement
# assert (
# check_queue(event="disable_multiposition", event_queue=model.event_queue)
# is True
# )
assert (
model.configuration["experiment"]["MicroscopeState"]["is_multiposition"]
is False
)
model.data_thread.join()
model.release_pipe("show_img_pipe")
def test_change_resolution(model):
"""
Note: The stage position check is an absolute mess due to us instantiating two
SyntheticStages--one for each microsocpe. We have to continuously reset the
stage positions to all zeros and make the configuration.yaml that comes with the
software have negative stage bounds.
"""
scopes = random.choices(
model.configuration["configuration"]["microscopes"].keys(), k=10
)
zooms = [
random.choice(
model.configuration["configuration"]["microscopes"][scope]["zoom"][
"position"
].keys()
)
for scope in scopes
]
axes = ["x", "y", "z", "theta", "f"]
for scope, zoom in zip(scopes, zooms):
# reset stage axes to all zeros, to match default SyntheticStage behaviour
for microscope in model.microscopes:
for ax in axes:
model.microscopes[microscope].stages[ax].move_absolute(
{ax + "_abs": 0}, wait_until_done=True
)
former_offset_dict = model.configuration["configuration"]["microscopes"][
model.configuration["experiment"]["MicroscopeState"]["microscope_name"]
]["stage"]
former_pos_dict = model.get_stage_position()
former_zoom = model.configuration["experiment"]["MicroscopeState"]["zoom"]
model.active_microscope.zoom.set_zoom(former_zoom)
print(f"{model.active_microscope_name}: {former_pos_dict}")
print(
f"CHANGING {model.active_microscope_name} at "
f'{model.configuration["experiment"]["MicroscopeState"]["zoom"]} to {scope}'
f" at {zoom}"
)
model.configuration["experiment"]["MicroscopeState"]["microscope_name"] = scope
model.configuration["experiment"]["MicroscopeState"]["zoom"] = zoom
solvent = model.configuration["experiment"]["Saving"]["solvent"]
model.change_resolution(scope)
self_offset_dict = model.configuration["configuration"]["microscopes"][scope][
"stage"
]
pos_dict = model.get_stage_position()
print(f"{model.active_microscope_name}: {pos_dict}")
# reset stage axes to all zeros, to match default SyntheticStage behaviour
for ax in model.active_microscope.stages:
print(f"axis {ax}")
try:
shift_ax = float(
model.active_microscope.zoom.stage_offsets[solvent][ax][
former_zoom
][zoom]
)
print(f"shift_ax {shift_ax}")
except (TypeError, KeyError):
shift_ax = 0
assert (
pos_dict[ax + "_pos"]
- self_offset_dict[ax + "_offset"]
+ former_offset_dict[ax + "_offset"]
- shift_ax
) == 0
assert model.active_microscope_name == scope
assert model.active_microscope.zoom.zoomvalue == zoom
def test_get_feature_list(model):
feature_lists = model.feature_list
assert model.get_feature_list(0) == ""
assert model.get_feature_list(len(feature_lists) + 1) == ""
from navigate.model.features.feature_related_functions import (
convert_feature_list_to_str,
)
for i in range(len(feature_lists)):
feature_str = model.get_feature_list(i + 1)
if "shared_list" not in feature_str:
assert feature_str == convert_feature_list_to_str(feature_lists[i])
# assert convert_str_to_feature_list(feature_str) == feature_lists[i]
def test_load_feature_list_from_str(model):
feature_lists = model.feature_list
l = len(feature_lists) # noqa
model.load_feature_list_from_str('[{"name": PrepareNextChannel}]')
assert len(feature_lists) == l + 1
from navigate.model.features.feature_related_functions import (
convert_feature_list_to_str,
)
assert (
convert_feature_list_to_str(feature_lists[-1])
== '[{"name": PrepareNextChannel,},]'
)
del feature_lists[-1]
feature_str = '[{"name": LoopByCount,"args": ([1, 2.0, True, False, \'abc\'],),},]'
model.load_feature_list_from_str(feature_str)
assert len(feature_lists) == l + 1
assert convert_feature_list_to_str(feature_lists[-1]) == feature_str
del feature_lists[-1]
def test_load_feature_records(model):
feature_lists = model.feature_list
l = len(feature_lists) # noqa
from navigate.config.config import get_navigate_path
from navigate.tools.file_functions import save_yaml_file, load_yaml_file
from navigate.model.features.feature_related_functions import (
convert_feature_list_to_str,
)
feature_lists_path = get_navigate_path() + "/feature_lists"
if not os.path.exists(feature_lists_path):
os.makedirs(feature_lists_path)
feature_records = load_yaml_file(f"{feature_lists_path}/__sequence.yml")
if not feature_records:
feature_records = []
save_yaml_file(
feature_lists_path,
{
"module_name": None,
"feature_list_name": "Test Feature List 5",
"feature_list": "[({'name': PrepareNextChannel}, "
"{'name': LoopByCount, 'args': (3,),})]",
},
"__test_1.yml",
)
model.load_feature_records()
assert len(feature_lists) == l + len(feature_records) + 1
assert (
convert_feature_list_to_str(feature_lists[-1])
== '[({"name": PrepareNextChannel,},{"name": LoopByCount,"args": (3,),},),]'
)
del feature_lists[-1]
os.remove(f"{feature_lists_path}/__test_1.yml")
model.load_feature_records()
assert len(feature_lists) == l + len(feature_records) * 2
feature_records_2 = load_yaml_file(f"{feature_lists_path}/__sequence.yml")
assert feature_records == feature_records_2
os.remove(f"{feature_lists_path}/__sequence.yml")

Some files were not shown because too many files have changed in this diff Show More