feat: init
This commit is contained in:
0
test/model/__init__.py
Normal file
0
test/model/__init__.py
Normal file
0
test/model/analysis/__init__.py
Normal file
0
test/model/analysis/__init__.py
Normal file
112
test/model/analysis/test_boundary_detect.py
Normal file
112
test/model/analysis/test_boundary_detect.py
Normal 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)]
|
||||
56
test/model/analysis/test_camera.py
Normal file
56
test/model/analysis/test_camera.py
Normal 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)
|
||||
0
test/model/concurrency/__init__.py
Normal file
0
test/model/concurrency/__init__.py
Normal file
758
test/model/concurrency/test_concurrency_tools.py
Normal file
758
test/model/concurrency/test_concurrency_tools.py
Normal 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
|
||||
269
test/model/data_sources/test_bdv_data_source.py
Normal file
269
test/model/data_sources/test_bdv_data_source.py
Normal 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
|
||||
95
test/model/data_sources/test_data_source.py
Normal file
95
test/model/data_sources/test_data_source.py
Normal 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
|
||||
108
test/model/data_sources/test_tiff_data_source.py
Normal file
108
test/model/data_sources/test_tiff_data_source.py
Normal 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")
|
||||
152
test/model/data_sources/test_zarr_data_source.py
Normal file
152
test/model/data_sources/test_zarr_data_source.py
Normal 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
|
||||
0
test/model/devices/APIs/__init__.py
Normal file
0
test/model/devices/APIs/__init__.py
Normal file
0
test/model/devices/APIs/coherent/__init__.py
Normal file
0
test/model/devices/APIs/coherent/__init__.py
Normal file
0
test/model/devices/APIs/dynamixel/__init__.py
Normal file
0
test/model/devices/APIs/dynamixel/__init__.py
Normal file
0
test/model/devices/APIs/hamamatsu/__init__.py
Normal file
0
test/model/devices/APIs/hamamatsu/__init__.py
Normal file
175
test/model/devices/APIs/hamamatsu/test_hamamatsu_api.py
Normal file
175
test/model/devices/APIs/hamamatsu/test_hamamatsu_api.py
Normal 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()
|
||||
0
test/model/devices/APIs/logitech/__init__.py
Normal file
0
test/model/devices/APIs/logitech/__init__.py
Normal file
0
test/model/devices/APIs/omicron/__init__.py
Normal file
0
test/model/devices/APIs/omicron/__init__.py
Normal file
0
test/model/devices/APIs/optotune/__init__.py
Normal file
0
test/model/devices/APIs/optotune/__init__.py
Normal file
0
test/model/devices/APIs/pi/__init__.py
Normal file
0
test/model/devices/APIs/pi/__init__.py
Normal file
0
test/model/devices/APIs/sutter/__init__.py
Normal file
0
test/model/devices/APIs/sutter/__init__.py
Normal file
0
test/model/devices/APIs/thorlabs/__init__.py
Normal file
0
test/model/devices/APIs/thorlabs/__init__.py
Normal file
0
test/model/devices/__init__.py
Normal file
0
test/model/devices/__init__.py
Normal file
0
test/model/devices/camera/__init__.py
Normal file
0
test/model/devices/camera/__init__.py
Normal file
80
test/model/devices/camera/test_camera_base.py
Normal file
80
test/model/devices/camera/test_camera_base.py
Normal 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)()
|
||||
193
test/model/devices/camera/test_camera_synthetic.py
Normal file
193
test/model/devices/camera/test_camera_synthetic.py
Normal 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
|
||||
618
test/model/devices/camera/test_daheng.py
Normal file
618
test/model/devices/camera/test_daheng.py
Normal 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()
|
||||
379
test/model/devices/camera/test_hamamatsu.py
Normal file
379
test/model/devices/camera/test_hamamatsu.py
Normal 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
|
||||
34
test/model/devices/daq/test_daq_base.py
Normal file
34
test/model/devices/daq/test_daq_base.py
Normal 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
|
||||
78
test/model/devices/daq/test_daq_ni.py
Normal file
78
test/model/devices/daq/test_daq_ni.py
Normal 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)()
|
||||
40
test/model/devices/daq/test_daq_synthetic.py
Normal file
40
test/model/devices/daq/test_daq_synthetic.py
Normal 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)()
|
||||
141
test/model/devices/filter_wheel/test_asi.py
Normal file
141
test/model/devices/filter_wheel/test_asi.py
Normal 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()
|
||||
28
test/model/devices/filter_wheel/test_fw_base.py
Normal file
28
test/model/devices/filter_wheel/test_fw_base.py
Normal 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
|
||||
55
test/model/devices/filter_wheel/test_fw_synthetic.py
Normal file
55
test/model/devices/filter_wheel/test_fw_synthetic.py
Normal 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)()
|
||||
189
test/model/devices/filter_wheel/test_sutter.py
Normal file
189
test/model/devices/filter_wheel/test_sutter.py
Normal 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()
|
||||
145
test/model/devices/galvo/test_galvo_base.py
Normal file
145
test/model/devices/galvo/test_galvo_base.py
Normal 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)
|
||||
133
test/model/devices/galvo/test_galvo_ni.py
Normal file
133
test/model/devices/galvo/test_galvo_ni.py
Normal 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,
|
||||
},
|
||||
)
|
||||
86
test/model/devices/galvo/test_galvo_synthetic.py
Normal file
86
test/model/devices/galvo/test_galvo_synthetic.py
Normal 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__()
|
||||
21
test/model/devices/lasers/test_laser_base.py
Normal file
21
test/model/devices/lasers/test_laser_base.py
Normal 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)()
|
||||
97
test/model/devices/lasers/test_laser_ni.py
Normal file
97
test/model/devices/lasers/test_laser_ni.py
Normal 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
|
||||
321
test/model/devices/pump/test_tecan.py
Normal file
321
test/model/devices/pump/test_tecan.py
Normal 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.
|
||||
106
test/model/devices/remote_focus/test_rf_base.py
Normal file
106
test/model/devices/remote_focus/test_rf_base.py
Normal 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
|
||||
29
test/model/devices/remote_focus/test_rf_ni.py
Normal file
29
test/model/devices/remote_focus/test_rf_ni.py
Normal 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)()
|
||||
20
test/model/devices/remote_focus/test_rf_synthetic.py
Normal file
20
test/model/devices/remote_focus/test_rf_synthetic.py
Normal 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)()
|
||||
59
test/model/devices/shutter/test_shutter_base.py
Normal file
59
test/model/devices/shutter/test_shutter_base.py
Normal 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()
|
||||
56
test/model/devices/shutter/test_shutter_ni.py
Normal file
56
test/model/devices/shutter/test_shutter_ni.py
Normal 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()
|
||||
73
test/model/devices/shutter/test_shutter_synthetic.py
Normal file
73
test/model/devices/shutter/test_shutter_synthetic.py
Normal 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()
|
||||
0
test/model/devices/stages/__init__.py
Normal file
0
test/model/devices/stages/__init__.py
Normal file
160
test/model/devices/stages/conftest.py
Normal file
160
test/model/devices/stages/conftest.py
Normal 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
|
||||
337
test/model/devices/stages/test_asi.py
Normal file
337
test/model/devices/stages/test_asi.py
Normal 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)
|
||||
244
test/model/devices/stages/test_mcl.py
Normal file
244
test/model/devices/stages/test_mcl.py
Normal 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)
|
||||
259
test/model/devices/stages/test_pi.py
Normal file
259
test/model/devices/stages/test_pi.py
Normal 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)
|
||||
231
test/model/devices/stages/test_stage_base.py
Normal file
231
test/model/devices/stages/test_stage_base.py
Normal 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
|
||||
158
test/model/devices/stages/test_stage_ni.py
Normal file
158
test/model/devices/stages/test_stage_ni.py
Normal 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)
|
||||
298
test/model/devices/stages/test_sutter.py
Normal file
298
test/model/devices/stages/test_sutter.py
Normal 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)
|
||||
253
test/model/devices/stages/test_tl_kcube_inertial.py
Normal file
253
test/model/devices/stages/test_tl_kcube_inertial.py
Normal 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)
|
||||
205
test/model/devices/stages/test_tl_kcube_steppermotor.py
Normal file
205
test/model/devices/stages/test_tl_kcube_steppermotor.py
Normal 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}")
|
||||
80
test/model/devices/test_synthetic_hardware.py
Normal file
80
test/model/devices/test_synthetic_hardware.py
Normal 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)
|
||||
85
test/model/devices/zoom/test_base.py
Normal file
85
test/model/devices/zoom/test_base.py
Normal 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
|
||||
60
test/model/devices/zoom/test_dynamixel.py
Normal file
60
test/model/devices/zoom/test_dynamixel.py
Normal 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()
|
||||
67
test/model/devices/zoom/test_synthetic.py
Normal file
67
test/model/devices/zoom/test_synthetic.py
Normal 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
647
test/model/dummy.py
Normal 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
|
||||
176
test/model/features/conftest.py
Normal file
176
test/model/features/conftest.py
Normal 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
|
||||
970
test/model/features/test_aslm_feature_container.py
Normal file
970
test/model/features/test_aslm_feature_container.py
Normal 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()
|
||||
134
test/model/features/test_autofocus.py
Normal file
134
test/model/features/test_autofocus.py
Normal 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()
|
||||
476
test/model/features/test_common_features.py
Normal file
476
test/model/features/test_common_features.py
Normal 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
|
||||
133
test/model/features/test_feature_related_functions.py
Normal file
133
test/model/features/test_feature_related_functions.py
Normal 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
|
||||
48
test/model/features/test_image_writer.py
Normal file
48
test/model/features/test_image_writer.py
Normal 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")
|
||||
222
test/model/features/test_restful_features.py
Normal file
222
test/model/features/test_restful_features.py
Normal 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()
|
||||
239
test/model/features/test_volume_search.py
Normal file
239
test/model/features/test_volume_search.py
Normal 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()
|
||||
185
test/model/metadata_sources/test_bdv_metadata.py
Normal file
185
test/model/metadata_sources/test_bdv_metadata.py
Normal 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
|
||||
154
test/model/metadata_sources/test_metadata.py
Normal file
154
test/model/metadata_sources/test_metadata.py
Normal 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
|
||||
|
||||
|
||||
46
test/model/metadata_sources/test_ome_tiff_metadata.py
Normal file
46
test/model/metadata_sources/test_ome_tiff_metadata.py
Normal 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
|
||||
189
test/model/metadata_sources/test_zarr_metadata.py
Normal file
189
test/model/metadata_sources/test_zarr_metadata.py
Normal 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.
|
||||
145
test/model/test_aslm_analysis.py
Normal file
145
test/model/test_aslm_analysis.py
Normal 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()
|
||||
84
test/model/test_device_startup_functions.py
Normal file
84
test/model/test_device_startup_functions.py
Normal 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")
|
||||
242
test/model/test_microscope.py
Normal file
242
test/model/test_microscope.py
Normal 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
443
test/model/test_model.py
Normal 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")
|
||||
59
test/model/test_plugins_model.py
Normal file
59
test/model/test_plugins_model.py
Normal 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)
|
||||
234
test/model/test_waveforms.py
Normal file
234
test/model/test_waveforms.py
Normal 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))
|
||||
Reference in New Issue
Block a user