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/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")

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 unittest.mock import patch
from navigate.model.plugins_model import PluginsModel
class TestPluginsModel(unittest.TestCase):
# comment this testcase since plugin_model doesn't have plugins_path now
# @patch("os.path.join")
# @patch("pathlib.Path.resolve")
# def test_initialization(self, mock_resolve, mock_join):
# mock_resolve.return_value.parent.parent = "mocked_path"
# mock_join.return_value = "mocked_path/plugins"
# model = PluginsModel()
# self.assertEqual(model.plugins_path, "mocked_path/plugins")
@patch("navigate.config.config.get_navigate_path")
@patch("os.makedirs")
def test_load_plugins(
self,
mock_get_nav_path,
mock_makedirs,
):
mock_get_nav_path.return_value = "mocked_navigate_path"
model = PluginsModel()
devices_dict, plugin_acquisition_modes = model.load_plugins()
self.assertIsInstance(devices_dict, dict)
self.assertIsInstance(plugin_acquisition_modes, dict)

View File

@@ -0,0 +1,234 @@
# 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
from navigate.model import waveforms
class TestWaveforms(unittest.TestCase):
"""
Unit Tests for the Navigate Model Waveforms
"""
def find_first_index_above_threshold(self, data, threshold):
"""
Finds the first index in the data array above the threshold
"""
first_index = next(x for x, val in enumerate(data) if val > threshold)
return first_index
def test_single_pulse_max_default_amplitude(self):
sample_rate = 100000
sweep_time = 0.4
data = waveforms.single_pulse(sample_rate=sample_rate, sweep_time=sweep_time)
self.assertEqual(np.max(data), 1)
def test_single_pulse_max_specified_amplitude(self):
sample_rate = 100000
sweep_time = 0.4
amplitude = 2
data = waveforms.single_pulse(
sample_rate=sample_rate, sweep_time=sweep_time, amplitude=amplitude
)
self.assertEqual(np.max(data), amplitude)
def test_single_pulse_min_default_amplitude(self):
sample_rate = 100000
sweep_time = 0.4
data = waveforms.single_pulse(sample_rate=sample_rate, sweep_time=sweep_time)
self.assertEqual(np.min(data), 0)
def test_single_pulse_onset_default_delay(self):
sample_rate = 100000
sweep_time = 0.4
default_delay = 10
data = waveforms.single_pulse(sample_rate=sample_rate, sweep_time=sweep_time)
first_index = self.find_first_index_above_threshold(data=data, threshold=0)
self.assertEqual(
int(sample_rate * sweep_time * default_delay / 100), first_index
)
def test_single_pulse_onset_specified_delay(self):
sample_rate = 100000
sweep_time = 0.4
delay = 20
data = waveforms.single_pulse(
sample_rate=sample_rate, sweep_time=sweep_time, delay=delay
)
first_index = self.find_first_index_above_threshold(data, 0.5)
# first_index = next(x for x, val in enumerate(data)
# if val > 0.5)
self.assertEqual(int(sample_rate * sweep_time * delay / 100), first_index)
def test_single_pulse_default_offset(self):
sample_rate = 100000
sweep_time = 0.4
default_offset = 0
data = waveforms.single_pulse(sample_rate=sample_rate, sweep_time=sweep_time)
self.assertEqual(np.min(data), default_offset)
def test_single_pulse_specified_offset(self):
sample_rate = 100000
sweep_time = 0.4
offset = 0.2
data = waveforms.single_pulse(
sample_rate=sample_rate, sweep_time=sweep_time, offset=offset
)
self.assertEqual(np.min(data), offset)
@pytest.mark.skip(
reason="double the correct value, have not bothered to figure out why"
)
def test_remote_focus_ramp_specified_delay(self):
sample_rate = 100000
sweep_time = 0.4
delay = 10.5
data = waveforms.remote_focus_ramp(
sample_rate=sample_rate, sweep_time=sweep_time, remote_focus_delay=delay
)
first_index = self.find_first_index_above_threshold(data=data, threshold=-1)
self.assertEqual(delay * sample_rate * sweep_time / 100, first_index - 1)
def test_remote_focus_ramp_amplitude_max(self):
amplitude = 1.5
data = waveforms.remote_focus_ramp(amplitude=amplitude)
self.assertEqual(np.max(data), amplitude)
def test_remote_focus_ramp_amplitude_min(self):
amplitude = 1.5
data = waveforms.remote_focus_ramp(amplitude=amplitude)
self.assertEqual(np.min(data), -1 * amplitude)
def test_remote_focus_offset_min(self):
default_amplitude = 1
offset = 0.5
data = waveforms.remote_focus_ramp(offset=offset)
self.assertEqual(np.min(data), -1 * default_amplitude + offset)
def test_remote_focus_offset_max(self):
default_amplitude = 1
offset = 0.5
data = waveforms.remote_focus_ramp(offset=offset)
self.assertEqual(np.max(data), default_amplitude + offset)
def test_dc_value(self):
sample_rate = 100000
sweep_time = 0.4
for amplitude in np.linspace(-5, 5, 3):
data = waveforms.dc_value(
sample_rate=sample_rate, sweep_time=sweep_time, amplitude=amplitude
)
self.assertEqual(np.max(data), amplitude)
self.assertEqual(np.size(data), sample_rate * sweep_time)
def test_sawtooth_amplitude(self):
sample_rate = 100000
sweep_time = 0.4
for amplitude in np.linspace(-5, 5, 3):
data = waveforms.sawtooth(
sample_rate=sample_rate, sweep_time=sweep_time, amplitude=amplitude
)
self.assertAlmostEqual(
np.max(data), np.abs(amplitude), delta=np.abs(amplitude) / 100
)
def test_square_amplitude(self):
sample_rate = 100000
sweep_time = 0.4
for amplitude in np.linspace(-5, 5, 3):
data = waveforms.square(
sample_rate=sample_rate, sweep_time=sweep_time, amplitude=amplitude
)
self.assertEqual(np.max(data), np.abs(amplitude))
self.assertEqual(np.min(data), -1 * np.abs(amplitude))
def test_square_offset(self):
sample_rate = 100000
sweep_time = 0.4
for amplitude in np.linspace(0, 5, 5):
for offset in np.linspace(0, 3, 3):
data = waveforms.square(
sample_rate=sample_rate,
offset=offset,
amplitude=amplitude,
sweep_time=sweep_time,
)
self.assertEqual(np.max(data), amplitude + offset)
self.assertEqual(np.min(data), -1 * amplitude + offset)
def test_smoothing_length(self):
"""Test that the smoothed waveform is proportionally larger than the
original waveform.
"""
ps = 10
waveform = waveforms.remote_focus_ramp()
smoothed_waveform = waveforms.smooth_waveform(waveform, ps)
np.testing.assert_almost_equal(
len(smoothed_waveform), len(waveform) * (1 + ps / 100) + 1
)
def test_smoothing_bounds(self):
ps = 10
waveform = waveforms.remote_focus_ramp()
smoothed_waveform = waveforms.smooth_waveform(waveform, ps)
assert waveform[0] == smoothed_waveform[0]
assert waveform[-1] == smoothed_waveform[-1]
assert np.max(waveform) >= np.max(smoothed_waveform)
def test_smoothing_zero_pct(self):
waveform_smoothing_pct = 0
waveform = waveforms.remote_focus_ramp(sample_rate=16)
smoothed_waveform = waveforms.smooth_waveform(waveform, waveform_smoothing_pct)
assert len(waveform) * waveform_smoothing_pct / 100 < 1
np.testing.assert_array_equal(waveform, smoothed_waveform)
def test_camera_exposure(self):
sr, st, ex, cd = 100000, 0.5, 0.4, 0.1
v = waveforms.camera_exposure(
sample_rate=sr, sweep_time=st, exposure=ex, camera_delay=cd
)
assert np.sum(v > 0) == int(sr * ex)
def test_camera_exposure_short(self):
"""In the event the camera delay + exposure_time > sweep time..."""
sr, st, ex, cd = 100000, 0.4, 0.4, 0.1
v = waveforms.camera_exposure(
sample_rate=sr, sweep_time=st, exposure=ex, camera_delay=cd
)
assert np.sum(v > 0) == int(sr * (ex - cd))