Move unit tests to their own sub-dir in tests
This commit is contained in:
parent
62b414af3c
commit
d08d4cd325
35 changed files with 6 additions and 7 deletions
6
tests/unit_tests/__init__.py
Normal file
6
tests/unit_tests/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""The unit-test suite for the OpenFlexure Microscope Server.
|
||||
|
||||
This package contains all of the unit tests that can be run without specific
|
||||
hardware. See also the `hardware-specific-tests` directory and the
|
||||
`integration-tests` directory for more testing!.
|
||||
"""
|
||||
22
tests/unit_tests/mock_stitching/mock-stitch.py
Normal file
22
tests/unit_tests/mock_stitching/mock-stitch.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""CLI script used for testing subprocess calls that should go to openflexure-stitch."""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def main():
|
||||
"""Echo command-line arguments with a short delay between each."""
|
||||
input_arguments = sys.argv[1:]
|
||||
for arg in input_arguments:
|
||||
# This is used to check we catch errors correctly.
|
||||
if arg == "ERROR":
|
||||
raise RuntimeError("I was told to do this.")
|
||||
if arg == "HANG":
|
||||
# Rather than hang, just sleep for 10s:
|
||||
time.sleep(10)
|
||||
print(arg, flush=True)
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
tests/unit_tests/test_autofocus.py
Normal file
98
tests/unit_tests/test_autofocus.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Tests for the autofoucs logic.
|
||||
|
||||
This doesn't check the behaviour of the JPEG shaprness monitor.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from labthings_fastapi.testing import create_thing_without_server
|
||||
|
||||
from openflexure_microscope_server.things.autofocus import (
|
||||
AutofocusThing,
|
||||
NoFocusFoundError,
|
||||
)
|
||||
|
||||
|
||||
def fake_sharpness_data(
|
||||
dz: int, start_z: int, max_loc: int, length: int = 41
|
||||
) -> tuple[list[float], np.ndarray, np.ndarray]:
|
||||
"""Create some fake data for the shapeness.
|
||||
|
||||
The highest returned sharpness is closest to max_loc
|
||||
"""
|
||||
# Some fake timestamps
|
||||
times = [i / 10 + 100000 for i in range(length)]
|
||||
img_dz = dz / (length - 1)
|
||||
heights = [round(start_z + i * img_dz) for i in range(length)]
|
||||
# Sharpnesses fall off linearly in this model.
|
||||
sharpnesses = [10 * dz - abs(max_loc - h) for h in heights]
|
||||
return times, np.array(heights), np.array(sharpnesses)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("start_z", "max_loc", "centre", "attempts_expected", "passes"),
|
||||
[
|
||||
# To complete, the max must be in the central 1200, so -600 to 600 when looping
|
||||
# from -1000 to 1000
|
||||
(0, 550, True, 1, True), # Found in loop1 from -1000 to 1000
|
||||
(0, 650, True, 2, True), # Just outside the limit in loop1
|
||||
(0, 1300, True, 2, True), # Found in loop2 from 0 to 2000
|
||||
(0, 1300, False, 1, True), # Found in loop1 from 0 to 2000 (as start="base")
|
||||
(0, -1300, True, 2, True), # Found in loop2 from 0 to 2000
|
||||
(0, 7300, True, 8, True), # Found in loop8 from 6000 to 8000
|
||||
(0, 9300, True, 10, True), # Found in loop10 from 8000 to 10000
|
||||
(0, 9900, True, 10, False), # Still not central in loop 10, doesn't pass
|
||||
],
|
||||
)
|
||||
def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes, mocker):
|
||||
"""Test the high level looping autofocus algorithm."""
|
||||
dz = 2000
|
||||
autofocus_thing = create_thing_without_server(AutofocusThing, mock_all_slots=True)
|
||||
|
||||
# Make a mock stage where move_absolute abs and relative updates the position counter.
|
||||
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
|
||||
|
||||
def set_pos(**kwargs: int) -> None:
|
||||
"""Move absolute should update position. So make a side effect for the mock."""
|
||||
for axis, value in kwargs.items():
|
||||
autofocus_thing._stage.position[axis] = value
|
||||
|
||||
def adjust_pos(**kwargs: int) -> None:
|
||||
"""Move relative should update position. So make a side effect for the mock."""
|
||||
for axis, value in kwargs.items():
|
||||
autofocus_thing._stage.position[axis] += value
|
||||
|
||||
autofocus_thing._stage.move_absolute.side_effect = set_pos
|
||||
autofocus_thing._stage.move_relative.side_effect = adjust_pos
|
||||
|
||||
# Make a mock sharpness monitor that can generate sharpness data.
|
||||
sharpness_monitor = mocker.MagicMock()
|
||||
sharpness_monitor.focus_rel.return_value = (0, 0)
|
||||
|
||||
def return_sharpness(*_args) -> tuple[list[float], np.ndarray, np.ndarray]:
|
||||
"""Generate sharpnesses based on parameterised input, and mock stage position."""
|
||||
return fake_sharpness_data(
|
||||
dz=dz,
|
||||
start_z=autofocus_thing._stage.position["z"],
|
||||
max_loc=max_loc,
|
||||
)
|
||||
|
||||
sharpness_monitor.move_data.side_effect = return_sharpness
|
||||
|
||||
# Mock the context manager call so out MagicMock sharpness monitor is returned.
|
||||
mock_context = mocker.patch(
|
||||
"openflexure_microscope_server.things.autofocus.JPEGSharpnessMonitor"
|
||||
)
|
||||
mock_context.return_value.__enter__.return_value = sharpness_monitor
|
||||
mock_context.return_value.__exit__.return_value = None
|
||||
|
||||
if passes:
|
||||
autofocus_thing.looping_autofocus(dz=dz, start="centre" if centre else "base")
|
||||
else:
|
||||
with pytest.raises(NoFocusFoundError):
|
||||
autofocus_thing.looping_autofocus(
|
||||
dz=dz, start="centre" if centre else "base"
|
||||
)
|
||||
assert sharpness_monitor.focus_rel.call_count == attempts_expected
|
||||
assert abs(max_loc - autofocus_thing._stage.position["z"]) < dz / 40
|
||||
323
tests/unit_tests/test_background_detectors.py
Normal file
323
tests/unit_tests/test_background_detectors.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"""Test the background detection algorithms.
|
||||
|
||||
This tests both the base class an individual algorithms.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openflexure_microscope_server.background_detect import (
|
||||
BackgroundDetectAlgorithm,
|
||||
ChannelBlankError,
|
||||
ChannelDeviationLUV,
|
||||
ChannelDistributions,
|
||||
ColourChannelDetectLUV,
|
||||
ColourChannelDetectSettings,
|
||||
MissingBackgroundDataError,
|
||||
_chunked_stds,
|
||||
)
|
||||
|
||||
RNG = np.random.default_rng()
|
||||
IMG_SHAPE = (820, 616, 3)
|
||||
BG_COLOR = [220, 215, 217]
|
||||
|
||||
PERC_REGEX = re.compile(r"(\d+\.+\d)+%")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def background_image():
|
||||
"""Generate a numpy array that simulates a background image."""
|
||||
image = np.ones(IMG_SHAPE, dtype=np.int16)
|
||||
image[:, :, 0] *= BG_COLOR[0]
|
||||
image[:, :, 1] *= BG_COLOR[1]
|
||||
image[:, :, 2] *= BG_COLOR[2]
|
||||
image += RNG.normal(scale=3, size=IMG_SHAPE).astype("int16")
|
||||
image[image < 0] = 0
|
||||
image[image > 255] = 255
|
||||
return image.astype("uint8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image(background_image):
|
||||
"""Generate a numpy array of a simulated image where 50% is a block of red."""
|
||||
image = background_image.copy()
|
||||
x_cent = IMG_SHAPE[0] // 2
|
||||
image[:x_cent, :, 0] -= 180
|
||||
return image
|
||||
|
||||
|
||||
def test_bg_detect_base_class():
|
||||
"""Test the base class for background detect.
|
||||
|
||||
If initialised as is it should raise not implemented error.
|
||||
"""
|
||||
with pytest.raises(NotImplementedError):
|
||||
BackgroundDetectAlgorithm()
|
||||
|
||||
|
||||
def test_partial_base_class(background_image):
|
||||
"""Create a partial class to initialise the base class and test other methods.
|
||||
|
||||
This test is to check that if the necessary methods are not set, that an
|
||||
appropriate error is raised.
|
||||
"""
|
||||
|
||||
class BadAlgo1(BackgroundDetectAlgorithm):
|
||||
"""Only has a settings model so it can initialise."""
|
||||
|
||||
settings_data_model: BaseModel = ColourChannelDetectSettings
|
||||
|
||||
bad_algo1 = BadAlgo1()
|
||||
status = bad_algo1.status
|
||||
assert not status.ready
|
||||
# Check the settings dictionary can be validated as ``ColourChannelDetectSettings``
|
||||
ColourChannelDetectSettings(**status.settings)
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
# Should error on any dictionary input. This simulates loading settings from
|
||||
# disk
|
||||
bad_algo1.background_data = {"key": 1}
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
bad_algo1.set_background(background_image)
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
bad_algo1.image_is_sample(background_image)
|
||||
|
||||
|
||||
def test_colour_channel_luv(background_image, sample_image):
|
||||
"""Test measuring if a sample is background."""
|
||||
cc_luv = ColourChannelDetectLUV()
|
||||
|
||||
# No background data so it is not ready and will error if image_is_sample is called.
|
||||
assert not cc_luv.status.ready
|
||||
with pytest.raises(MissingBackgroundDataError):
|
||||
cc_luv.image_is_sample(background_image)
|
||||
|
||||
# Set the background
|
||||
cc_luv.set_background(background_image)
|
||||
# Now it is ready
|
||||
assert cc_luv.status.ready
|
||||
sample, message = cc_luv.image_is_sample(background_image)
|
||||
assert not sample
|
||||
assert "0.0%" in message
|
||||
sample, message = cc_luv.image_is_sample(sample_image)
|
||||
assert sample
|
||||
match = PERC_REGEX.search(message)
|
||||
assert match is not None
|
||||
# Should be 50% background. Allowing 49.8-50.2% due to noise.
|
||||
assert 49.8 < float(match.group(1)) < 50.2
|
||||
|
||||
# Require 75% coverage
|
||||
cc_luv.settings = ColourChannelDetectSettings(min_sample_coverage=75.0)
|
||||
|
||||
sample, message = cc_luv.image_is_sample(sample_image)
|
||||
# No longer detected as a sample.
|
||||
assert not sample
|
||||
match = PERC_REGEX.search(message)
|
||||
assert match is not None
|
||||
# Still 50% background.
|
||||
assert 49.8 < float(match.group(1)) < 50.2
|
||||
|
||||
|
||||
def test_colour_channel_luv_save_load(background_image, sample_image):
|
||||
"""Get settings and data as dicts, and creating new instance using these dicts.
|
||||
|
||||
This is how the camera will load/save settings from/to disk.
|
||||
"""
|
||||
cc_luv = ColourChannelDetectLUV()
|
||||
cc_luv.set_background(background_image)
|
||||
|
||||
# Check types for background data
|
||||
assert cc_luv.background_data_model is ChannelDistributions
|
||||
assert isinstance(cc_luv.background_data, cc_luv.background_data_model)
|
||||
# Change Settings
|
||||
cc_luv.settings = ColourChannelDetectSettings(min_sample_coverage=10.0)
|
||||
|
||||
setting_dict = cc_luv.settings.model_dump()
|
||||
data_dict = cc_luv.background_data.model_dump()
|
||||
|
||||
# Remove the old detector so we don't accidentally use it!
|
||||
del cc_luv
|
||||
|
||||
# Create a new instance
|
||||
cc_luv2 = ColourChannelDetectLUV()
|
||||
assert not cc_luv2.status.ready
|
||||
# Load settings and channels from dictionary as the camera will do on init.
|
||||
cc_luv2.settings = setting_dict
|
||||
cc_luv2.background_data = data_dict
|
||||
|
||||
# Now should be ready to use
|
||||
assert cc_luv2.status.ready
|
||||
assert cc_luv2.settings.min_sample_coverage == 10.0
|
||||
sample, _ = cc_luv2.image_is_sample(sample_image)
|
||||
assert sample
|
||||
|
||||
# Remove the 2nd detector so we don't accidentally use it!
|
||||
del cc_luv2
|
||||
|
||||
# One final test that None can be set explicitly to background data as this will
|
||||
# happen if loading with background detect not saved.
|
||||
cc_luv3 = ColourChannelDetectLUV()
|
||||
assert not cc_luv3.status.ready
|
||||
# Load settings and channels from dictionary as the camera will do on init.
|
||||
cc_luv3.settings = setting_dict
|
||||
cc_luv3.background_data = None
|
||||
# Still not ready
|
||||
assert not cc_luv3.status.ready
|
||||
|
||||
|
||||
def test_colour_channel_luv_load_bad_data():
|
||||
"""Check a type error is thrown on bad data input."""
|
||||
|
||||
class WrongModel(BaseModel):
|
||||
"""Using a different BaseModel as this is most likely to cause confusion."""
|
||||
|
||||
prop1: int = 8
|
||||
prop2: str = "foo"
|
||||
|
||||
cc_luv = ColourChannelDetectLUV()
|
||||
with pytest.raises(TypeError):
|
||||
cc_luv.settings = WrongModel()
|
||||
with pytest.raises(TypeError):
|
||||
cc_luv.background_data = WrongModel()
|
||||
|
||||
|
||||
def create_patchwork_image(magnitude=3, blank_channels=None):
|
||||
"""Create a patchwork image, with known stds per chunk.
|
||||
|
||||
:return: The image, and the (8,8,3) array of stds
|
||||
"""
|
||||
if blank_channels is None:
|
||||
blank_channels = []
|
||||
n_rows = 8
|
||||
n_cols = 8
|
||||
chunk_h = 10
|
||||
chunk_w = 10
|
||||
|
||||
# Precompute chunk stds and construct the final image
|
||||
expected_stds = np.zeros((n_rows, n_cols, 3), dtype=float)
|
||||
img = np.zeros((n_rows * chunk_h, n_cols * chunk_w, 3), dtype=np.uint8)
|
||||
|
||||
for i in range(n_rows):
|
||||
for j in range(n_cols):
|
||||
for channel in range(3):
|
||||
if channel in blank_channels:
|
||||
chunk = np.zeros((chunk_h, chunk_w), dtype=np.uint8)
|
||||
else:
|
||||
chunk = 9.8 * np.ones((chunk_h, chunk_w))
|
||||
chunk += RNG.normal(scale=magnitude, size=(chunk_h, chunk_w))
|
||||
chunk = chunk.astype(np.uint8)
|
||||
|
||||
# Store its std
|
||||
expected_stds[i, j, channel] = np.std(chunk)
|
||||
|
||||
# Insert chunk into the final large image
|
||||
y0, y1 = i * chunk_h, (i + 1) * chunk_h
|
||||
x0, x1 = j * chunk_w, (j + 1) * chunk_w
|
||||
img[y0:y1, x0:x1, channel] = chunk
|
||||
return img, expected_stds
|
||||
|
||||
|
||||
def test_chunked_stds_with_precomputed_chunk_stds():
|
||||
"""Test _chunked_stds returns the answer calculated when making a patchwork image."""
|
||||
img, expected_stds = create_patchwork_image()
|
||||
|
||||
# Run the function under test
|
||||
result = _chunked_stds(img, n_rows=8, n_cols=8)
|
||||
|
||||
# Compare
|
||||
np.testing.assert_allclose(result, expected_stds, rtol=1e-6, atol=1e-12)
|
||||
|
||||
|
||||
def test_channel_deviation_luv_set_background(mocker):
|
||||
"""Test set_background takes the median of each channel, and errors for blank channels."""
|
||||
cd_luv = ChannelDeviationLUV()
|
||||
|
||||
# Patch RGB to LUV so or we don't know what the STDs should be
|
||||
mocker.patch("cv2.cvtColor", side_effect=lambda img, _method: img)
|
||||
|
||||
for magnitude in [0.2, 1, 10]:
|
||||
# Create an image and set it as background
|
||||
img, expected_stds = create_patchwork_image(magnitude=magnitude)
|
||||
cd_luv.set_background(img)
|
||||
|
||||
# Do a somewhat verbose checking for clarity
|
||||
for channel in range(3):
|
||||
# Saved std
|
||||
channel_std = cd_luv.background_data.standard_deviations[channel]
|
||||
# Expected median
|
||||
channel_median = np.median(expected_stds[:, :, channel])
|
||||
# If the median is above the minimum allowed then it should be returned
|
||||
if channel_median > cd_luv.min_stds[channel]:
|
||||
assert np.isclose(channel_std, channel_median, rtol=1e-6, atol=1e-12)
|
||||
else:
|
||||
# If not the minimum is returned.
|
||||
assert channel_std == cd_luv.min_stds[channel]
|
||||
|
||||
# Also check that if channels are blank then an error is thrown
|
||||
img, _expected_stds = create_patchwork_image(magnitude=1, blank_channels=[1, 2])
|
||||
with pytest.raises(ChannelBlankError):
|
||||
cd_luv.set_background(img)
|
||||
|
||||
|
||||
def test_channel_deviation_luv_image_is_sample(background_image, mocker):
|
||||
"""Check image_is_sample reports the result from get_sample_coverage."""
|
||||
cd_luv = ChannelDeviationLUV()
|
||||
|
||||
# No background data so it is not ready and will error if image_is_sample is called.
|
||||
assert not cd_luv.status.ready
|
||||
with pytest.raises(MissingBackgroundDataError):
|
||||
cd_luv.image_is_sample(background_image)
|
||||
|
||||
cd_luv.settings.min_sample_coverage = 20
|
||||
cd_luv.get_sample_coverage = mocker.Mock(return_value=10)
|
||||
is_sample, message = cd_luv.image_is_sample(background_image)
|
||||
assert not is_sample
|
||||
assert message == r"only 10.0% sample"
|
||||
|
||||
# Reduce the min coverage
|
||||
cd_luv.settings.min_sample_coverage = 9
|
||||
|
||||
is_sample, message = cd_luv.image_is_sample(background_image)
|
||||
assert is_sample
|
||||
assert message == r"10.0% sample"
|
||||
|
||||
|
||||
def test_channel_deviation_luv_get_sample_coverage(background_image, mocker):
|
||||
"""Check _get_sample_coverage returns the values expected."""
|
||||
cd_luv = ChannelDeviationLUV()
|
||||
|
||||
# Create fake chunked STD data where each channel is the numbers 0 -> 31.5 in 0.5
|
||||
# steps
|
||||
grid = np.arange(0, 32, 0.5).reshape(8, 8)
|
||||
fake_stds = np.stack([grid, grid, grid], axis=-1)
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.background_detect._chunked_stds",
|
||||
return_value=fake_stds,
|
||||
)
|
||||
|
||||
# Create fake background
|
||||
cd_luv.background_data = ChannelDistributions(
|
||||
means=[0, 0, 0], standard_deviations=[1.1, 1.1, 1.1]
|
||||
)
|
||||
# Get sample coverage with channel tolerance of 7. Checking each channel for the
|
||||
# numbers below 7.7. There are 16 out of 64. So 75% should be sample
|
||||
cd_luv.settings.channel_tolerance = 7
|
||||
assert cd_luv.get_sample_coverage(background_image) == 75
|
||||
# This is unchanged if two channels have larger background values.
|
||||
cd_luv.background_data = ChannelDistributions(
|
||||
means=[0, 0, 0], standard_deviations=[1.6, 1.6, 1.1]
|
||||
)
|
||||
assert cd_luv.get_sample_coverage(background_image) == 75
|
||||
# But coverage increases if any channels has a lower background value.
|
||||
cd_luv.background_data = ChannelDistributions(
|
||||
means=[0, 0, 0], standard_deviations=[1.6, 0.6, 1.1]
|
||||
)
|
||||
assert cd_luv.get_sample_coverage(background_image) == 85.9375
|
||||
# Returns to 75% if that channel is empty
|
||||
fake_stds[:, :, 1] = 0
|
||||
assert cd_luv.get_sample_coverage(background_image) == 75
|
||||
69
tests/unit_tests/test_camera.py
Normal file
69
tests/unit_tests/test_camera.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Use the Simulation camera to test base camera functionality."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
||||
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
||||
|
||||
from .utilities.lt_test_utils import LabThingsTestEnv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_env() -> lt.ThingClient:
|
||||
"""Yield a test environment with the Simulated Camera and Dummy Stage."""
|
||||
thing_conf = {"camera": SimulatedCamera, "stage": DummyStage}
|
||||
with LabThingsTestEnv(things=thing_conf) as env:
|
||||
yield env
|
||||
|
||||
|
||||
def test_handle_broken_frame(test_env):
|
||||
"""Monkey patch the the mjpeg steam so 1 in 5 frames are broken, then test operation.
|
||||
|
||||
This simulates the very occasional broken frames that can occur when grabbing
|
||||
directly from the MJPEG stream.
|
||||
"""
|
||||
camera = test_env.get_thing_by_type(SimulatedCamera)
|
||||
|
||||
# Money patch the mjpeg_stream grab_frame to break 1 in 5 frames.
|
||||
frame_number = 0
|
||||
original_grabber = camera.mjpeg_stream.grab_frame
|
||||
|
||||
async def flaky_grabber():
|
||||
"""Break 1 in 5 frames."""
|
||||
# Use a non-local variable to know the frame count.
|
||||
nonlocal frame_number
|
||||
frame = await original_grabber()
|
||||
if frame_number % 5 == 2:
|
||||
# Make a weird broken frame
|
||||
frame = frame[:2000] + frame[:2000]
|
||||
frame_number += 1
|
||||
return frame
|
||||
|
||||
camera.mjpeg_stream.grab_frame = flaky_grabber
|
||||
|
||||
# Check that this does cause broken frames.
|
||||
# The noqa is because we don't know exactly when the error is thrown so we
|
||||
# can't have a single simple statement in the pytest raises.
|
||||
with pytest.raises(OSError, match="broken data stream when reading image file"): # noqa PT012
|
||||
for _i in range(15):
|
||||
jpeg = camera.grab_jpeg()
|
||||
np.asarray(Image.open(jpeg.open()))
|
||||
|
||||
# Check that grab_as_array handles the broken frames and completes without
|
||||
# the same error.
|
||||
for _i in range(15):
|
||||
array = camera.grab_as_array()
|
||||
assert isinstance(array, np.ndarray)
|
||||
|
||||
|
||||
def test_simulation_cam_calibration(test_env):
|
||||
"""Test that the simulated camera can be calibrated and reports calibration correctly."""
|
||||
camera = test_env.get_thing_by_type(SimulatedCamera)
|
||||
assert camera.calibration_required
|
||||
camera.full_auto_calibrate()
|
||||
assert not camera.calibration_required
|
||||
assert camera.background_detector_status.ready
|
||||
207
tests/unit_tests/test_camera_buffer.py
Normal file
207
tests/unit_tests/test_camera_buffer.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""Tests for the built in buffer for the camera."""
|
||||
|
||||
from random import randint
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from openflexure_microscope_server.things.camera import (
|
||||
CameraMemoryBuffer,
|
||||
NoImageInMemoryError,
|
||||
)
|
||||
|
||||
RANDOM_GENERATOR = np.random.default_rng()
|
||||
|
||||
|
||||
def random_image():
|
||||
"""Create a random image."""
|
||||
imarray = RANDOM_GENERATOR.integers(
|
||||
low=0, high=255, size=(100, 100, 3), dtype="uint8"
|
||||
)
|
||||
return Image.fromarray(imarray)
|
||||
|
||||
|
||||
def random_metadata():
|
||||
"""Create a misc dictionary to pretend to be metadata."""
|
||||
# Not very metadata like, but we are just checking that the same dict it
|
||||
# is returned
|
||||
return {"a": randint(1, 100), "b": randint(1, 100)}
|
||||
|
||||
|
||||
def test_add_and_get_image():
|
||||
"""Check images can be captured and retrieved."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
misc_image = random_image()
|
||||
buffer_id = mem_buf.add_image(misc_image)
|
||||
returned_image, _ = mem_buf.get_image(buffer_id)
|
||||
# It is the same image
|
||||
assert misc_image is returned_image
|
||||
# It is now removed from memory
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image(buffer_id)
|
||||
|
||||
|
||||
def test_add_and_get_image_twice():
|
||||
"""Check images can be retrieved twice if remove flag set false."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
misc_image = random_image()
|
||||
buffer_id = mem_buf.add_image(misc_image)
|
||||
returned_image, _ = mem_buf.get_image(buffer_id, remove=False)
|
||||
# It is the same image
|
||||
assert misc_image is returned_image
|
||||
# It is still in memory
|
||||
returned_image, _ = mem_buf.get_image(buffer_id)
|
||||
assert misc_image is returned_image
|
||||
# It is now removed from memory
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image(buffer_id)
|
||||
|
||||
|
||||
def test_get_without_id():
|
||||
"""Check images can be captured and retrieved without ID."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
misc_image = random_image()
|
||||
mem_buf.add_image(misc_image)
|
||||
returned_image, _ = mem_buf.get_image()
|
||||
# It is the same image
|
||||
assert misc_image is returned_image
|
||||
# It is now removed from memory
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image()
|
||||
|
||||
|
||||
def test_get_two_images():
|
||||
"""Check two images can be retrieved."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
misc_image1 = random_image()
|
||||
misc_image2 = random_image()
|
||||
buffer_id1 = mem_buf.add_image(misc_image1, buffer_max=2)
|
||||
buffer_id2 = mem_buf.add_image(misc_image2, buffer_max=2)
|
||||
returned_image1, _ = mem_buf.get_image(buffer_id1)
|
||||
returned_image2, _ = mem_buf.get_image(buffer_id2)
|
||||
# It they the same images
|
||||
assert misc_image1 is returned_image1
|
||||
assert misc_image2 is returned_image2
|
||||
# They are removed from memory
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image(buffer_id1)
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image(returned_image2)
|
||||
|
||||
|
||||
def test_get_two_images_without_setting_buffer_size():
|
||||
"""Check two images can't be retrieved if the buffer size isn't set."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
misc_image1 = random_image()
|
||||
misc_image2 = random_image()
|
||||
buffer_id1 = mem_buf.add_image(misc_image1)
|
||||
buffer_id2 = mem_buf.add_image(misc_image2)
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image(buffer_id1)
|
||||
returned_image2, _ = mem_buf.get_image(buffer_id2)
|
||||
# Image 2 the expected image
|
||||
assert misc_image2 is returned_image2
|
||||
|
||||
|
||||
def test_buffer_size_changing():
|
||||
"""Check buffer size resets back to 1 when if not set."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
misc_image1 = random_image()
|
||||
misc_image2 = random_image()
|
||||
misc_image3 = random_image()
|
||||
buffer_id1 = mem_buf.add_image(misc_image1, buffer_max=3)
|
||||
buffer_id2 = mem_buf.add_image(misc_image2, buffer_max=3)
|
||||
# Third capture doesn't set buffer size, so it will be reset
|
||||
buffer_id3 = mem_buf.add_image(misc_image3)
|
||||
# As buffer size was reset, images 1 and 2 are deleted
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image(buffer_id1)
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image(buffer_id2)
|
||||
returned_image3, _ = mem_buf.get_image(buffer_id3)
|
||||
# Image 3 the expected image
|
||||
assert misc_image3 is returned_image3
|
||||
|
||||
|
||||
def test_capture_two_images_get_without_id():
|
||||
"""Check that all images are deleted when getting without id."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
misc_image1 = random_image()
|
||||
misc_image2 = random_image()
|
||||
mem_buf.add_image(misc_image1, buffer_max=2)
|
||||
mem_buf.add_image(misc_image2, buffer_max=2)
|
||||
returned_image, _ = mem_buf.get_image()
|
||||
# When buffer_id is not specified, the most recent image (image2) is expected to
|
||||
# be retrieved
|
||||
assert returned_image is misc_image2
|
||||
# Check all images were wiped from memory, but trying get_image without an id
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image()
|
||||
|
||||
|
||||
def test_buffer_size_respected():
|
||||
"""Capture 10 images with a buffer size of 5. Check only last 5 exist."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
|
||||
images = []
|
||||
buffer_ids = []
|
||||
for _i in range(10):
|
||||
image = random_image()
|
||||
buffer_id = mem_buf.add_image(image, buffer_max=5)
|
||||
images.append(image)
|
||||
buffer_ids.append(buffer_id)
|
||||
|
||||
for i, (image, buffer_id) in enumerate(zip(images, buffer_ids, strict=True)):
|
||||
if i < 5:
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image(buffer_id)
|
||||
else:
|
||||
returned_image, _ = mem_buf.get_image(buffer_id)
|
||||
assert image is returned_image
|
||||
|
||||
|
||||
def test_clear_buffer():
|
||||
"""Capture 10 images clear the buffer and check they are gone."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
|
||||
images = []
|
||||
buffer_ids = []
|
||||
for _i in range(10):
|
||||
image = random_image()
|
||||
buffer_id = mem_buf.add_image(image, buffer_max=10)
|
||||
images.append(image)
|
||||
buffer_ids.append(buffer_id)
|
||||
|
||||
# Clear the buffer
|
||||
mem_buf.clear()
|
||||
|
||||
# They are now gone
|
||||
for _image, buffer_id in zip(images, buffer_ids, strict=True):
|
||||
with pytest.raises(NoImageInMemoryError):
|
||||
mem_buf.get_image(buffer_id)
|
||||
|
||||
|
||||
def test_get_metadata_too():
|
||||
"""Capture 10 images with metadata and check metadata is returned as expected."""
|
||||
mem_buf = CameraMemoryBuffer()
|
||||
|
||||
images = []
|
||||
metadatas = []
|
||||
buffer_ids = []
|
||||
for _i in range(10):
|
||||
image = random_image()
|
||||
metadata = random_metadata()
|
||||
buffer_id = mem_buf.add_image(image, metadata, buffer_max=10)
|
||||
images.append(image)
|
||||
metadatas.append(metadata)
|
||||
buffer_ids.append(buffer_id)
|
||||
|
||||
# Preallocate zipped data, to avoid long confusing lines
|
||||
zipped = zip(images, metadatas, buffer_ids, strict=True)
|
||||
|
||||
# Check both image and metadata
|
||||
for image, metadata, buffer_id in zipped:
|
||||
returned_image, returned_metadata = mem_buf.get_image(buffer_id)
|
||||
assert image is returned_image
|
||||
assert metadata is returned_metadata
|
||||
140
tests/unit_tests/test_cameras.py
Normal file
140
tests/unit_tests/test_cameras.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Tests for camera classes. These tests focus on checking the camera APIs are equivalent.
|
||||
|
||||
Tests for specific camera hardware are in the hardware-specific-tests directory. Tests
|
||||
on camera functionality using the simulation camera are in "test_camera".
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from labthings_fastapi.testing import create_thing_without_server
|
||||
|
||||
from openflexure_microscope_server.things.camera import BaseCamera
|
||||
from openflexure_microscope_server.things.camera.opencv import OpenCVCamera
|
||||
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_picam_thing(mocker):
|
||||
"""Import PiCamera without hardware well enough to get a ThingDescription."""
|
||||
dummy_cam = mocker.Mock()
|
||||
mock_picamera2 = mocker.MagicMock()
|
||||
mock_picamera2.return_value.__enter__.return_value = dummy_cam
|
||||
mock_picamera2.return_value.__exit__.return_value = None
|
||||
|
||||
mocker.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"picamera2": mock_picamera2,
|
||||
"picamera2.encoders": mocker.Mock(),
|
||||
"picamera2.outputs": mocker.Mock(),
|
||||
},
|
||||
)
|
||||
|
||||
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
|
||||
|
||||
return create_thing_without_server(StreamingPiCamera2)
|
||||
|
||||
|
||||
def _get_clean_camera_description(camera_thing):
|
||||
"""Return actions and properties for a camera Thing, separating those exposed to the UI.
|
||||
|
||||
Return cleaned sets of actions and properties for camera_thing, excluding
|
||||
calibration actions exposed to the UI as primary or secondary calibration actions,
|
||||
and excluding the properties exposed in manual camera settings.
|
||||
|
||||
:returns: Dict with keys:
|
||||
|
||||
* "actions" - Actions not explicitly exposed to the UI by this camera.
|
||||
* "properties" - Properties not explicitly exposed to the UI as manual camera
|
||||
settings.
|
||||
* "calibration_actions" - Actions explicitly exposed to the UI by this camera.
|
||||
* "manual_properties" - Properties explicitly exposed to the UI as manual
|
||||
camera settings.
|
||||
"""
|
||||
td = camera_thing.thing_description()
|
||||
actions = set(td.actions.keys())
|
||||
properties = set(td.properties.keys())
|
||||
|
||||
# Calibration actions are camera specific
|
||||
primary = [btn.action for btn in camera_thing.primary_calibration_actions]
|
||||
secondary = [btn.action for btn in camera_thing.secondary_calibration_actions]
|
||||
calibration_actions = set(primary + secondary)
|
||||
|
||||
actions -= calibration_actions
|
||||
|
||||
# Manual properties are also camera specific
|
||||
manual_props = {ctrl.property_name for ctrl in camera_thing.manual_camera_settings}
|
||||
properties -= manual_props
|
||||
|
||||
return {
|
||||
"actions": actions,
|
||||
"properties": properties,
|
||||
"calibration_actions": calibration_actions,
|
||||
"manual_properties": manual_props,
|
||||
}
|
||||
|
||||
|
||||
def test_thing_description_equivalence(mock_picam_thing):
|
||||
"""Ensure the built-in camera Thing subclasses retain consistent core functionality.
|
||||
|
||||
This test verifies that extra actions are not unintentionally introduced to
|
||||
camera child classes. Any addition of actions must be accompanied by an update
|
||||
to this test, prompting discussion of whether the action belongs in the subclass
|
||||
or the base camera class.
|
||||
"""
|
||||
base_td = create_thing_without_server(BaseCamera).thing_description()
|
||||
base_actions = set(base_td.actions.keys())
|
||||
base_props = set(base_td.properties.keys())
|
||||
|
||||
sim_camera = create_thing_without_server(SimulatedCamera)
|
||||
sim_description = _get_clean_camera_description(sim_camera)
|
||||
opencv_camera = create_thing_without_server(OpenCVCamera)
|
||||
opencv_description = _get_clean_camera_description(opencv_camera)
|
||||
picamera_description = _get_clean_camera_description(mock_picam_thing)
|
||||
|
||||
# Note. These are the actions and properties that are not exposed to the UI via
|
||||
# `primary_calibration_actions`, `secondary_calibration_actions`, or
|
||||
# `manual_camera_settings`.
|
||||
sim_actions = sim_description["actions"]
|
||||
sim_props = sim_description["properties"]
|
||||
|
||||
opencv_actions = opencv_description["actions"]
|
||||
opencv_props = opencv_description["properties"]
|
||||
|
||||
picamera_actions = picamera_description["actions"]
|
||||
picamera_props = picamera_description["properties"]
|
||||
|
||||
# Camera actions and properties should generally be equivalent except for exposed
|
||||
# manual settings and calibration actions.
|
||||
assert opencv_actions == sim_actions == base_actions
|
||||
assert opencv_props == sim_props == base_props
|
||||
|
||||
# For now PiCamera has a number of extra actions and properties. These should be
|
||||
# reduced over time by creating a way to use the functionality in a way as clearly
|
||||
# defined as PiCamera specific, or by replicating the functionality for other
|
||||
# cameras.
|
||||
picamera_extra_actions = {
|
||||
"set_static_green_equalisation",
|
||||
"set_ce_enable_to_off",
|
||||
"stop_streaming",
|
||||
"reset_ccm",
|
||||
}
|
||||
picamera_extra_props = {
|
||||
"mjpeg_bitrate",
|
||||
"colour_correction_matrix",
|
||||
"lens_shading_tables",
|
||||
"sensor_resolution",
|
||||
"capture_metadata",
|
||||
"camera_configuration",
|
||||
"stream_resolution",
|
||||
"tuning",
|
||||
"sensor_modes",
|
||||
"sensor_mode",
|
||||
}
|
||||
# Note these are only the action not exposed as calibration actions.
|
||||
for action in picamera_extra_actions:
|
||||
assert action in picamera_actions
|
||||
for props in picamera_extra_props:
|
||||
assert props in picamera_props
|
||||
assert picamera_actions - base_actions == picamera_extra_actions
|
||||
assert picamera_props - base_props == picamera_extra_props
|
||||
125
tests/unit_tests/test_config_utilities.py
Normal file
125
tests/unit_tests/test_config_utilities.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Test the configuration handling functions in utilities."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from json import JSONDecodeError
|
||||
|
||||
import pytest
|
||||
|
||||
from openflexure_microscope_server import utilities
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "patch", "outcome", "err_if_enforce"),
|
||||
[
|
||||
({"a": "b"}, {"a": "c"}, {"a": "c"}, False),
|
||||
({"a": "b"}, {"b": "c"}, {"a": "b", "b": "c"}, False),
|
||||
({"a": "b"}, {"a": None}, {}, False),
|
||||
({"a": "b", "b": "c"}, {"a": None}, {"b": "c"}, False),
|
||||
({"a": ["b"]}, {"a": "c"}, {"a": "c"}, False),
|
||||
({"a": "c"}, {"a": ["b"]}, {"a": ["b"]}, False),
|
||||
(
|
||||
{"a": {"b": "c"}},
|
||||
{"a": {"b": "d", "c": None}},
|
||||
{"a": {"b": "d"}},
|
||||
False,
|
||||
),
|
||||
({"a": [{"b": "c"}]}, {"a": [1]}, {"a": [1]}, False),
|
||||
(["a", "b"], ["c", "d"], ["c", "d"], True),
|
||||
({"a": "b"}, ["c"], ["c"], True),
|
||||
({"a": "foo"}, None, None, True),
|
||||
({"a": "foo"}, "bar", "bar", True),
|
||||
({"e": None}, {"a": 1}, {"e": None, "a": 1}, False),
|
||||
([1, 2], {"a": "b", "c": None}, {"a": "b"}, True),
|
||||
({}, {"a": {"bb": {"ccc": None}}}, {"a": {"bb": {}}}, False),
|
||||
],
|
||||
)
|
||||
def test_merge_patch(target, patch, outcome, err_if_enforce):
|
||||
"""Check that merge_patch returns the expected outcome for each target and patch.
|
||||
|
||||
These test cases are specified in Appedix A of IETF RFC 7396.
|
||||
"""
|
||||
assert utilities.merge_patch(target, patch) == outcome
|
||||
|
||||
# If expected to error when enforce_dict is True then check:
|
||||
if err_if_enforce:
|
||||
with pytest.raises(ValueError, match="Both target and patch"):
|
||||
utilities.merge_patch(target, patch, enforce_dict=True)
|
||||
else:
|
||||
# Else should run without error with same expected value
|
||||
assert utilities.merge_patch(target, patch, enforce_dict=True) == outcome
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("base_conf", "resolves_to"),
|
||||
[
|
||||
("/var/base.json", "/var/base.json"),
|
||||
("base.json", "/var/openflexure/settings/base.json"),
|
||||
("./base.json", "/var/openflexure/settings/base.json"),
|
||||
("../base.json", "/var/openflexure/base.json"),
|
||||
(
|
||||
"$OFM_LIB/base.json",
|
||||
"/var/openflexure/application/openflexure-microscope-server/base.json",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_resolve_path_from_dir(base_conf, resolves_to, mocker):
|
||||
"""Test that resolve path handles, absolute and relative paths and expands env vars."""
|
||||
mocker.patch.dict(
|
||||
os.environ,
|
||||
{"OFM_LIB": "/var/openflexure/application/openflexure-microscope-server"},
|
||||
)
|
||||
conf_dir = "/var/openflexure/settings"
|
||||
resolved_path = utilities.resolve_path_from_dir(base_conf, directory=conf_dir)
|
||||
assert resolved_path == resolves_to
|
||||
|
||||
|
||||
def test_patching_config(tmpdir):
|
||||
"""Test each situation in load_patched_config."""
|
||||
conf_file = os.path.join(tmpdir, "ofm_config.json")
|
||||
base_conf_file = os.path.join(tmpdir, "ofm_base_config.json")
|
||||
|
||||
simple_config = {"things": {"thing1": "python.class"}}
|
||||
config_patch = {"things": {"thing2": "python.class2"}}
|
||||
patched_config = {"things": {"thing1": "python.class", "thing2": "python.class2"}}
|
||||
broken_json = "{{invalid{Json"
|
||||
|
||||
# Error if config file doesn't exist
|
||||
with pytest.raises(IOError, match="Couldn't load configuration"):
|
||||
utilities.load_patched_config(conf_file)
|
||||
|
||||
# Error if config file contains bad json
|
||||
with open(conf_file, "w", encoding="utf-8") as file_obj:
|
||||
file_obj.write(broken_json)
|
||||
with pytest.raises(JSONDecodeError, match="Invalid JSON in configuration"):
|
||||
utilities.load_patched_config(conf_file)
|
||||
|
||||
# If base_config_file is not set, just return the configuration
|
||||
with open(conf_file, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(simple_config, file_obj)
|
||||
assert utilities.load_patched_config(conf_file) == simple_config
|
||||
|
||||
# Set the "base_config_file" instead, error as there is no base config file
|
||||
config = {"base_config_file": "./ofm_base_config.json"}
|
||||
with open(conf_file, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(config, file_obj)
|
||||
with pytest.raises(IOError, match="Couldn't load base configuration"):
|
||||
utilities.load_patched_config(conf_file)
|
||||
|
||||
# Error if base config file contains bad json
|
||||
with open(base_conf_file, "w", encoding="utf-8") as file_obj:
|
||||
file_obj.write(broken_json)
|
||||
with pytest.raises(JSONDecodeError, match="Invalid JSON in base configuration"):
|
||||
utilities.load_patched_config(conf_file)
|
||||
|
||||
# Loads the simple configuration from the base config file.
|
||||
with open(base_conf_file, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(simple_config, file_obj)
|
||||
assert utilities.load_patched_config(conf_file) == simple_config
|
||||
|
||||
# Set the outer config to have a patch.
|
||||
# It now reads the simple_config from the base condif file and patches it
|
||||
config["patch"] = config_patch
|
||||
with open(conf_file, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(config, file_obj)
|
||||
assert utilities.load_patched_config(conf_file) == patched_config
|
||||
97
tests/unit_tests/test_dummy_server.py
Normal file
97
tests/unit_tests/test_dummy_server.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Test the server without creating a full HTTP server and socket connection.
|
||||
|
||||
Rather than spinning up a full uvicorn webserver for each test these tests use
|
||||
the FastAPI ``TestClient`` or directly communicate with the underlying
|
||||
LabThings-FastAPI code. This increases speed of testing significantly.
|
||||
|
||||
For tests that require a full running server see the ``integration-tests``
|
||||
directory in the root of the repository.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import piexif
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from .utilities.lt_test_utils import LabThingsTestEnv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_env():
|
||||
"""Yield a server with a very basic configuration."""
|
||||
thing_conf = {
|
||||
"camera": {
|
||||
"class": "openflexure_microscope_server.things.camera.simulation:SimulatedCamera",
|
||||
"kwargs": {
|
||||
"shape": (240, 320, 3),
|
||||
"canvas_shape": (1000, 1500, 3),
|
||||
"frame_interval": 0.01,
|
||||
},
|
||||
},
|
||||
"stage": {
|
||||
"class": "openflexure_microscope_server.things.stage.dummy:DummyStage",
|
||||
"kwargs": {"step_time": 0.000001},
|
||||
},
|
||||
"autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing",
|
||||
"camera_stage_mapping": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper",
|
||||
}
|
||||
with LabThingsTestEnv(things=thing_conf) as env:
|
||||
yield env
|
||||
|
||||
|
||||
def test_autofocus(test_env):
|
||||
"""Test Fast Autofocus can run doesn't raise an exception."""
|
||||
# Adjust the time for stage is 100 microseconds rather than 1 microsecond.
|
||||
test_env.get_thing_by_name("stage").step_time = 0.0001
|
||||
autofocus = test_env.get_thing_client("autofocus")
|
||||
_ = autofocus.fast_autofocus()
|
||||
|
||||
|
||||
def test_grab_jpeg(test_env):
|
||||
"""Check that grab_jpeg returns a blob that can be opened."""
|
||||
camera = test_env.get_thing_client("camera")
|
||||
blob = camera.grab_jpeg()
|
||||
_image = Image.open(blob.open())
|
||||
|
||||
|
||||
def test_capture_jpeg_metadata(test_env):
|
||||
"""Check that the position is encoded into the image metadata."""
|
||||
camera = test_env.get_thing_client("camera")
|
||||
blob = camera.capture_jpeg()
|
||||
image = Image.open(blob.open())
|
||||
exif_dict = piexif.load(image.info["exif"])
|
||||
encoded_metadata = exif_dict["Exif"][piexif.ExifIFD.UserComment]
|
||||
metadata = json.loads(encoded_metadata)
|
||||
assert "position" in metadata["stage"]
|
||||
|
||||
|
||||
def test_stage(test_env):
|
||||
"""Test moving th stage forwards and backwards."""
|
||||
stage = test_env.get_thing_client("stage")
|
||||
start = stage.position
|
||||
move = {"x": 1, "y": 2, "z": 3}
|
||||
stage.move_relative(**move)
|
||||
pos = stage.position
|
||||
for s, m, p in zip(start.values(), move.values(), pos.values(), strict=True):
|
||||
assert s + m == p
|
||||
stage.move_relative(**{k: -v for k, v in move.items()})
|
||||
pos = stage.position
|
||||
for s, p in zip(start.values(), pos.values(), strict=True):
|
||||
assert s == p
|
||||
|
||||
|
||||
def test_capture_array(test_env):
|
||||
"""Capture array from simulation and check the size is as expected."""
|
||||
camera = test_env.get_thing_client("camera")
|
||||
array = np.asarray(camera.capture_array())
|
||||
assert array.shape == (240, 320, 3)
|
||||
|
||||
|
||||
def test_camera_stage_mapping_calibration(test_env):
|
||||
"""Check that camera stage mapping can run without an exception."""
|
||||
camera = test_env.get_thing_client("camera")
|
||||
camera.settling_time = 0
|
||||
camera_stage_mapping = test_env.get_thing_client("camera_stage_mapping")
|
||||
camera_stage_mapping.calibrate_xy()
|
||||
63
tests/unit_tests/test_legacy_api.py
Normal file
63
tests/unit_tests/test_legacy_api.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Test server booting and shut down."""
|
||||
|
||||
import asyncio
|
||||
from socket import gethostname
|
||||
|
||||
# Import as ofm server to attempt to minimise confusion with server as a var in other
|
||||
# functions and also FastAPI `Server`.
|
||||
from openflexure_microscope_server.server import legacy_api
|
||||
|
||||
|
||||
def test_v2_endpoints(mocker):
|
||||
"""Check that the expected v2 endpoints are added."""
|
||||
mock_server = mocker.Mock()
|
||||
# Mock the camera thing to mocke the lores_mjpeg stream get_frame()
|
||||
mock_server.things = {"camera": mocker.Mock()}
|
||||
mock_server.things["camera"].lores_mjpeg_stream.grab_frame = mocker.AsyncMock(
|
||||
return_value="Mock Frame"
|
||||
)
|
||||
mock_app = mock_server.app
|
||||
# The wrapper returned for app.get so we can see what functions are decorated.
|
||||
get_wrapper = mock_app.get.return_value
|
||||
head_wrapper = mock_app.head.return_value
|
||||
|
||||
legacy_api.add_v2_endpoints(mock_server)
|
||||
|
||||
assert get_wrapper.call_count == 3
|
||||
assert head_wrapper.call_count == 1
|
||||
|
||||
# The calls for the get decorator and the internal wrapper
|
||||
get_and_wrapper_calls = zip(
|
||||
mock_app.get.call_args_list, get_wrapper.call_args_list, strict=True
|
||||
)
|
||||
# Pull out the first arg of each to get the route and wrapped function, save as a
|
||||
# dictionary - route: function
|
||||
routes = {
|
||||
get_call.args[0]: wrapper_call.args[0]
|
||||
for get_call, wrapper_call in get_and_wrapper_calls
|
||||
}
|
||||
|
||||
# Check the fake routes are set
|
||||
assert "/routes" in routes
|
||||
fake_routes_dict = routes["/routes"]()
|
||||
assert isinstance(fake_routes_dict, dict)
|
||||
for fake_route in legacy_api.FAKE_ROUTES:
|
||||
assert fake_route in fake_routes_dict
|
||||
assert fake_routes_dict[fake_route]["url"] == fake_route
|
||||
assert fake_routes_dict[fake_route]["methods"] == ["GET"]
|
||||
|
||||
# Check snapshot returns a jupeg response from the async get_frame of the
|
||||
# lores_mjpeg_stream
|
||||
assert "/api/v2/streams/snapshot" in routes
|
||||
# First get the async frame function from the head wrapper
|
||||
get_frames_func = head_wrapper.call_args.args[0]()
|
||||
assert routes["/api/v2/streams/snapshot"] == head_wrapper()
|
||||
|
||||
jpg_response = asyncio.run(get_frames_func)
|
||||
assert isinstance(jpg_response, legacy_api.JPEGResponse)
|
||||
# Value should be set as mocked rather than a frame
|
||||
assert jpg_response.body.decode() == "Mock Frame"
|
||||
|
||||
# Also check the name is the hostname.
|
||||
assert "/api/v2/instrument/settings/name" in routes
|
||||
assert routes["/api/v2/instrument/settings/name"]() == gethostname()
|
||||
134
tests/unit_tests/test_lock_decorator.py
Normal file
134
tests/unit_tests/test_lock_decorator.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Tests for the ``requires_lock`` decorator in ``utilities.py``."""
|
||||
|
||||
import threading
|
||||
from time import sleep, time
|
||||
|
||||
import pytest
|
||||
|
||||
from openflexure_microscope_server.utilities import requires_lock
|
||||
|
||||
|
||||
class LockableClass:
|
||||
"""A class with methods that can be locked with the ``requires_lock`` decorator."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialise the LockableClass with a rentrant lock."""
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@property
|
||||
@requires_lock
|
||||
def test_property(self) -> int:
|
||||
"""A property that always returns 1."""
|
||||
return 1
|
||||
|
||||
@requires_lock
|
||||
def fast_task(self) -> int:
|
||||
"""Instantly return the number 2."""
|
||||
return 2
|
||||
|
||||
@requires_lock
|
||||
def slow_task(self) -> int:
|
||||
"""Take 0.5s to return the number 3."""
|
||||
sleep(0.5)
|
||||
return 3
|
||||
|
||||
@requires_lock
|
||||
def task_that_calls_fast_task(self) -> int:
|
||||
"""Require a lock and call a fast task that requires it too."""
|
||||
return self.fast_task()
|
||||
|
||||
@requires_lock
|
||||
def task_that_calls_property(self) -> int:
|
||||
"""Require a lock and call a property that requires it too."""
|
||||
return self.test_property
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lockable_obj() -> LockableClass:
|
||||
"""Return an instance of LockableClass."""
|
||||
return LockableClass()
|
||||
|
||||
|
||||
def test_error_if_no_lock(lockable_obj):
|
||||
"""Test there is an AttributeError if the object has no _lock attribute."""
|
||||
# Delete the lock!
|
||||
del lockable_obj._lock
|
||||
with pytest.raises(AttributeError) as exec_info:
|
||||
lockable_obj.test_property
|
||||
assert str(exec_info.value) == "LockableClass has no '_lock' attribute"
|
||||
|
||||
|
||||
def test_error_if_lock_of_wrong_type(lockable_obj):
|
||||
"""Test there is an TypeError if the object's _lock attribute isn't a lock."""
|
||||
# Delete the lock!
|
||||
lockable_obj._lock = "I'm a mock, not a lock!"
|
||||
with pytest.raises(TypeError) as exec_info:
|
||||
lockable_obj.test_property
|
||||
expected_msg = "requires_lock requires self._lock to be a lock"
|
||||
assert str(exec_info.value) == expected_msg
|
||||
|
||||
|
||||
def test_functionality(lockable_obj):
|
||||
"""Check that this doesn't affect the most basic functionality of the class."""
|
||||
assert lockable_obj.test_property == 1
|
||||
assert lockable_obj.fast_task() == 2
|
||||
# Don't check slow task here as it is slow and functionally no different from fast
|
||||
# task.
|
||||
# Check tasks that call other tasks locked to check that they are rentrant
|
||||
assert lockable_obj.task_that_calls_property() == 1
|
||||
assert lockable_obj.task_that_calls_fast_task() == 2
|
||||
|
||||
|
||||
def check_fast_target_waits(lockable_obj, fast_target):
|
||||
"""Start threads with a slow target and the supplied fast target, check lock works.
|
||||
|
||||
:param lockable_obj: An instance of LockableClass
|
||||
:param fast_target: The callable to test with the fast thread.
|
||||
|
||||
The program runs by:
|
||||
|
||||
* First checking that fast_target when run on its own completes in less than
|
||||
0.1s
|
||||
* Starts the thread with the slow_target.
|
||||
* Starts the thread with the fast_target straight afterwards.
|
||||
* Wait for the fast target to complete.
|
||||
* Check the slow target has completeted once returned (showing that the fast
|
||||
target had to wait because of the lock)
|
||||
* Check that the time elapsed is over 0.4s.
|
||||
"""
|
||||
# On its own the fast thread is fast.
|
||||
fast_thread = threading.Thread(target=fast_target)
|
||||
t_start = time()
|
||||
fast_thread.start()
|
||||
fast_thread.join()
|
||||
assert time() - t_start < 0.1
|
||||
|
||||
# If slow thread is running it has to wait.
|
||||
fast_thread = threading.Thread(target=fast_target)
|
||||
slow_thread = threading.Thread(target=lockable_obj.slow_task)
|
||||
|
||||
t_start = time()
|
||||
slow_thread.start()
|
||||
fast_thread.start()
|
||||
fast_thread.join()
|
||||
# slow task must have finished
|
||||
assert not slow_thread.is_alive()
|
||||
assert time() - t_start > 0.4
|
||||
|
||||
|
||||
def test_locking(lockable_obj):
|
||||
"""Test the locking works when calling decorated methods in two threads."""
|
||||
check_fast_target_waits(lockable_obj, lockable_obj.fast_task)
|
||||
|
||||
|
||||
def test_property_locking(lockable_obj):
|
||||
"""Test that the locking works when calling a property not a function.
|
||||
|
||||
The target is a method with no lock, that calls the property that requires
|
||||
the lock.
|
||||
"""
|
||||
|
||||
def property_caller() -> int:
|
||||
return lockable_obj.test_property
|
||||
|
||||
check_fast_target_waits(lockable_obj, property_caller)
|
||||
187
tests/unit_tests/test_logging.py
Normal file
187
tests/unit_tests/test_logging.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Test the server's logging configuration and handling."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from openflexure_microscope_server import logging as ofm_logging
|
||||
|
||||
|
||||
def test_no_warnings_if_correct_permissions(caplog):
|
||||
"""Check that configure_logging no warnings on normal operation.
|
||||
|
||||
Checking that:
|
||||
|
||||
* The logger can be set up without warnings.
|
||||
* That the log file is created.
|
||||
* That the log file contains the expected message about setting up the logger.
|
||||
"""
|
||||
# Reset handler at start of test
|
||||
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
|
||||
with caplog.at_level(logging.WARNING), tempfile.TemporaryDirectory() as tmpdir:
|
||||
ofm_logging.configure_logging(tmpdir)
|
||||
assert len(caplog.records) == 0
|
||||
with open(ofm_logging.OFM_LOG_FILE, "r", encoding="utf-8") as log_file:
|
||||
log_txt = log_file.read()
|
||||
assert "OFM server root logger has been set up at INFO level" in log_txt
|
||||
root_logger = logging.getLogger()
|
||||
assert ofm_logging.OFM_HANDLER in root_logger.handlers
|
||||
|
||||
|
||||
def test_permission_error_raises_warning(mocker, caplog):
|
||||
"""Check that configure_logging raises warning on PermissionError."""
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.logging.RotatingFileHandler",
|
||||
side_effect=PermissionError("Permission denied"),
|
||||
)
|
||||
# Reset handler at start of test
|
||||
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
|
||||
with caplog.at_level(logging.WARNING), tempfile.TemporaryDirectory() as tmpdir:
|
||||
ofm_logging.configure_logging(tmpdir)
|
||||
assert len(caplog.records) == 1
|
||||
# Check OFM logger is added even if the file logger couldn't be.
|
||||
root_logger = logging.getLogger()
|
||||
assert ofm_logging.OFM_HANDLER in root_logger.handlers
|
||||
|
||||
|
||||
def test_making_log_dir(caplog):
|
||||
"""Check that configure_logging will make a dir if needed."""
|
||||
# Reset handler at start of test
|
||||
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
|
||||
with caplog.at_level(logging.WARNING), tempfile.TemporaryDirectory() as tmpdir:
|
||||
log_dir = os.path.join(tmpdir, "new_dir")
|
||||
assert not os.path.isdir(log_dir)
|
||||
ofm_logging.configure_logging(log_dir)
|
||||
assert len(caplog.records) == 0
|
||||
assert os.path.isdir(log_dir)
|
||||
|
||||
|
||||
def test_max_logs():
|
||||
"""Proclaim that only the most recent 250 logs are stored in memory."""
|
||||
# Reset handler at start of test
|
||||
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ofm_logging.configure_logging(tmpdir)
|
||||
# But I would log five hundred times.
|
||||
for i in range(500):
|
||||
logging.info("log %s", i)
|
||||
logs = ofm_logging.OFM_HANDLER.log_history.split("\n")
|
||||
assert len(logs) == 250
|
||||
assert logs[0].endswith("log 250")
|
||||
assert logs[-1].endswith("log 499")
|
||||
# And I would log five hundred more.
|
||||
for i in range(500):
|
||||
logging.info("log %s", 500 + i)
|
||||
# Just to be the test who logged a thousand times to check your hand-el-or!
|
||||
logs = ofm_logging.OFM_HANDLER.log_history.split("\n")
|
||||
assert len(logs) == 250
|
||||
assert logs[0].endswith("log 750")
|
||||
assert logs[-1].endswith("log 999")
|
||||
|
||||
|
||||
def test_ofm_handler_ignores_uvicorn_access():
|
||||
"""Check uvicorn.access logs are ignored by custom handler.
|
||||
|
||||
The uvicorn.access logger logs every time an endpoint is triggered.
|
||||
"""
|
||||
# Reset handler at start of test
|
||||
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ofm_logging.configure_logging(tmpdir)
|
||||
logs = ofm_logging.OFM_HANDLER.log_history.split("\n")
|
||||
starting_len = len(logs)
|
||||
logging.info("Root 1")
|
||||
uvicorn_logger = logging.getLogger("uvicorn.access")
|
||||
for i in range(500):
|
||||
uvicorn_logger.info("Uvicorn log %s", i)
|
||||
logging.info("Root 2")
|
||||
logs = ofm_logging.OFM_HANDLER.log_history.split("\n")
|
||||
assert len(logs) == starting_len + 2
|
||||
assert logs[starting_len].endswith("Root 1")
|
||||
assert logs[-1].endswith("Root 2")
|
||||
|
||||
|
||||
def test_server_responses():
|
||||
"""Check that the FastAPI responses from the server are as expected.
|
||||
|
||||
* retrieve_log() -> PlainTextResponse containing the last 250 logs separated by
|
||||
a new line
|
||||
* retrieve_log_from_file() -> All the logs from the log file.
|
||||
"""
|
||||
# Reset handler at start of test
|
||||
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ofm_logging.configure_logging(tmpdir)
|
||||
for i in range(500):
|
||||
logging.info("log %s", i)
|
||||
log_response = ofm_logging.retrieve_log()
|
||||
assert isinstance(log_response, PlainTextResponse)
|
||||
logs = log_response.body.decode().split("\n")
|
||||
assert len(logs) == 250
|
||||
file_log_response = ofm_logging.retrieve_log_from_file()
|
||||
assert isinstance(file_log_response, PlainTextResponse)
|
||||
file_logs = file_log_response.body.decode().split("\n")
|
||||
assert len(file_logs) > 500
|
||||
|
||||
|
||||
def test_server_response_with_no_log_file():
|
||||
"""Check that an HTTP exception is raised if logging isn't configured."""
|
||||
# Reset handler at start of test
|
||||
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
|
||||
# Also Reset log file global
|
||||
ofm_logging.OFM_LOG_FILE = None
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
ofm_logging.retrieve_log_from_file()
|
||||
assert excinfo.value.status_code == 500
|
||||
|
||||
|
||||
def test_server_response_with_no_log_dir():
|
||||
"""Check that an HTTP exception is raised if the log file cannot be accessed."""
|
||||
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ofm_logging.configure_logging(tmpdir)
|
||||
for i in range(500):
|
||||
logging.info("log %s", i)
|
||||
file_log_response = ofm_logging.retrieve_log_from_file()
|
||||
assert isinstance(file_log_response, PlainTextResponse)
|
||||
# Close and delete the log folder, this should create an error.
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
ofm_logging.retrieve_log_from_file()
|
||||
assert excinfo.value.status_code == 500
|
||||
|
||||
|
||||
FAKE_UVICORN_LOGGER = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("log_command", "names_in_log", "names_not_in_log"),
|
||||
[
|
||||
(FAKE_UVICORN_LOGGER.debug, [], ["<uvicorn>", "<uvicorn.error>"]),
|
||||
(FAKE_UVICORN_LOGGER.info, ["<uvicorn>"], ["<uvicorn.error>"]),
|
||||
(FAKE_UVICORN_LOGGER.warning, ["<uvicorn>"], ["<uvicorn.error>"]),
|
||||
(FAKE_UVICORN_LOGGER.error, ["<uvicorn.error>"], ["<uvicorn>"]),
|
||||
(FAKE_UVICORN_LOGGER.exception, ["<uvicorn.error>"], ["<uvicorn>"]),
|
||||
],
|
||||
)
|
||||
def test_uvicorn_error_only_says_error_on_error(
|
||||
log_command, names_in_log, names_not_in_log
|
||||
):
|
||||
"""Check that an HTTP exception is raised if the log file cannot be accessed.
|
||||
|
||||
Parametrised to check: debug doesn't log, info and warning log as <uvicorn>, and
|
||||
error logs as <uvicorn.error>.
|
||||
"""
|
||||
ofm_logging.OFM_HANDLER = ofm_logging.OFMHandler()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ofm_logging.configure_logging(tmpdir)
|
||||
log_command("Mockety mock mock!")
|
||||
with open(ofm_logging.OFM_LOG_FILE, "r", encoding="utf-8") as log_file:
|
||||
log_txt = log_file.read()
|
||||
for name in names_in_log:
|
||||
assert name in log_txt
|
||||
for name in names_not_in_log:
|
||||
assert name not in log_txt
|
||||
263
tests/unit_tests/test_picamera_tuning_files.py
Normal file
263
tests/unit_tests/test_picamera_tuning_files.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"""Tests for interactions with tuning files and dictionaries."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from openflexure_microscope_server.things.camera import (
|
||||
picamera_tuning_file_utils as tf_utils,
|
||||
)
|
||||
|
||||
RNG = np.random.default_rng()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imx219_tuning():
|
||||
"""Load the default tuning file for the PiCamera v2."""
|
||||
return tf_utils.load_default_tuning("imx219")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imx477_tuning():
|
||||
"""Load the default tuning file for the PiCamera HQ."""
|
||||
return tf_utils.load_default_tuning("imx477")
|
||||
|
||||
|
||||
def test_load_tuning(imx219_tuning, imx477_tuning):
|
||||
"""Check the sctandard tuning files load and contain the correct information."""
|
||||
assert imx219_tuning != imx477_tuning
|
||||
|
||||
for tuning in [imx219_tuning, imx477_tuning]:
|
||||
assert tuning["version"] == 2.0
|
||||
assert tuning["target"] == "bcm2835"
|
||||
assert isinstance(tuning["algorithms"], list)
|
||||
|
||||
# The algorithms in order, and whether we expect them to be the same for the cameras.
|
||||
algos = [
|
||||
("rpi.black_level", True),
|
||||
("rpi.dpc", True),
|
||||
("rpi.lux", True),
|
||||
("rpi.noise", True),
|
||||
("rpi.geq", True),
|
||||
("rpi.sdn", True),
|
||||
("rpi.awb", True),
|
||||
("rpi.agc", True),
|
||||
("rpi.alsc", False),
|
||||
("rpi.contrast", True),
|
||||
("rpi.ccm", False),
|
||||
("rpi.sharpen", True),
|
||||
("rpi.hdr", True),
|
||||
("rpi.sync", True),
|
||||
]
|
||||
|
||||
# Strict= true so this will error if the tuning files are not the same length as
|
||||
# the algorithms
|
||||
zipped_algos = zip(
|
||||
algos, imx219_tuning["algorithms"], imx477_tuning["algorithms"], strict=True
|
||||
)
|
||||
|
||||
# Split up the algorithm name, either we expect them to be the same in both files
|
||||
# and the algorithm for the two tuning files.
|
||||
for (algo_name, algo_same), algo219, algo477 in zipped_algos:
|
||||
# Check this is the correct algorithm
|
||||
assert algo_name in algo219
|
||||
assert algo_name in algo477
|
||||
# Check they are they are the same if expected to be.
|
||||
if algo_same:
|
||||
assert algo219 == algo477
|
||||
else:
|
||||
assert algo219 != algo477
|
||||
|
||||
|
||||
def test_find_tuning_algo_v1():
|
||||
"""Check tuning algorithms are read directly from dict if no version is set."""
|
||||
# Version 1 onf the tuning file was just a dictionary of algorithms
|
||||
mock_tuning = {"mock": {"param": 1}, "foo": {"param": "bar"}}
|
||||
assert tf_utils.find_tuning_algo(mock_tuning, "mock") == {"param": 1}
|
||||
assert tf_utils.find_tuning_algo(mock_tuning, "foo") == {"param": "bar"}
|
||||
with pytest.raises(KeyError, match="No algorithm who in tuning."):
|
||||
assert tf_utils.find_tuning_algo(mock_tuning, "who")
|
||||
|
||||
# Also check that if version is not 1 it is read differently.
|
||||
mock_tuning["version"] = 2
|
||||
with pytest.raises(KeyError, match="A v2 tuning file must specify"):
|
||||
assert tf_utils.find_tuning_algo(mock_tuning, "mock")
|
||||
|
||||
|
||||
def test_find_tuning_algo_v2():
|
||||
"""Check reading version 2 dictionary structure."""
|
||||
mock_tuning = {
|
||||
"version": 2.0,
|
||||
"algorithms": [{"mock": {"param": 1}}, {"foo": {"param": "bar"}}],
|
||||
}
|
||||
assert tf_utils.find_tuning_algo(mock_tuning, "mock") == {"param": 1}
|
||||
assert tf_utils.find_tuning_algo(mock_tuning, "foo") == {"param": "bar"}
|
||||
with pytest.raises(KeyError, match="No algorithm who in tuning."):
|
||||
assert tf_utils.find_tuning_algo(mock_tuning, "who")
|
||||
|
||||
|
||||
def _assert_lst_table_equivalence(array, table):
|
||||
"""Check equivalence between input numpy array and output table."""
|
||||
for i in range(12):
|
||||
for j in range(16):
|
||||
assert table[i][j] == round(float(array[i, j]), 3)
|
||||
assert table[i][j] == round(float(array[i, j]), 3)
|
||||
|
||||
|
||||
def test_tuning_lst_tools(imx219_tuning, imx477_tuning):
|
||||
"""Check reading the default lens shading tables."""
|
||||
for tuning in (imx219_tuning, imx477_tuning):
|
||||
assert not tf_utils.lst_calibrated(tuning)
|
||||
|
||||
lst_model = tf_utils.get_lst(tuning)
|
||||
assert isinstance(lst_model, tf_utils.LensShading)
|
||||
# Check the tables are lists of lists
|
||||
assert isinstance(lst_model.luminance, list)
|
||||
assert isinstance(lst_model.luminance[0], list)
|
||||
assert isinstance(lst_model.Cr, list)
|
||||
assert isinstance(lst_model.Cr[0], list)
|
||||
assert isinstance(lst_model.Cb, list)
|
||||
assert isinstance(lst_model.Cb[0], list)
|
||||
# Loaded table has default lens tuning,
|
||||
assert lst_model.colour_temp == tf_utils.DEFAULT_COLOUR_TEMP
|
||||
|
||||
|
||||
def test_tuning_initial_colour_gains(imx219_tuning, imx477_tuning):
|
||||
"""The initial colour gains are as expected."""
|
||||
assert tf_utils.get_colour_gains_from_lst(imx219_tuning) == (1.068, 1.259)
|
||||
assert tf_utils.get_colour_gains_from_lst(imx477_tuning) == (1.0, 2.0)
|
||||
|
||||
|
||||
def test_setting_lst(imx219_tuning):
|
||||
"""Check that the LST can be set."""
|
||||
lum = RNG.normal(size=(12, 16))
|
||||
cr = RNG.normal(size=(12, 16))
|
||||
cb = RNG.normal(size=(12, 16))
|
||||
|
||||
updated_tuning = tf_utils.set_lst(
|
||||
imx219_tuning,
|
||||
luminance=lum,
|
||||
cr=cr,
|
||||
cb=cb,
|
||||
colour_temp=tf_utils.CALIBRATED_COLOUR_TEMP,
|
||||
)
|
||||
|
||||
# Check it wasn't modified in place.
|
||||
assert updated_tuning != imx219_tuning
|
||||
# Check it now reports as calibrated
|
||||
assert tf_utils.lst_calibrated(updated_tuning)
|
||||
|
||||
lst_model = tf_utils.get_lst(updated_tuning)
|
||||
assert lst_model.colour_temp == tf_utils.CALIBRATED_COLOUR_TEMP
|
||||
# Check the numbers are equivalent from the input array and the output table
|
||||
# except for formatting and rounding.
|
||||
_assert_lst_table_equivalence(lum, lst_model.luminance)
|
||||
_assert_lst_table_equivalence(cr, lst_model.Cr)
|
||||
_assert_lst_table_equivalence(cb, lst_model.Cb)
|
||||
|
||||
# Flatten the lens shading tables
|
||||
flat_tuning = tf_utils.flatten_lst(updated_tuning)
|
||||
# In this case only the colour channels
|
||||
flat_chroma_tuning = tf_utils.flatten_lst(updated_tuning, keep_luminance=True)
|
||||
|
||||
# Check each was changed
|
||||
assert updated_tuning != flat_tuning
|
||||
assert updated_tuning != flat_chroma_tuning
|
||||
assert flat_tuning != flat_chroma_tuning
|
||||
|
||||
# Check the flattened tunings report as not calibrated
|
||||
assert not tf_utils.lst_calibrated(flat_tuning)
|
||||
assert not tf_utils.lst_calibrated(flat_chroma_tuning)
|
||||
|
||||
flat_lst_model = tf_utils.get_lst(flat_tuning)
|
||||
flat_chroma_lst_model = tf_utils.get_lst(flat_chroma_tuning)
|
||||
|
||||
flat_array = np.ones((12, 16))
|
||||
|
||||
# Check the that it really was flattened for the flat tuning
|
||||
_assert_lst_table_equivalence(flat_array, flat_lst_model.luminance)
|
||||
_assert_lst_table_equivalence(flat_array, flat_lst_model.Cr)
|
||||
_assert_lst_table_equivalence(flat_array, flat_lst_model.Cb)
|
||||
|
||||
# Check only colour channels flattened when keep_luminance=True
|
||||
_assert_lst_table_equivalence(lum, flat_chroma_lst_model.luminance)
|
||||
_assert_lst_table_equivalence(flat_array, flat_chroma_lst_model.Cr)
|
||||
_assert_lst_table_equivalence(flat_array, flat_chroma_lst_model.Cb)
|
||||
|
||||
|
||||
def test_setting_ccm(imx219_tuning):
|
||||
"""Check the colour correction matrix can be set."""
|
||||
# CCM is set as a flat list. Create a mock ccm
|
||||
new_ccm = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]
|
||||
|
||||
updated_tuning = tf_utils.set_ccm(imx219_tuning, new_ccm)
|
||||
|
||||
# Check it wasn't modified in place.
|
||||
assert updated_tuning != imx219_tuning
|
||||
|
||||
# Check the value was set
|
||||
assert tf_utils.get_ccm(updated_tuning) == new_ccm
|
||||
|
||||
# Check that our dictionary doesn't update if the input list is updated.
|
||||
new_ccm[0] = 0.0
|
||||
assert tf_utils.get_ccm(updated_tuning) != new_ccm
|
||||
|
||||
new_ccm.append(1.0)
|
||||
with pytest.raises(
|
||||
ValueError, match="col_corr_matrix should be a list of 9 floats"
|
||||
):
|
||||
tf_utils.set_ccm(imx219_tuning, new_ccm)
|
||||
|
||||
|
||||
def test_green_equalisation(imx219_tuning):
|
||||
"""Test setting the offset parameter of green equalisation."""
|
||||
assert tf_utils.geq_is_static(imx219_tuning)
|
||||
bad_geq_tuning = tf_utils.set_static_geq(imx219_tuning, offset=1234)
|
||||
# Check it wasn't modified in place.
|
||||
assert bad_geq_tuning != imx219_tuning
|
||||
|
||||
# No longer static
|
||||
assert not tf_utils.geq_is_static(bad_geq_tuning)
|
||||
|
||||
fixed_geq_tuning = tf_utils.set_static_geq(bad_geq_tuning)
|
||||
|
||||
assert tf_utils.geq_is_static(fixed_geq_tuning)
|
||||
|
||||
|
||||
def test_ce_enable(imx219_tuning):
|
||||
"""Check the setting of ce enable in the contrast algorithm."""
|
||||
assert tf_utils.ce_enable_is_static(imx219_tuning)
|
||||
|
||||
# No way to enable it so do it manually
|
||||
bad_tuning = deepcopy(imx219_tuning)
|
||||
contrast = tf_utils.find_tuning_algo(bad_tuning, "rpi.contrast")
|
||||
contrast["ce_enable"] = 1
|
||||
|
||||
assert not tf_utils.ce_enable_is_static(bad_tuning)
|
||||
|
||||
fixed_tuning = tf_utils.set_ce_to_disabled(bad_tuning)
|
||||
assert tf_utils.ce_enable_is_static(fixed_tuning)
|
||||
|
||||
|
||||
def test_copying_algorithms(imx219_tuning):
|
||||
"""Check an algorithm can be copied from one file to another."""
|
||||
# Update the CCM
|
||||
new_ccm = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]
|
||||
updated_ccm_tuning = tf_utils.set_ccm(imx219_tuning, new_ccm)
|
||||
# It has changed
|
||||
assert updated_ccm_tuning != imx219_tuning
|
||||
|
||||
# Create another tuning file with the GEQ updated.
|
||||
updated_geq_tuning = tf_utils.set_static_geq(imx219_tuning, offset=1234)
|
||||
# It has changed
|
||||
assert updated_geq_tuning != imx219_tuning
|
||||
|
||||
# Copy the CCM from the dict with the bad GEQ value into the one with the updated
|
||||
# CCM
|
||||
original_tuning = tf_utils.copy_algo_from_other_tuning(
|
||||
"rpi.ccm", base_tuning_file=updated_ccm_tuning, copy_from=updated_geq_tuning
|
||||
)
|
||||
|
||||
# This should now be equivalent to the original.
|
||||
assert original_tuning == imx219_tuning
|
||||
80
tests/unit_tests/test_sangaboard.py
Normal file
80
tests/unit_tests/test_sangaboard.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Tests for the Sangaboard thing."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from labthings_fastapi.testing import create_thing_without_server
|
||||
|
||||
from openflexure_microscope_server.things.stage.sangaboard import (
|
||||
RECOMMENDED_VERSION,
|
||||
REQUIRED_VERSION,
|
||||
SangaboardThing,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sanga_thing(mocker):
|
||||
"""Return a Sangaboard thing with a MagicMock for self._sangaboard."""
|
||||
sanga_thing = create_thing_without_server(SangaboardThing)
|
||||
sanga_thing._sangaboard = mocker.MagicMock()
|
||||
return sanga_thing
|
||||
|
||||
|
||||
def test_check_old_firmware(mock_sanga_thing):
|
||||
"""Check firmware prior to version 1 throws a Runtime Error."""
|
||||
mock_sanga_thing._sangaboard.firmware_version = "0.1.1"
|
||||
with pytest.raises(RuntimeError):
|
||||
mock_sanga_thing.check_firmware()
|
||||
|
||||
|
||||
def test_firmware_before_recommended(mock_sanga_thing, caplog):
|
||||
"""Check required version warns if it is lower than the recommended version."""
|
||||
if REQUIRED_VERSION == RECOMMENDED_VERSION:
|
||||
# If required is currently the same as recommended then this test isn't valid
|
||||
return
|
||||
mock_sanga_thing._sangaboard.firmware_version = str(REQUIRED_VERSION)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
mock_sanga_thing.check_firmware()
|
||||
assert len(caplog.records) == 1
|
||||
msg = (
|
||||
f"Sangaboard firmware version {REQUIRED_VERSION} is below the recommended "
|
||||
f"{RECOMMENDED_VERSION}."
|
||||
)
|
||||
assert caplog.records[0].message == msg
|
||||
|
||||
|
||||
def test_dev_is_before_recommended(mock_sanga_thing, caplog):
|
||||
"""Check that a dev version of the recommended version warns."""
|
||||
if REQUIRED_VERSION == RECOMMENDED_VERSION:
|
||||
# If required is currently the same as recommended then this test isn't valid
|
||||
return
|
||||
dev_version = RECOMMENDED_VERSION.replace(prerelease="dev1")
|
||||
mock_sanga_thing._sangaboard.firmware_version = str(dev_version)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
mock_sanga_thing.check_firmware()
|
||||
assert len(caplog.records) == 1
|
||||
msg = (
|
||||
f"Sangaboard firmware version {dev_version} is below the recommended "
|
||||
f"{RECOMMENDED_VERSION}."
|
||||
)
|
||||
assert caplog.records[0].message == msg
|
||||
|
||||
|
||||
def test_valid_firmware(mock_sanga_thing, caplog):
|
||||
"""Check valid firmware doesn't error."""
|
||||
versions = [
|
||||
RECOMMENDED_VERSION, # Recommended
|
||||
RECOMMENDED_VERSION.bump_major(), # Higher versions
|
||||
RECOMMENDED_VERSION.bump_minor(),
|
||||
RECOMMENDED_VERSION.bump_patch(),
|
||||
RECOMMENDED_VERSION.bump_patch().replace(prerelease="dev1"),
|
||||
]
|
||||
# Note that dev1 will not warn in check version, but will warn in the underlying
|
||||
# Sangaboard library as it is a pre-release.
|
||||
|
||||
for version in versions:
|
||||
mock_sanga_thing._sangaboard.firmware_version = str(version)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
mock_sanga_thing.check_firmware()
|
||||
assert len(caplog.records) == 0
|
||||
125
tests/unit_tests/test_scan_data.py
Normal file
125
tests/unit_tests/test_scan_data.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Test the functionality in the scan_directories module."""
|
||||
|
||||
import json
|
||||
from copy import copy
|
||||
from datetime import datetime, timedelta
|
||||
from math import floor
|
||||
|
||||
from openflexure_microscope_server.scan_directories import ScanData
|
||||
|
||||
MOCK_START_TIME = datetime(
|
||||
year=2024,
|
||||
month=12,
|
||||
day=25,
|
||||
hour=11,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=1234,
|
||||
)
|
||||
|
||||
MOCK_END_TIME = datetime(
|
||||
year=2024,
|
||||
month=12,
|
||||
day=25,
|
||||
hour=12,
|
||||
minute=29,
|
||||
second=3,
|
||||
microsecond=5678,
|
||||
)
|
||||
|
||||
|
||||
def _fake_scan_data(**kwargs) -> ScanData:
|
||||
"""Make fake scan data, the start time is now. Final properties are not added.
|
||||
|
||||
:param **kwargs: Key word arguments can be used to override other values.
|
||||
"""
|
||||
data_dict = {
|
||||
"scan_name": "fake_scan_0001",
|
||||
"starting_position": {"x": 123, "y": 456, "z": 789},
|
||||
"overlap": 0.1,
|
||||
"max_dist": 100000,
|
||||
"dx": 100,
|
||||
"dy": 100,
|
||||
"autofocus_dz": 1000,
|
||||
"autofocus_on": True,
|
||||
"start_time": copy(MOCK_START_TIME),
|
||||
"skip_background": True,
|
||||
"stitch_automatically": True,
|
||||
"correlation_resize": 0.25,
|
||||
"save_resolution": (1000, 1000),
|
||||
}
|
||||
for key, value in kwargs.items():
|
||||
data_dict[key] = value
|
||||
return ScanData(**data_dict)
|
||||
|
||||
|
||||
def test_set_final_data():
|
||||
"""Check that adding final data to a ScanData object works as expected."""
|
||||
scan_data = _fake_scan_data()
|
||||
|
||||
assert scan_data.image_count == 0
|
||||
assert scan_data.duration is None
|
||||
assert scan_data.scan_result is None
|
||||
|
||||
# Quickly take 123 images!
|
||||
scan_data.image_count += 123
|
||||
# Should set duration based on finishing at datetime.now()
|
||||
scan_data.set_final_data(result="Success")
|
||||
expected_duration = datetime.now() - MOCK_START_TIME
|
||||
expected_duration_s = expected_duration.total_seconds()
|
||||
scan_duration_s = scan_data.duration.total_seconds()
|
||||
|
||||
assert scan_data.image_count == 123
|
||||
assert expected_duration_s - 1 < scan_duration_s < expected_duration_s + 1
|
||||
assert scan_data.scan_result == "Success"
|
||||
|
||||
|
||||
def test_custom_serialisation():
|
||||
"""Check that the custom serialisation in ScanData works as expected."""
|
||||
scan_data = _fake_scan_data()
|
||||
# Serialise to string then load directly as json
|
||||
scan_data_dict = json.loads(scan_data.model_dump_json())
|
||||
assert scan_data_dict["start_time"] == "2024-12-25_11:00:00"
|
||||
assert scan_data_dict["image_count"] == 0
|
||||
assert scan_data_dict["duration"] == "Unknown"
|
||||
assert scan_data_dict["scan_result"] == "Unknown"
|
||||
|
||||
scan_data.image_count += 123
|
||||
scan_data.set_final_data(result="Success")
|
||||
# Can't mock datetime.now as datetime is immutable. So just replace duration
|
||||
scan_data.duration = MOCK_END_TIME - scan_data.start_time
|
||||
scan_data_dict = json.loads(scan_data.model_dump_json())
|
||||
assert scan_data_dict["image_count"] == 123
|
||||
assert scan_data_dict["duration"] == "1:29:03"
|
||||
assert scan_data_dict["scan_result"] == "Success"
|
||||
|
||||
|
||||
def test_round_trip_not_finalised():
|
||||
"""Check that ScanData without final data can be serialised and deserialised."""
|
||||
scan_data = _fake_scan_data()
|
||||
scan_data_dict = json.loads(scan_data.model_dump_json())
|
||||
scan_data_reloaded = ScanData(**scan_data_dict)
|
||||
|
||||
# For the round trip to be equal we must remove microseconds from the start
|
||||
# time as they are not saved
|
||||
scan_data.start_time = scan_data.start_time.replace(microsecond=0)
|
||||
assert scan_data == scan_data_reloaded
|
||||
|
||||
|
||||
def test_round_trip_finalised():
|
||||
"""Check that finalised ScanData can be serialised and deserialised."""
|
||||
scan_data = _fake_scan_data()
|
||||
# Finalise the data.
|
||||
scan_data.image_count += 123
|
||||
scan_data.set_final_data(result="Success")
|
||||
|
||||
scan_data_dict = json.loads(scan_data.model_dump_json())
|
||||
scan_data_reloaded = ScanData(**scan_data_dict)
|
||||
|
||||
# For the round trip to be equal we must remove microseconds from the start
|
||||
# time and duration as they are not saved
|
||||
scan_data.model_copy(update={})
|
||||
scan_data.start_time = scan_data.start_time.replace(microsecond=0)
|
||||
scan_data.duration = timedelta(seconds=floor(scan_data.duration.total_seconds()))
|
||||
|
||||
assert scan_data == scan_data_reloaded
|
||||
617
tests/unit_tests/test_scan_directories.py
Normal file
617
tests/unit_tests/test_scan_directories.py
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
"""Test the functionality in the scan_directories module."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from collections import namedtuple
|
||||
|
||||
import pytest
|
||||
|
||||
from openflexure_microscope_server.scan_directories import (
|
||||
SCAN_DATA_FILENAME,
|
||||
NotEnoughFreeSpaceError,
|
||||
ScanDirectory,
|
||||
ScanDirectoryManager,
|
||||
ScanInfo,
|
||||
get_files_in_zip,
|
||||
)
|
||||
|
||||
from .test_scan_data import _fake_scan_data
|
||||
from .utilities import assert_unique_of_length
|
||||
|
||||
# Use our own dir in the root temp dir not a dynamically generated one so we
|
||||
# have some control of when it is deleted
|
||||
BASE_SCAN_DIR = os.path.join(tempfile.gettempdir(), "scans")
|
||||
|
||||
|
||||
def _clear_scan_dir() -> None:
|
||||
"""Delete the scan dir."""
|
||||
if os.path.exists(BASE_SCAN_DIR):
|
||||
shutil.rmtree(BASE_SCAN_DIR)
|
||||
|
||||
|
||||
def _add_fake_image(scan_dir: ScanDirectory) -> None:
|
||||
"""Make a fake image on disk in the scan directory."""
|
||||
unique = False
|
||||
while not unique:
|
||||
x_pos = random.randint(-10000, 10000)
|
||||
y_pos = random.randint(-10000, 10000)
|
||||
filename = f"image_{x_pos}_{y_pos}.jpg"
|
||||
filepath = os.path.join(scan_dir.images_dir, filename)
|
||||
unique = not os.path.exists(filepath)
|
||||
with open(filepath, "w") as f_obj:
|
||||
f_obj.write("fake")
|
||||
|
||||
|
||||
def _add_fake_file(
|
||||
scan_dir: ScanDirectory, filename: str, in_im_dir: bool = False
|
||||
) -> None:
|
||||
"""Make a fake file on disk in the scan directory.
|
||||
|
||||
:param in_im_dir: Boolean, if set True the fake file is created in the images
|
||||
directory of the scan not the root directory.
|
||||
"""
|
||||
if in_im_dir:
|
||||
filepath = os.path.join(scan_dir.images_dir, filename)
|
||||
else:
|
||||
filepath = os.path.join(scan_dir.dir_path, filename)
|
||||
with open(filepath, "w") as f_obj:
|
||||
f_obj.write("fake")
|
||||
|
||||
|
||||
def _make_fake_dzi(scan_dir: ScanDirectory, n_layers: int = 8) -> None:
|
||||
"""Create a fake DZI in a scan.
|
||||
|
||||
:param n_layers: The number of layers of tiles. I.e. tile directories numbered
|
||||
0...(n_layers-1) will be created. Default 8
|
||||
"""
|
||||
# Add an a dzi image
|
||||
dzi_fname = scan_dir.name + ".dzi"
|
||||
dzi_path = os.path.join(scan_dir.images_dir, dzi_fname)
|
||||
with open(dzi_path, "w") as f_obj:
|
||||
f_obj.write("This should be xml")
|
||||
|
||||
# and the directory for the dzi tiles
|
||||
dzi_tile_dir = scan_dir.name + "_files"
|
||||
dzi_tile_dir_path = os.path.join(scan_dir.images_dir, dzi_tile_dir)
|
||||
os.makedirs(dzi_tile_dir_path)
|
||||
|
||||
# this directory then contains a directories numbered from 0-n
|
||||
for i in range(n_layers):
|
||||
layer_dir_path = os.path.join(dzi_tile_dir_path, str(i))
|
||||
os.makedirs(layer_dir_path)
|
||||
|
||||
# Number of images (in each axis) in this layer. Number of tiles doubles each
|
||||
# layer, but the first few layers always have 1 image.
|
||||
n_ims = math.ceil(2 ** (i - 4))
|
||||
for x_index in range(n_ims):
|
||||
for y_index in range(n_ims):
|
||||
tile_name = f"{x_index}_{y_index}.jpg"
|
||||
tile_path = os.path.join(layer_dir_path, tile_name)
|
||||
with open(tile_path, "w") as f_obj:
|
||||
f_obj.write("This would normally be jpeg data")
|
||||
|
||||
|
||||
def test_basic_directory_operations():
|
||||
"""Test some basic operations.
|
||||
|
||||
Test some basic operations, including:
|
||||
- ScanDirectoryManager creates a scan directory
|
||||
- For a fake (dummy) scan, a path can be created and retrieved
|
||||
- For a fake (dummy) file, a path can be created and retrieved (for both a .zip and .img file)
|
||||
- When a scan directory is created, the directory exists as does the image directory
|
||||
- Scan directories can be deleted and scans can be cleared
|
||||
"""
|
||||
_clear_scan_dir()
|
||||
assert not os.path.isdir(BASE_SCAN_DIR)
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
# Check it makes the scan directory
|
||||
assert os.path.isdir(BASE_SCAN_DIR)
|
||||
|
||||
# Considering a fake scan
|
||||
scan_name = "fake_scan_0001"
|
||||
scan_path = os.path.join(BASE_SCAN_DIR, scan_name)
|
||||
scan_im_dir = os.path.join(BASE_SCAN_DIR, scan_name, "images")
|
||||
|
||||
# It doesn't exist but we can check its paths
|
||||
assert not scan_dir_manager.exists(scan_name)
|
||||
assert not os.path.isdir(scan_path)
|
||||
assert scan_dir_manager.path_for(scan_name) == scan_path
|
||||
assert scan_dir_manager.img_dir_for(scan_name) == scan_im_dir
|
||||
|
||||
# Get the path of a fake file
|
||||
fake_file = scan_dir_manager.get_file_path_from(scan_name, "foo.zip")
|
||||
assert fake_file == os.path.join(scan_path, "foo.zip")
|
||||
# But this is none if we check it exists
|
||||
fake_file = scan_dir_manager.get_file_path_from(
|
||||
scan_name, "foo.zip", check_exists=True
|
||||
)
|
||||
assert fake_file is None
|
||||
|
||||
# Get the path of another fake file
|
||||
fake_file = scan_dir_manager.get_file_path_from_img_dir(scan_name, "bar.img")
|
||||
assert fake_file == os.path.join(scan_im_dir, "bar.img")
|
||||
# But this is none if we check it exists
|
||||
fake_file = scan_dir_manager.get_file_path_from_img_dir(
|
||||
scan_name, "bar.img", check_exists=True
|
||||
)
|
||||
assert fake_file is None
|
||||
|
||||
# Create the dir
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
# This returns a ScanDirectory object
|
||||
assert isinstance(scan_dir, ScanDirectory)
|
||||
# The directory now exists as does the image directory
|
||||
assert scan_dir_manager.exists(scan_name)
|
||||
assert os.path.isdir(scan_path)
|
||||
assert os.path.isdir(scan_im_dir)
|
||||
|
||||
# Test cleanup. Delete the created scan and scan directory
|
||||
scan_dir_manager.delete_scan(scan_name)
|
||||
assert not scan_dir_manager.exists(scan_name)
|
||||
assert not os.path.isdir(scan_path)
|
||||
|
||||
|
||||
def test_bad_scan_names():
|
||||
"""Check scan names with spaces, or worse BASH commands are not allowed."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake scan")
|
||||
assert scan_dir.name == "fake_scan_0001"
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake scan;rm -rf /;")
|
||||
assert scan_dir.name == "fake_scan_rm_-rf____0001"
|
||||
|
||||
|
||||
def test_scan_sequence_and_listing(caplog):
|
||||
"""Check created scans are added in order and listed correctly."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
|
||||
# Create some scan data and mark it as successful to get an end date.
|
||||
scan_data = _fake_scan_data()
|
||||
scan_data.set_final_data(result="Success")
|
||||
# Make 4 scans
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
|
||||
# Check they exist and are numbered sequentially
|
||||
all_scans = scan_dir_manager.all_scans
|
||||
assert_unique_of_length(all_scans, 4)
|
||||
assert "fake_scan_0001" in all_scans
|
||||
assert "fake_scan_0002" in all_scans
|
||||
assert "fake_scan_0003" in all_scans
|
||||
assert "fake_scan_0004" in all_scans
|
||||
|
||||
# Start capturing warnings
|
||||
with caplog.at_level(logging.WARNING):
|
||||
# Check scan data can be read for all of them
|
||||
# (more detailed scan_info tests below)
|
||||
all_scan_info = scan_dir_manager.all_scans_info()
|
||||
for scan_info in all_scan_info:
|
||||
assert isinstance(scan_info, ScanInfo)
|
||||
assert scan_info.name.startswith("fake_scan_000")
|
||||
assert scan_info.number_of_images == 0
|
||||
assert scan_info.duration is not None
|
||||
# There are no warnings or errors
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
# Mess up the JSON of the final scan
|
||||
with open(scan_dir.scan_data_path, "w", encoding="utf-8") as f:
|
||||
f.write("Mock JSON")
|
||||
|
||||
# Re-read the data with bad data for fake_scan_0004
|
||||
all_scan_info = scan_dir_manager.all_scans_info()
|
||||
for scan_info in all_scan_info:
|
||||
assert isinstance(scan_info, ScanInfo)
|
||||
assert scan_info.name.startswith("fake_scan_000")
|
||||
assert scan_info.number_of_images == 0
|
||||
# Bad data for scan 0004. So duration is None.
|
||||
if scan_info.name == "fake_scan_0004":
|
||||
assert scan_info.duration is None
|
||||
else:
|
||||
assert scan_info.duration is not None
|
||||
# This should have warned about the bad data
|
||||
assert len(caplog.records) == 1
|
||||
assert (
|
||||
caplog.records[0].message == "Could not load scan data for fake_scan_0004."
|
||||
)
|
||||
|
||||
# Clear the warning logs
|
||||
caplog.clear()
|
||||
# Re-read the data again claiming fake_scan_0004 is ongoing
|
||||
all_scan_info = scan_dir_manager.all_scans_info(ongoing="fake_scan_0004")
|
||||
for scan_info in all_scan_info:
|
||||
assert isinstance(scan_info, ScanInfo)
|
||||
assert scan_info.name.startswith("fake_scan_000")
|
||||
assert scan_info.number_of_images == 0
|
||||
# scan 0004 is marked as ongoing, so duration is None.
|
||||
if scan_info.name == "fake_scan_0004":
|
||||
assert scan_info.duration is None
|
||||
else:
|
||||
assert scan_info.duration is not None
|
||||
# This time there is no warning
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
|
||||
def test_scan_name_non_sequential():
|
||||
"""Check new scan has the correct name if the directories are not sequential."""
|
||||
_clear_scan_dir()
|
||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0001"))
|
||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0002"))
|
||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0003"))
|
||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0005"))
|
||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0007"))
|
||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0011"))
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
assert scan_dir.name == "fake_scan_0012"
|
||||
|
||||
all_scans = scan_dir_manager.all_scans
|
||||
assert_unique_of_length(all_scans, 7)
|
||||
assert "fake_scan_0001" in all_scans
|
||||
assert "fake_scan_0002" in all_scans
|
||||
assert "fake_scan_0003" in all_scans
|
||||
assert "fake_scan_0005" in all_scans
|
||||
assert "fake_scan_0007" in all_scans
|
||||
assert "fake_scan_0011" in all_scans
|
||||
assert "fake_scan_0012" in all_scans
|
||||
|
||||
|
||||
def test_all_scan_names_taken():
|
||||
"""If the next sequential scan name needs more than 4 digits check error is thrown."""
|
||||
_clear_scan_dir()
|
||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_9999"))
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
with pytest.raises(FileExistsError):
|
||||
scan_dir_manager.new_scan_dir("fake_scan")
|
||||
|
||||
|
||||
def test_no_scan_names_given():
|
||||
"""Check correct default scan name is used if empty string is given."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("")
|
||||
assert scan_dir.name == "scan_0001"
|
||||
|
||||
|
||||
def test_scan_info():
|
||||
"""Test the scan info is correct even using fake scan data."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
|
||||
for _i in range(17):
|
||||
_add_fake_image(scan_dir)
|
||||
info = scan_dir.scan_info()
|
||||
now = time.time()
|
||||
|
||||
assert info.name == "fake_scan_0001"
|
||||
# Created and modified in the last 5 seconds
|
||||
assert now - 5 < info.created < now
|
||||
assert now - 5 < info.modified < now
|
||||
assert info.number_of_images == 17
|
||||
assert not info.stitch_available
|
||||
assert info.dzi is None
|
||||
|
||||
# Add a fake stitched images and check this is recognised as a stitch not
|
||||
# a scan image
|
||||
_add_fake_file(scan_dir, "fake_scan_0001_stitched.jpg", in_im_dir=True)
|
||||
info = scan_dir.scan_info()
|
||||
assert info.number_of_images == 17
|
||||
assert info.stitch_available
|
||||
|
||||
|
||||
def test_get_final_stitch():
|
||||
"""Check that the final stitch can be retrieved."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
|
||||
# Create some scan images files
|
||||
for _i in range(17):
|
||||
_add_fake_image(scan_dir)
|
||||
|
||||
# No scans, so None should be returned, from both manager and scan dir
|
||||
assert scan_dir_manager.get_final_stitch_path(scan_dir.name) is None
|
||||
assert scan_dir.get_final_stitch_name() is None
|
||||
|
||||
fake_scan_name = "fake_scan_0001_stitched.jpg"
|
||||
fake_scan_path = scan_dir_manager.get_file_path_from_img_dir(
|
||||
scan_name=scan_dir.name,
|
||||
filename="fake_scan_0001_stitched.jpg",
|
||||
check_exists=False,
|
||||
)
|
||||
# Add a fake scan
|
||||
_add_fake_file(scan_dir, fake_scan_name, in_im_dir=True)
|
||||
# Manager returns full path
|
||||
assert scan_dir_manager.get_final_stitch_path(scan_dir.name) == fake_scan_path
|
||||
# ScanDirectory object returns just the filename
|
||||
assert scan_dir.get_final_stitch_name() == fake_scan_name
|
||||
|
||||
|
||||
def test_get_scan_data_path():
|
||||
"""Check that a scan data path behaves as expected."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
scan_name = scan_dir.name
|
||||
expected_path = os.path.join(scan_dir.images_dir, SCAN_DATA_FILENAME)
|
||||
# Asking the scan manager for the path will return None as there is no file.
|
||||
assert scan_dir_manager.get_scan_data_path(scan_name) is None
|
||||
# Asking the scan directly will return the location the file should be located
|
||||
# this is so it can be created.
|
||||
assert scan_dir.scan_data_path == expected_path
|
||||
|
||||
# Remove the images directory.
|
||||
shutil.rmtree(scan_dir.images_dir)
|
||||
# When the images directory is deleted, internally `scan_dir.images_dir`
|
||||
# will return `None`. Check that `get_scan_data_path` copes with the `None`
|
||||
# return from `scan_dir.images_dir`.
|
||||
assert scan_dir_manager.get_scan_data_path(scan_name) is None
|
||||
|
||||
|
||||
def test_get_scan_data_dict():
|
||||
"""Check that the dictionary for the scan data is returned, or None if doesn't exist."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
scan_name = scan_dir.name
|
||||
# Doesn't yet exist
|
||||
assert scan_dir_manager.get_scan_data_dict(scan_name) is None
|
||||
|
||||
fake_data = {"foo": 1, "bar": "foobar"}
|
||||
with open(scan_dir.scan_data_path, "w", encoding="utf-8") as json_file:
|
||||
json.dump(fake_data, json_file)
|
||||
|
||||
# Should now be able to load this fake data from disk
|
||||
assert scan_dir_manager.get_scan_data_dict(scan_name) == fake_data
|
||||
|
||||
# Check None is returned if the data cannot be read.
|
||||
with open(scan_dir.scan_data_path, "w", encoding="utf-8") as json_file:
|
||||
json_file.write("this is not json")
|
||||
assert scan_dir_manager.get_scan_data_dict(scan_name) is None
|
||||
|
||||
|
||||
def test_empty_scan_info():
|
||||
"""Test the scan info is correct even if the scan is empty."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
|
||||
info = scan_dir.scan_info()
|
||||
now = time.time()
|
||||
|
||||
assert info.name == "fake_scan_0001"
|
||||
# Created and modified in the last 5 seconds
|
||||
assert now - 5 < info.created < now
|
||||
assert now - 5 < info.modified < now
|
||||
assert info.number_of_images == 0
|
||||
assert not info.stitch_available
|
||||
assert info.dzi is None
|
||||
|
||||
|
||||
def test_zipping_scan_data():
|
||||
"""Test zipping the scan images with fake image data."""
|
||||
# Run twice, once calling the ScanDirectory directly,
|
||||
# Once calling the ScanDirectoryManager
|
||||
for caller in ["scan_dir", "manager"]:
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
|
||||
# Create 21 fake scan images, a fake stitch, and a fake zip
|
||||
for _i in range(21):
|
||||
_add_fake_image(scan_dir)
|
||||
_add_fake_file(scan_dir, "fake_scan_0001_stitched.jpg", in_im_dir=True)
|
||||
_add_fake_file(scan_dir, "zipfile.zip")
|
||||
|
||||
# This fake dzi should have loads of images. The DZI should not be zipped!
|
||||
_make_fake_dzi(scan_dir)
|
||||
|
||||
# zip the directory without setting as the final version. It should only
|
||||
# zip the 21 scan images
|
||||
if caller == "scan_dir":
|
||||
zip_fname = scan_dir.zip_files()
|
||||
else:
|
||||
zip_fname = scan_dir_manager.zip_scan("fake_scan_0001")
|
||||
zip_files = get_files_in_zip(zip_fname)
|
||||
assert_unique_of_length(zip_files, 21)
|
||||
|
||||
# Zip again with final version on and there should be 22 images
|
||||
if caller == "scan_dir":
|
||||
scan_dir.zip_files(final_version=True)
|
||||
else:
|
||||
scan_dir_manager.zip_scan("fake_scan_0001", final_version=True)
|
||||
zip_files = get_files_in_zip(zip_fname)
|
||||
assert_unique_of_length(get_files_in_zip(zip_fname), 22)
|
||||
# Check the zips are not in the zip
|
||||
for file in zip_files:
|
||||
assert not file.endswith(".zip")
|
||||
assert not file.endswith(".dzi")
|
||||
|
||||
|
||||
def test_saving_and_loading_scan_data():
|
||||
"""Test that scan data is saved and loaded as expected."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
scan_name = scan_dir.name
|
||||
|
||||
# Should start without a scan data file.
|
||||
assert not os.path.isfile(scan_dir.scan_data_path)
|
||||
# Create
|
||||
scan_data_obj = _fake_scan_data()
|
||||
scan_dir.save_scan_data(scan_data_obj)
|
||||
# File should now exist
|
||||
assert os.path.isfile(scan_dir.scan_data_path)
|
||||
|
||||
# Dump the scan json to a string an reload it
|
||||
# Note that more detailed checking of the dumping and loading of ScanData is in
|
||||
# tests/test_scan_data.py
|
||||
scan_data_dict = json.loads(scan_data_obj.model_dump_json())
|
||||
# What is loaded from file should be the same as from dumping and loading.
|
||||
assert scan_dir_manager.get_scan_data_dict(scan_name) == scan_data_dict
|
||||
|
||||
|
||||
def test_saving_scan_data_error():
|
||||
"""Test that saving scan data if there is no images directory raises FileNotFoundError."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
|
||||
# Remove the images directory.
|
||||
shutil.rmtree(scan_dir.images_dir)
|
||||
# Should raise FileNotFoundError.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
scan_dir.save_scan_data(_fake_scan_data())
|
||||
|
||||
|
||||
def test_all_files():
|
||||
"""Test all_files returns the path, and respects skipped directories."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
# This fake dzi should have loads of images. The DZI should not be zipped!
|
||||
_make_fake_dzi(scan_dir)
|
||||
all_files = scan_dir.all_files()
|
||||
|
||||
# As standard for 8 layers there are 89 jpegs and 1 dzi file.
|
||||
assert_unique_of_length(all_files, 90)
|
||||
dzi_file = None
|
||||
# Check all files exist
|
||||
for file in all_files:
|
||||
assert os.path.exists(os.path.join(scan_dir.dir_path, file))
|
||||
if file.endswith(".dzi"):
|
||||
# There is only 1 dzi file, so this should be None
|
||||
assert dzi_file is None
|
||||
dzi_file = file
|
||||
# Once loop is complete there should be a dzi file
|
||||
assert dzi_file is not None
|
||||
# Get the dzi tile directory name
|
||||
dzi_dir = os.path.basename(dzi_file[:-4] + "_files")
|
||||
|
||||
# Get all files skipping the dzi tile directory
|
||||
all_files = scan_dir.all_files(skip_dirs=dzi_dir)
|
||||
# There is now only one file
|
||||
assert len(all_files) == 1
|
||||
# It is the DZI file
|
||||
assert all_files[0] == dzi_file
|
||||
|
||||
|
||||
def test_creating_scan_dir_for_missing_scan():
|
||||
"""Check creating ScanDirectory object for a dir that doesn't exist fails."""
|
||||
_clear_scan_dir()
|
||||
with pytest.raises(FileNotFoundError):
|
||||
ScanDirectory("not_real_0001", BASE_SCAN_DIR)
|
||||
|
||||
|
||||
def test_none_returned_for_missing_images_dir():
|
||||
"""None should be returned for image dir path if images dir does not exist.
|
||||
|
||||
By default images directories are created at the same time the scan directory
|
||||
is created. However, edge cases such as problems in deletions, microscopes
|
||||
with older scans on, etc can cause and empty scan directory, so it is handled
|
||||
explicitly.
|
||||
"""
|
||||
_clear_scan_dir()
|
||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0001"))
|
||||
scan_dir = ScanDirectory("fake_scan_0001", BASE_SCAN_DIR)
|
||||
assert scan_dir.images_dir is None
|
||||
# Also check that get scan files returns and empty list
|
||||
assert scan_dir.get_scan_files() == []
|
||||
|
||||
|
||||
def test_extracting_files():
|
||||
"""Test the private _find_files method of ScanDirectories.
|
||||
|
||||
Add files to directory and check expected returns.
|
||||
"""
|
||||
_clear_scan_dir()
|
||||
os.makedirs(os.path.join(BASE_SCAN_DIR, "fake_scan_0001", "images"))
|
||||
scan_dir = ScanDirectory("fake_scan_0001", BASE_SCAN_DIR)
|
||||
|
||||
# Starting all lists should be empty
|
||||
scan_files = scan_dir.get_scan_files()
|
||||
assert scan_dir._extract_scan_images(scan_files) == []
|
||||
assert scan_dir._extract_final_stitches(scan_files) == []
|
||||
assert scan_dir._extract_dzi_files(scan_files) == []
|
||||
|
||||
# Add a number of images
|
||||
for _i in range(2321):
|
||||
_add_fake_image(scan_dir)
|
||||
|
||||
scan_files = scan_dir.get_scan_files()
|
||||
assert_unique_of_length(scan_dir._extract_scan_images(scan_files), 2321)
|
||||
assert_unique_of_length(scan_dir._extract_final_stitches(scan_files), 0)
|
||||
assert_unique_of_length(scan_dir._extract_dzi_files(scan_files), 0)
|
||||
|
||||
# Add and a stitched image
|
||||
_add_fake_file(scan_dir, "fake_scan_0001_stitched.jpg", in_im_dir=True)
|
||||
|
||||
scan_files = scan_dir.get_scan_files()
|
||||
assert_unique_of_length(scan_dir._extract_scan_images(scan_files), 2321)
|
||||
assert_unique_of_length(scan_dir._extract_final_stitches(scan_files), 1)
|
||||
assert_unique_of_length(scan_dir._extract_dzi_files(scan_files), 0)
|
||||
|
||||
_make_fake_dzi(scan_dir)
|
||||
|
||||
# check totals are still correct after adding a dzi with lots of tiles.
|
||||
scan_files = scan_dir.get_scan_files()
|
||||
scan_images = scan_dir._extract_scan_images(scan_files)
|
||||
stitches = scan_dir._extract_final_stitches(scan_files)
|
||||
dzi_files = scan_dir._extract_dzi_files(scan_files)
|
||||
assert_unique_of_length(scan_images, 2321)
|
||||
assert_unique_of_length(stitches, 1)
|
||||
assert_unique_of_length(dzi_files, 1)
|
||||
|
||||
# And check the names are as expected
|
||||
assert stitches[0] == "fake_scan_0001_stitched.jpg"
|
||||
assert dzi_files[0] == "fake_scan_0001.dzi"
|
||||
|
||||
|
||||
DiskUsage = namedtuple("DiskUsage", ["total", "used", "free"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("free_space", [500_000_001, 700_000_000, 5_000_000_000])
|
||||
def test_disk_not_full(mocker, free_space):
|
||||
"""Check no error thrown if disk has over 500MB of space."""
|
||||
total_space = 16_000_000_000
|
||||
# Mock the disk_usage
|
||||
mocker.patch(
|
||||
"shutil.disk_usage",
|
||||
return_value=DiskUsage(
|
||||
total=total_space, used=total_space - free_space, free=free_space
|
||||
),
|
||||
)
|
||||
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
scan_dir_manager.check_free_disk_space()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("free_space", [100_000_000, 499_999_999])
|
||||
def test_disk_full(mocker, free_space):
|
||||
"""Check error thrown if disk has under 500MB of space."""
|
||||
total_space = 16_000_000_000
|
||||
# Mock the disk_usage
|
||||
mocker.patch(
|
||||
"shutil.disk_usage",
|
||||
return_value=DiskUsage(
|
||||
total=total_space, used=total_space - free_space, free=free_space
|
||||
),
|
||||
)
|
||||
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
with pytest.raises(NotEnoughFreeSpaceError):
|
||||
scan_dir_manager.check_free_disk_space()
|
||||
310
tests/unit_tests/test_scan_planners.py
Normal file
310
tests/unit_tests/test_scan_planners.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""Test the scan planning algorithms of the Microscope.
|
||||
|
||||
As well as low level function by function tests, this test suite also provides tests
|
||||
that simulate scanning a sample, checking that the expected path is followed.
|
||||
"""
|
||||
|
||||
from copy import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from openflexure_microscope_server import scan_planners
|
||||
|
||||
from .utilities import scan_test_helpers
|
||||
|
||||
|
||||
def test_enforce_xy_tuple():
|
||||
"""Check that 2 value tuples (or ValueErrors) are always returned."""
|
||||
bad_len_vals = [[1], [], (1,), (2, 4, 4), [1, 4, 5, 7]]
|
||||
for value in bad_len_vals:
|
||||
with pytest.raises(ValueError, match="2 value tuple expected"):
|
||||
scan_planners.enforce_xy_tuple(value)
|
||||
|
||||
bad_type_vals = ["hi", 1, {"this": "that"}, {1, 2}]
|
||||
for value in bad_type_vals:
|
||||
with pytest.raises(TypeError):
|
||||
scan_planners.enforce_xy_tuple(value)
|
||||
|
||||
assert scan_planners.enforce_xy_tuple((1, 6)) == (1, 6)
|
||||
assert scan_planners.enforce_xy_tuple([1, 6]) == (1, 6)
|
||||
|
||||
|
||||
def test_enforce_xyz_tuple():
|
||||
"""Check that 3 value tuples (or ValueErrors) are always returned."""
|
||||
bad_len_vals = [[1], [], (1,), (2, 4), [1, 4, 5, 7]]
|
||||
for value in bad_len_vals:
|
||||
with pytest.raises(ValueError, match="3 value tuple expected"):
|
||||
scan_planners.enforce_xyz_tuple(value)
|
||||
|
||||
bad_type_vals = ["hi!", 1, {"this": "that"}, {1, 2, 3}]
|
||||
for value in bad_type_vals:
|
||||
with pytest.raises(TypeError):
|
||||
scan_planners.enforce_xyz_tuple(value)
|
||||
|
||||
assert scan_planners.enforce_xyz_tuple((1, 6, 2)) == (1, 6, 2)
|
||||
assert scan_planners.enforce_xyz_tuple([1, 6, 6]) == (1, 6, 6)
|
||||
|
||||
|
||||
def test_base_class_not_implemented():
|
||||
"""Check NotImplementedError is raised when initialising ScanPlanner directly."""
|
||||
initial_position = (100, 50)
|
||||
with pytest.raises(NotImplementedError):
|
||||
scan_planners.ScanPlanner(initial_position=initial_position)
|
||||
|
||||
|
||||
def test_v_basic_smart_spiral():
|
||||
"""Check that a SmartSpiral where the first image is not sample completes."""
|
||||
initial_position = (100, 50)
|
||||
planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000}
|
||||
planner = scan_planners.SmartSpiral(
|
||||
initial_position=initial_position, planner_settings=planner_settings
|
||||
)
|
||||
# Create a planner. It shouldn't be complete.
|
||||
assert not planner.scan_complete
|
||||
# When we start it should want to stay in the initial pos and have
|
||||
# no z_estimate
|
||||
xy_pos, z_pos = planner.get_next_location_and_z_estimate()
|
||||
assert xy_pos == initial_position
|
||||
assert z_pos is None
|
||||
|
||||
# Try to mark location as imaged with only xy_position
|
||||
with pytest.raises(ValueError, match="3 value tuple expected"):
|
||||
planner.mark_location_visited(xy_pos, imaged=False, focused=False)
|
||||
# scan still not complete
|
||||
assert not planner.scan_complete
|
||||
# if we mark this position as visited but not imaged
|
||||
planner.mark_location_visited(
|
||||
(xy_pos[0], xy_pos[1], 10), imaged=False, focused=False
|
||||
)
|
||||
# scan is now complete
|
||||
assert planner.scan_complete
|
||||
|
||||
# if scan is complete, asking for the next location returns an error
|
||||
with pytest.raises(RuntimeError):
|
||||
planner.get_next_location_and_z_estimate()
|
||||
|
||||
|
||||
def test_bad_smart_spiral_settings():
|
||||
"""Check that KeyError is raised when SmartSpiral is given bad settings."""
|
||||
initial_position = (100, 50)
|
||||
|
||||
# Class init should raise error if no planner_settings dictionary set
|
||||
msg = "SmartSpiral requires a planner_settings dictionary with keys"
|
||||
with pytest.raises(ValueError, match=msg):
|
||||
scan_planners.SmartSpiral(initial_position=initial_position)
|
||||
|
||||
planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000}
|
||||
keys = ["dx", "dy", "max_dist"]
|
||||
|
||||
# Class init should raise error if planner_settings is missing any key
|
||||
for delkey in keys:
|
||||
bad_planner_settings = copy(planner_settings)
|
||||
del bad_planner_settings[delkey]
|
||||
with pytest.raises(KeyError):
|
||||
scan_planners.SmartSpiral(
|
||||
initial_position=initial_position, planner_settings=bad_planner_settings
|
||||
)
|
||||
|
||||
# Class init should raise error if planner_settings if any value can't be cast
|
||||
# to int
|
||||
keys = ["dx", "dy", "max_dist"]
|
||||
for badkey in keys:
|
||||
bad_planner_settings = copy(planner_settings)
|
||||
bad_planner_settings[badkey] = "I can't be converted to an int"
|
||||
with pytest.raises(ValueError, match="invalid literal for int()"):
|
||||
scan_planners.SmartSpiral(
|
||||
initial_position=initial_position, planner_settings=bad_planner_settings
|
||||
)
|
||||
|
||||
|
||||
def test_smart_spiral_first_few_pos():
|
||||
"""Test for correct data addition during initial scan positions.
|
||||
|
||||
This is a very long test, not strictly a "unit" test. It checks step by step
|
||||
that data is added correctly for the first few positions in a scan. It is
|
||||
intended to catch basic issues if the algorithm is updated.
|
||||
"""
|
||||
initial_position = (100, 50)
|
||||
planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000}
|
||||
# Create a planner
|
||||
planner = scan_planners.SmartSpiral(
|
||||
initial_position=initial_position, planner_settings=planner_settings
|
||||
)
|
||||
# it shouldn't start complete
|
||||
assert not planner.scan_complete
|
||||
# When we start it should want to stay in the initial pos and have
|
||||
# no z_estimate
|
||||
xy_pos1, z_pos1 = planner.get_next_location_and_z_estimate()
|
||||
assert xy_pos1 == initial_position
|
||||
assert z_pos1 is None
|
||||
# Set a focus value
|
||||
z_focus = 10
|
||||
xyz_pos1 = (xy_pos1[0], xy_pos1[1], z_focus)
|
||||
# if we mark this position as visited, imaged, and focused
|
||||
planner.mark_location_visited(xyz_pos1, imaged=True, focused=True)
|
||||
|
||||
# scan is not complete
|
||||
assert not planner.scan_complete
|
||||
|
||||
# Lists should have updated to add the position to histories
|
||||
assert xyz_pos1 in planner.imaged_locations
|
||||
assert xyz_pos1 in planner.focused_locations
|
||||
assert xy_pos1 in planner.path_history
|
||||
|
||||
# remove from planned
|
||||
assert xy_pos1 not in planner.remaining_locations
|
||||
|
||||
# Add 4 new points to planned
|
||||
assert (100, 0) in planner.remaining_locations
|
||||
assert (100, 100) in planner.remaining_locations
|
||||
assert (150, 50) in planner.remaining_locations
|
||||
assert (50, 50) in planner.remaining_locations
|
||||
assert len(planner.remaining_locations) == 4
|
||||
|
||||
# Now the next location is updated
|
||||
xy_pos2, z_pos2 = planner.get_next_location_and_z_estimate()
|
||||
# move in negative x-dir first
|
||||
assert xy_pos2 == (50, 50)
|
||||
# Focus set from closest point
|
||||
assert z_pos2 is z_focus
|
||||
|
||||
xyz_pos2 = (xy_pos2[0], xy_pos2[1], z_focus)
|
||||
# if we mark this position as visited, imaged, and NOT focused
|
||||
planner.mark_location_visited(xyz_pos2, imaged=True, focused=False)
|
||||
|
||||
# Check this position remove from planned
|
||||
assert xy_pos2 not in planner.remaining_locations
|
||||
# Check original position not re-added
|
||||
assert xy_pos1 not in planner.remaining_locations
|
||||
|
||||
# 3 old points still planned
|
||||
assert (100, 0) in planner.remaining_locations
|
||||
assert (100, 100) in planner.remaining_locations
|
||||
assert (150, 50) in planner.remaining_locations
|
||||
# Add 3 new points to planned
|
||||
assert (0, 50) in planner.remaining_locations
|
||||
assert (50, 100) in planner.remaining_locations
|
||||
assert (50, 0) in planner.remaining_locations
|
||||
assert len(planner.remaining_locations) == 6
|
||||
|
||||
# Now the next location is updated
|
||||
xy_pos3, z_pos3 = planner.get_next_location_and_z_estimate()
|
||||
# move in negative y-dir first next
|
||||
assert xy_pos3 == (50, 0)
|
||||
# Focus still set as before
|
||||
assert z_pos3 is z_focus
|
||||
# Check that the closest focus site to pos3 is pos 1 as
|
||||
# pos 2 is not focussed
|
||||
assert planner.closest_focus_site(xy_pos3) == xyz_pos1
|
||||
|
||||
new_z_focus = 20
|
||||
xyz_pos3 = (xy_pos3[0], xy_pos3[1], new_z_focus)
|
||||
# Finally check that if this is focused...
|
||||
planner.mark_location_visited(xyz_pos3, imaged=True, focused=True)
|
||||
# ... then the new 4th point ...
|
||||
xy_pos4, z_pos4 = planner.get_next_location_and_z_estimate()
|
||||
# ...(100, 0)...
|
||||
assert xy_pos4 == (100, 0)
|
||||
# ... and it should get its focus from the lowest neighbouring point
|
||||
assert z_pos4 is z_focus
|
||||
assert planner.closest_focus_site(xy_pos4) == xyz_pos3
|
||||
|
||||
|
||||
def test_smart_spiral_stops_on_max_dist():
|
||||
"""Test that if max distance is reached smart spiral really does stop."""
|
||||
initial_position = (0, 0)
|
||||
planner_settings = {"dx": 100, "dy": 100, "max_dist": 1000}
|
||||
# Create a planner
|
||||
planner = scan_planners.SmartSpiral(
|
||||
initial_position=initial_position, planner_settings=planner_settings
|
||||
)
|
||||
while not planner.scan_complete:
|
||||
xy_pos, _ = planner.get_next_location_and_z_estimate()
|
||||
xyz_pos = (xy_pos[0], xy_pos[1], 0)
|
||||
planner.mark_location_visited(xyz_pos, imaged=True, focused=True)
|
||||
|
||||
# Estimate of radius = 10 scans, pir^2 = 314 images. In reality it works out as
|
||||
# 317
|
||||
assert len(planner.path_history) == 317
|
||||
|
||||
|
||||
def test_mark_wrong_location():
|
||||
"""Check that an error is raised if a scan marks the wrong location as visited."""
|
||||
initial_position = (100, 50)
|
||||
planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000}
|
||||
# Create a planner
|
||||
planner = scan_planners.SmartSpiral(
|
||||
initial_position=initial_position, planner_settings=planner_settings
|
||||
)
|
||||
|
||||
xy_pos, _ = planner.get_next_location_and_z_estimate()
|
||||
# Trying to mark the wrong location as visited raises error
|
||||
wrong_xyz_pos = (xy_pos[0] + 1, xy_pos[1], 0)
|
||||
with pytest.raises(RuntimeError):
|
||||
planner.mark_location_visited(wrong_xyz_pos, imaged=True, focused=True)
|
||||
|
||||
|
||||
def test_closest_focus_with_large_numbers():
|
||||
"""Tests to check that everything works well with huge numbers of steps.
|
||||
|
||||
The number of steps gets very large on the micorscope. But most of the tests
|
||||
above use smaller numbers for clarity.
|
||||
"""
|
||||
initial_position = (0, 0)
|
||||
# Set this up, but we won't use the settings
|
||||
planner_settings = {"dx": 10000, "dy": 10000, "max_dist": 100000}
|
||||
# Create a planner
|
||||
planner = scan_planners.SmartSpiral(
|
||||
initial_position=initial_position, planner_settings=planner_settings
|
||||
)
|
||||
# Directly overwrite the private path history locations list for test
|
||||
|
||||
# For two points 1m points away it should choose the last as they are equal
|
||||
|
||||
planner._path_history = [
|
||||
scan_planners.VisitedScanLocation((1000000, 0, 0), imaged=True, focused=True),
|
||||
scan_planners.VisitedScanLocation((0, 1000000, 0), imaged=True, focused=True),
|
||||
]
|
||||
assert planner.closest_focus_site((0, 0)) == (0, 1000000, 0)
|
||||
# Try similar
|
||||
planner._path_history = [
|
||||
scan_planners.VisitedScanLocation((1234567, 0, 0), imaged=True, focused=True),
|
||||
scan_planners.VisitedScanLocation((-1234567, 0, 0), imaged=True, focused=True),
|
||||
]
|
||||
assert planner.closest_focus_site((0, 0)) == (-1234567, 0, 0)
|
||||
|
||||
# Make the first point 1 step closer
|
||||
planner._path_history = [
|
||||
scan_planners.VisitedScanLocation((1234566, 0, 0), imaged=True, focused=True),
|
||||
scan_planners.VisitedScanLocation((-1234567, 0, 0), imaged=True, focused=True),
|
||||
]
|
||||
assert planner.closest_focus_site((0, 0)) == (1234566, 0, 0)
|
||||
|
||||
|
||||
def test_example_smart_spiral():
|
||||
"""Test the smart spiral scan algorithm on the different sample types.
|
||||
|
||||
The sample types:
|
||||
|
||||
* ``regular``
|
||||
* ``lobed``
|
||||
* ``core"``
|
||||
|
||||
These are defined in scan_test_helpers.load_sample_points
|
||||
|
||||
This will fail if the locations or path between locations visited has changed
|
||||
for any of the samples listed.
|
||||
"""
|
||||
example_samples = [
|
||||
"regular",
|
||||
"lobed",
|
||||
"core",
|
||||
]
|
||||
for sample_name in example_samples:
|
||||
_, planner = scan_test_helpers.example_smart_spiral(sample_name)
|
||||
expected_planner = (
|
||||
scan_test_helpers.get_expected_result_for_example_smart_spiral(sample_name)
|
||||
)
|
||||
|
||||
assert planner.path_history == expected_planner.path_history
|
||||
assert planner.imaged_locations == expected_planner.imaged_locations
|
||||
176
tests/unit_tests/test_serve_static_files.py
Normal file
176
tests/unit_tests/test_serve_static_files.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Test the code that mounts static files to the server, without creating a server."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.responses import FileResponse, RedirectResponse
|
||||
|
||||
from openflexure_microscope_server.server import serve_static_files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_static_dir():
|
||||
"""Return the path of a mock static directory.
|
||||
|
||||
It contains enough files to be recognised as a webapp.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.makedirs(os.path.join(tmpdir, "js"))
|
||||
os.makedirs(os.path.join(tmpdir, "css"))
|
||||
with open(os.path.join(tmpdir, "index.html"), "w", encoding="utf-8") as f_obj:
|
||||
f_obj.write("<mock>mock</mock>")
|
||||
yield tmpdir
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "allow_cache"),
|
||||
[
|
||||
("foo", False),
|
||||
("woff2.foo", False),
|
||||
("strange_file_ending_in_woff2", False),
|
||||
("fontfile.woff2", True),
|
||||
],
|
||||
)
|
||||
def test_add_static_file(filename, allow_cache, mocker):
|
||||
"""Test running add_static_file with both font and non-font files.
|
||||
|
||||
This should creates a wrapped function that returns a FileResponse, this
|
||||
should be have the path on disk and the no cache headers if not a woff2 font.
|
||||
"""
|
||||
mock_app = mocker.Mock()
|
||||
# Get the wrapper function from the mocked decorator
|
||||
wrapper = mock_app.get.return_value
|
||||
serve_static_files.add_static_file(mock_app, fname=filename, folder="bar")
|
||||
|
||||
# It is called once
|
||||
assert mock_app.get.call_count == 1
|
||||
# Check args for the get decorator
|
||||
assert len(mock_app.get.call_args.args) == 1
|
||||
assert mock_app.get.call_args.args[0] == "/" + filename
|
||||
assert len(mock_app.get.call_args.kwargs) == 2
|
||||
assert mock_app.get.call_args.kwargs["response_class"] == FileResponse
|
||||
assert not mock_app.get.call_args.kwargs["include_in_schema"]
|
||||
|
||||
# Checked wrapper and the the returned wrapped function
|
||||
assert wrapper.call_count == 1
|
||||
wrapped = wrapper.call_args.args[0]
|
||||
# the wrapped function should return the file response
|
||||
response = wrapped()
|
||||
assert isinstance(response, FileResponse)
|
||||
assert response.path == os.path.join("bar", filename)
|
||||
# The file response headers always have some standard data, and if the file is
|
||||
# allowed to be cached it should also have the no_cache data
|
||||
for key, value in serve_static_files.NO_CACHE_HEADERS.items():
|
||||
if allow_cache:
|
||||
assert key not in response.headers
|
||||
else:
|
||||
# Check headers that refuse caching are present
|
||||
assert key in response.headers
|
||||
assert response.headers[key] == value
|
||||
|
||||
|
||||
def test_add_static_with_no_static_dir(mocker):
|
||||
"""Test that a FileNotFound error is raised if the static dir does not exist."""
|
||||
mock_app = mocker.Mock()
|
||||
# Mock STATIC_PATH to something that doesn't exist
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.server.serve_static_files.STATIC_PATH",
|
||||
"fakepath",
|
||||
)
|
||||
# Should raise as the mock static dir does not exist
|
||||
with pytest.raises(FileNotFoundError):
|
||||
serve_static_files.add_static_files(mock_app, scans_folder=None)
|
||||
assert mock_app.get.call_count == 0
|
||||
|
||||
|
||||
def test_check_static_dir(mock_static_dir, mocker):
|
||||
"""Test check_static_dir recognises a basic webapp structure."""
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.server.serve_static_files.STATIC_PATH",
|
||||
mock_static_dir,
|
||||
)
|
||||
# First run shouldn't error as the mock dir has enough files.
|
||||
serve_static_files.check_static_dir()
|
||||
|
||||
js_path = os.path.join(mock_static_dir, "js")
|
||||
index_path = os.path.join(mock_static_dir, "index.html")
|
||||
|
||||
# Remove javascript dir and check error.
|
||||
shutil.rmtree(js_path)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
serve_static_files.check_static_dir()
|
||||
# replace javascript dir with file, it should still error.
|
||||
shutil.copyfile(index_path, js_path)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
serve_static_files.check_static_dir()
|
||||
|
||||
# Return it to a folder and check everything works again
|
||||
os.remove(js_path)
|
||||
os.makedirs(js_path)
|
||||
serve_static_files.check_static_dir()
|
||||
|
||||
# But it errors again if index.html is removed.
|
||||
os.remove(index_path)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
serve_static_files.check_static_dir()
|
||||
|
||||
|
||||
def test_add_static_files(mock_static_dir, mocker):
|
||||
"""Check add_static_files mounts the internal webapp dirs, and sets endpoints for files."""
|
||||
mock_app = mocker.Mock()
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.server.serve_static_files.STATIC_PATH",
|
||||
mock_static_dir,
|
||||
)
|
||||
# Get the wrapper function from the mocked decorator
|
||||
wrapper = mock_app.get.return_value
|
||||
serve_static_files.add_static_files(mock_app, scans_folder=None)
|
||||
|
||||
# Get should have been called twice to create a route for index
|
||||
assert mock_app.get.call_count == 2
|
||||
# Once at root as a redirict
|
||||
assert mock_app.get.call_args_list[0].args[0] == "/"
|
||||
assert mock_app.get.call_args_list[0].kwargs["response_class"] == RedirectResponse
|
||||
# Once at index.html as a file response
|
||||
assert mock_app.get.call_args_list[1].args[0] == "/index.html"
|
||||
assert mock_app.get.call_args_list[1].kwargs["response_class"] == FileResponse
|
||||
|
||||
# The wrapper was also called twice to wrap two different functions
|
||||
assert wrapper.call_count == 2
|
||||
first_wrapped = wrapper.call_args_list[0].args[0]
|
||||
second_wrapped = wrapper.call_args_list[1].args[0]
|
||||
|
||||
# The first wrapped function is the redirect to "index.html", it is async
|
||||
assert asyncio.run(first_wrapped()) == "/index.html"
|
||||
# The second is the file response for index.html, the path should be to the file on
|
||||
# disk
|
||||
assert second_wrapped().path == os.path.join(mock_static_dir, "index.html")
|
||||
# It shouldn't cache
|
||||
assert second_wrapped().headers["Pragma"] == "no-cache"
|
||||
|
||||
# Also should have mounted both dirs
|
||||
assert mock_app.mount.call_count == 2
|
||||
mounted_paths = [call.args[0] for call in mock_app.mount.call_args_list]
|
||||
mounted_dirs = [call.args[1].directory for call in mock_app.mount.call_args_list]
|
||||
assert "/css/" in mounted_paths
|
||||
assert "/js/" in mounted_paths
|
||||
assert os.path.join(mock_static_dir, "css") in mounted_dirs
|
||||
assert os.path.join(mock_static_dir, "js") in mounted_dirs
|
||||
|
||||
|
||||
def test_add_static_files_with_scan_dir(mock_static_dir, mocker):
|
||||
"""Check the scan dir mounts if supplied."""
|
||||
mock_app = mocker.Mock()
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.server.serve_static_files.STATIC_PATH",
|
||||
mock_static_dir,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as scandir:
|
||||
serve_static_files.add_static_files(mock_app, scans_folder=scandir)
|
||||
# The scan dir should be the last to mount so can use call args. It should mount
|
||||
# at /scans/
|
||||
assert mock_app.mount.call_args.args[0] == "/scans/"
|
||||
assert mock_app.mount.call_args.args[1].directory == scandir
|
||||
104
tests/unit_tests/test_server_cli.py
Normal file
104
tests/unit_tests/test_server_cli.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Test server booting and shut down."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Import as ofm server to attempt to minimise confusion with server as a var in other
|
||||
# functions and also FastAPI `Server`.
|
||||
from openflexure_microscope_server import server as ofm_server
|
||||
|
||||
from .test_server_config import FULL_CONFIG
|
||||
|
||||
|
||||
def test_no_config():
|
||||
"""Check that an error is thrown if no configuration is set for the microspe via CLI."""
|
||||
with pytest.raises(RuntimeError, match="No configuration"):
|
||||
ofm_server.serve_from_cli([])
|
||||
|
||||
|
||||
def test_successful_start(mocker, caplog):
|
||||
"""Check some basic things from the microscope setup.
|
||||
|
||||
This test checks that logging is set up and that the actual function used for
|
||||
handling shutdown events shuts down the mjpeg streams.
|
||||
"""
|
||||
mock_server = mocker.Mock()
|
||||
# Create a mock for the camera so we can check the MJPEG streams are closed on
|
||||
# shutdown.
|
||||
mock_camera = mocker.Mock()
|
||||
mock_server.things = {"camera": mock_camera}
|
||||
# Mock the LabThings function that returns the server so we have a mock server
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.server.lt.ThingServer.from_config",
|
||||
return_value=mock_server,
|
||||
)
|
||||
# Also mock customisation or it will try to access the hard drive and make files.
|
||||
mock_customise = mocker.patch.object(ofm_server, "customise_server")
|
||||
# And mock uvicorn.run as we don't want to start a server process
|
||||
mock_uvicorn_run = mocker.patch("openflexure_microscope_server.server.uvicorn.run")
|
||||
|
||||
# Run the mock CLI
|
||||
ofm_server.serve_from_cli(["-c", FULL_CONFIG])
|
||||
|
||||
# Check that the server was customised and the run
|
||||
assert mock_customise.call_count == 1
|
||||
assert mock_uvicorn_run.call_count == 1
|
||||
# Read the log config that was set
|
||||
log_config = mock_uvicorn_run.call_args.kwargs["log_config"]
|
||||
# Check both uvicorn loggers were set to propagate
|
||||
assert log_config["loggers"]["uvicorn"]["propagate"]
|
||||
assert log_config["loggers"]["uvicorn.access"]["propagate"]
|
||||
|
||||
# Now Mock a shutdown signal being sent
|
||||
ofm_server.Server.handle_exit(mock_server.app, "mock_signal", "mock_frame")
|
||||
|
||||
# Check
|
||||
assert mock_camera.kill_mjpeg_streams.call_count == 1
|
||||
|
||||
# Mock shutdown again but with the MJPEG stream throwing a BaseException
|
||||
mock_camera.kill_mjpeg_streams.side_effect = BaseException("mock-54321")
|
||||
|
||||
# This should log at error level but even a BaseException will not cause an error
|
||||
# that stops the shutdown signal propagating
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ofm_server.Server.handle_exit(mock_server.app, "mock_signal", "mock_frame")
|
||||
# Assert there is one error log
|
||||
assert len(caplog.records) == 1
|
||||
# And that it is the message raised by the kill_mjpeg_streams mock
|
||||
assert str(caplog.records[0].msg) == "mock-54321"
|
||||
|
||||
|
||||
def test_failed_customise(mocker):
|
||||
"""Check what happens if there is an error before the server is started by uvicorn.
|
||||
|
||||
This differs based on whether the --fallback flag is set.
|
||||
"""
|
||||
mock_server = mocker.Mock()
|
||||
# Mock the LabThings function that returns the server so we have a mock server
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.server.lt.ThingServer.from_config",
|
||||
return_value=mock_server,
|
||||
)
|
||||
# Also mock customisation or it will try to access the hard drive and make files.
|
||||
mocker.patch.object(
|
||||
ofm_server, "customise_server", side_effect=RuntimeError("Can't touch this")
|
||||
)
|
||||
# And mock uvicorn.run as we don't want to start a server process
|
||||
mock_uvicorn_run = mocker.patch("openflexure_microscope_server.server.uvicorn.run")
|
||||
|
||||
# Running the mock CLI will error
|
||||
with pytest.raises(RuntimeError, match="Can't touch this"):
|
||||
ofm_server.serve_from_cli(["-c", FULL_CONFIG])
|
||||
|
||||
# But with the fallback flag uvicorn run will be run
|
||||
ofm_server.serve_from_cli(["-c", FULL_CONFIG, "--fallback"])
|
||||
|
||||
assert mock_uvicorn_run.call_count == 1
|
||||
# Get the fallback app passed to uvicorn tun
|
||||
fallback_app = mock_uvicorn_run.call_args.args[0]
|
||||
# Check it really is a fastapi
|
||||
assert isinstance(fallback_app, FastAPI)
|
||||
# An that it has the error to display
|
||||
assert str(fallback_app.labthings_error) == "Can't touch this"
|
||||
129
tests/unit_tests/test_server_config.py
Normal file
129
tests/unit_tests/test_server_config.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Test server booting and shut down."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
# Import as ofm server to attempt to minimise confusion with server as a var in other
|
||||
# functions and also FastAPI `Server`.
|
||||
from openflexure_microscope_server import server as ofm_server
|
||||
|
||||
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(THIS_DIR))
|
||||
FULL_CONFIG = os.path.join(REPO_ROOT, "ofm_config_full.json")
|
||||
SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side_effect", [None, RuntimeError("Mock")])
|
||||
def test_monkey_patched_handle_exit(side_effect, mocker):
|
||||
"""Test that handle exit is monkey_patched."""
|
||||
mock_app = mocker.Mock()
|
||||
# Setup the same for any side_effect
|
||||
# Create mocks for FastAPI.Server, and for our custom shutdown function
|
||||
mock_shutdown_func = mocker.Mock(side_effect=side_effect)
|
||||
mock_fastapi_server = mocker.patch.object(ofm_server, "Server")
|
||||
original_mock_handle_exit = mock_fastapi_server.handle_exit
|
||||
handle_exit_args = (mock_app, "mock_signal", "mock_frame")
|
||||
|
||||
ofm_server.set_shutdown_function(mock_shutdown_func)
|
||||
|
||||
if side_effect is None:
|
||||
# In normal operation, both the custom shutdown function and the original
|
||||
# handle_exit should be called
|
||||
ofm_server.Server.handle_exit(*handle_exit_args)
|
||||
|
||||
assert mock_shutdown_func.call_count == 1
|
||||
assert original_mock_handle_exit.call_count == 1
|
||||
# Check arguments are propagated correctly.
|
||||
assert original_mock_handle_exit.call_args.args == handle_exit_args
|
||||
else:
|
||||
# Use an error side effect to check that our custom shutdown function is called
|
||||
# before the standard `handle_exit`
|
||||
with pytest.raises(RuntimeError, match="Mock"):
|
||||
ofm_server.Server.handle_exit(*handle_exit_args)
|
||||
# The error was raised so we know our custom function was run, check that
|
||||
# handle_exit wasn't.
|
||||
# Note: that the non-mocked shutdown function is entirely wrapped in a
|
||||
# try/except BaseExcpetion, so that handle_exit will always run.
|
||||
assert original_mock_handle_exit.call_count == 0
|
||||
|
||||
|
||||
def test_customise_server(mocker):
|
||||
"""Check that all server customisation stages are called."""
|
||||
mock_server = mocker.Mock()
|
||||
# Mock everything to be called in customisation, partly setup may not be complete,
|
||||
# but also to limit excess coverage reporting.
|
||||
mocked_add_static = mocker.patch.object(ofm_server, "add_static_files")
|
||||
mocked_v2_endpoints = mocker.patch.object(ofm_server, "add_v2_endpoints")
|
||||
mocked_configure_logging = mocker.patch.object(ofm_server, "configure_logging")
|
||||
mocked_retrieve_log = mocker.patch.object(ofm_server, "retrieve_log")
|
||||
mocked_retrieve_log_file = mocker.patch.object(ofm_server, "retrieve_log_from_file")
|
||||
|
||||
mock_app = mock_server.app
|
||||
# The wrapper returned for app.get so we can see what functions are decorated.
|
||||
wrapper = mock_app.get.return_value
|
||||
|
||||
# Finally we can run it!
|
||||
ofm_server.customise_server(mock_server, "mock_log_folder", "mock_scan_folder")
|
||||
|
||||
# Check each internal customisation function is called
|
||||
assert mocked_add_static.call_count == 1
|
||||
assert mocked_v2_endpoints.call_count == 1
|
||||
assert mocked_configure_logging.call_count == 1
|
||||
# Check app.get adds two routes
|
||||
assert mock_app.get.call_count == 2
|
||||
assert wrapper.call_count == 2
|
||||
|
||||
# Check the routes and functions are as expected.
|
||||
added_routes = [call.args[0] for call in mock_app.get.call_args_list]
|
||||
assert "/log/" in added_routes
|
||||
assert "/logfile/" in added_routes
|
||||
wrapped_functions = [call.args[0] for call in wrapper.call_args_list]
|
||||
assert mocked_retrieve_log in wrapped_functions
|
||||
assert mocked_retrieve_log_file
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_file", "expected_scan_dir"),
|
||||
[(FULL_CONFIG, "/var/openflexure/scans/"), (SIM_CONFIG, "./openflexure/scans/")],
|
||||
)
|
||||
def test_get_scans_dir_ok(config_file, expected_scan_dir):
|
||||
"""Test the _get_scans_dir function and also check the standard config files."""
|
||||
with open(config_file, "r", encoding="utf-8") as f_obj:
|
||||
config_dict = json.load(f_obj)
|
||||
assert ofm_server._get_scans_dir(config_dict) == expected_scan_dir
|
||||
|
||||
|
||||
def test_get_scans_dir_no_smart_scan():
|
||||
"""Test _get_scans_dir with no SmartScanThing."""
|
||||
# Load the standard config dict
|
||||
with open(FULL_CONFIG, "r", encoding="utf-8") as f_obj:
|
||||
config_dict = json.load(f_obj)
|
||||
# Delete smart scan
|
||||
del config_dict["things"]["smart_scan"]
|
||||
# No SmartScanThing, should return None
|
||||
assert ofm_server._get_scans_dir(config_dict) is None
|
||||
|
||||
|
||||
def test_get_scans_dir_bad_smart_scan_config():
|
||||
"""Test _get_scans_dir with a SmartScanThing that doesn't set a config dir."""
|
||||
# Load the standard config dict
|
||||
with open(FULL_CONFIG, "r", encoding="utf-8") as f_obj:
|
||||
config_dict = json.load(f_obj)
|
||||
|
||||
# Copy the dictionary
|
||||
broken_config = deepcopy(config_dict)
|
||||
# Delete all the smart scan kwargs
|
||||
del broken_config["things"]["smart_scan"]["kwargs"]
|
||||
# Creates an Error
|
||||
with pytest.raises(RuntimeError):
|
||||
ofm_server._get_scans_dir(broken_config)
|
||||
|
||||
# Same thing should happen if just the scans_folder key is deleted
|
||||
broken_config = deepcopy(config_dict)
|
||||
del broken_config["things"]["smart_scan"]["kwargs"]
|
||||
# Creates an Error
|
||||
with pytest.raises(RuntimeError):
|
||||
ofm_server._get_scans_dir(broken_config)
|
||||
432
tests/unit_tests/test_smart_scan.py
Normal file
432
tests/unit_tests/test_smart_scan.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
"""Test the SmartScanThing *without* connecting it to a LabThings Server.
|
||||
|
||||
By testing without connecting to the LabThings server it is possible to
|
||||
directly poll any properties and to start any methods.
|
||||
|
||||
Any methods with LabThings dependency injections will require dependencies
|
||||
to be passed in manually. Rather than passing in real LabThings clients
|
||||
it is possible to create test objects that mock these clients. Thus, entirely
|
||||
isolating one Thing for testing, at the expense of needing to create detailed
|
||||
mock objects.
|
||||
|
||||
For these tests to reliably represent real behaviour the mock Things will need to
|
||||
be tested for matching signatures with dynamically generated clients.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from labthings_fastapi.exceptions import InvocationCancelledError
|
||||
from labthings_fastapi.testing import create_thing_without_server
|
||||
|
||||
from openflexure_microscope_server.scan_directories import (
|
||||
NotEnoughFreeSpaceError,
|
||||
ScanData,
|
||||
)
|
||||
from openflexure_microscope_server.things.smart_scan import (
|
||||
ScanNotRunningError,
|
||||
SmartScanThing,
|
||||
)
|
||||
|
||||
# Use our own dir in the root temp dir not a dynamically generated one so we
|
||||
# have some control of when it is deleted
|
||||
SCAN_DIR = os.path.join(tempfile.gettempdir(), "scans")
|
||||
|
||||
|
||||
def _clear_scan_dir() -> None:
|
||||
"""Delete the scan dir."""
|
||||
if os.path.exists(SCAN_DIR):
|
||||
shutil.rmtree(SCAN_DIR)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smart_scan_thing():
|
||||
"""Return a smart scan thing as a fixture."""
|
||||
return create_thing_without_server(
|
||||
SmartScanThing, scans_folder=SCAN_DIR, mock_all_slots=True
|
||||
)
|
||||
|
||||
|
||||
def test_initial_properties(smart_scan_thing):
|
||||
"""Check the initial values of properties.
|
||||
|
||||
Test properties of SmartScanThing are available without a ThingServer
|
||||
and return expected default values.
|
||||
"""
|
||||
assert smart_scan_thing._scan_dir_manager.base_dir == SCAN_DIR
|
||||
assert smart_scan_thing.latest_scan_name is None
|
||||
|
||||
|
||||
def test_inaccessible_scan_methods(smart_scan_thing):
|
||||
"""Test that method with @_scan_running decorator is inaccessible.
|
||||
|
||||
The @_scan_running decorator makes these functions inaccessible unless
|
||||
a scan is running.
|
||||
"""
|
||||
with pytest.raises(ScanNotRunningError):
|
||||
smart_scan_thing._run_scan()
|
||||
with pytest.raises(ScanNotRunningError):
|
||||
smart_scan_thing._manage_stitching_threads()
|
||||
|
||||
|
||||
def test_private_delete_scan(smart_scan_thing, caplog):
|
||||
"""Test the private _delete_scan method deletes directories or warns if it can't."""
|
||||
_clear_scan_dir()
|
||||
with caplog.at_level(logging.INFO):
|
||||
fake_scan_name = "fake_scan_0001"
|
||||
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
|
||||
|
||||
# Make the outer scan dir, but not the one to delete
|
||||
os.makedirs(SCAN_DIR)
|
||||
# Attempt to delete the fake scan. Expect it to fail and provide a warning
|
||||
deleted = smart_scan_thing._delete_scan(fake_scan_name)
|
||||
assert not deleted
|
||||
assert len(caplog.records) == 1
|
||||
assert caplog.records[0].levelname == "WARNING"
|
||||
assert caplog.records[0].name == "labthings_fastapi.things.smartscanthing"
|
||||
|
||||
# Make a dir for the fake scan and delete it.
|
||||
os.makedirs(fake_scan_path)
|
||||
assert os.path.exists(fake_scan_path)
|
||||
deleted = smart_scan_thing._delete_scan(fake_scan_name)
|
||||
assert not os.path.exists(fake_scan_path)
|
||||
assert deleted
|
||||
# Check no extra logs generated
|
||||
assert len(caplog.records) == 1
|
||||
|
||||
|
||||
def test_public_delete_scan(smart_scan_thing, caplog):
|
||||
"""Test the delete_scan API call deletes directories or warns if it can't."""
|
||||
_clear_scan_dir()
|
||||
with caplog.at_level(logging.INFO):
|
||||
fake_scan_name = "fake_scan_0001"
|
||||
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
|
||||
|
||||
# Make the outer scan dir, but not the one to delete
|
||||
os.makedirs(SCAN_DIR)
|
||||
|
||||
# Attempt to delete the fake scan. Expect it to fail
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
smart_scan_thing.delete_scan(fake_scan_name)
|
||||
# Should raise a 400 error if the scan doesn't exist, not a 404 as the server
|
||||
# was not expecting to receive the scan files
|
||||
assert exc_info.value.status_code == 400
|
||||
assert len(caplog.records) == 1
|
||||
assert caplog.records[0].levelname == "WARNING"
|
||||
assert caplog.records[0].name == "labthings_fastapi.things.smartscanthing"
|
||||
|
||||
# Make a dir for the fake scan and delete it.
|
||||
os.makedirs(fake_scan_path)
|
||||
assert os.path.exists(fake_scan_path)
|
||||
smart_scan_thing.delete_scan(fake_scan_name)
|
||||
assert not os.path.exists(fake_scan_path)
|
||||
# Check no extra logs generated
|
||||
assert len(caplog.records) == 1
|
||||
|
||||
|
||||
def test_delete_all_scans(smart_scan_thing, caplog):
|
||||
"""Check the delete_all_scan API really does delete all the scans."""
|
||||
_clear_scan_dir()
|
||||
with caplog.at_level(logging.INFO):
|
||||
fake_scan_names = [
|
||||
"fake_scan_0001",
|
||||
"fake_scan_0002",
|
||||
"fake_scan_0003",
|
||||
"fake_scan_0004",
|
||||
]
|
||||
for fake_scan_name in fake_scan_names:
|
||||
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
|
||||
os.makedirs(fake_scan_path)
|
||||
assert os.path.exists(fake_scan_path)
|
||||
smart_scan_thing.delete_all_scans()
|
||||
for fake_scan_name in fake_scan_names:
|
||||
fake_scan_path = os.path.join(SCAN_DIR, fake_scan_name)
|
||||
assert not os.path.exists(fake_scan_path)
|
||||
# No logs generated
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
|
||||
def _run_only_outer_scan(
|
||||
smart_scan_thing, mocker, adjust_initial_state: Optional[Callable] = None
|
||||
):
|
||||
"""Create a subclass of SmartScanThing to mock _run_scan and run sample_scan.
|
||||
|
||||
This should do all the set up for a scan, move into the mocked
|
||||
_run_scan method where this can be tested. Once this is done
|
||||
the final scan behaviour can be tested too.
|
||||
|
||||
adjust_initial_state is a callable which accepts the mocked smart scan thing
|
||||
as the only variable. It can be used to adjust the initial state of the test
|
||||
|
||||
|
||||
This seems hard to do with a fixture so it is being done with a private
|
||||
function
|
||||
"""
|
||||
|
||||
def check_locked(*_args, **_kwargs):
|
||||
"""Check the scan is locked."""
|
||||
assert smart_scan_thing._scan_lock.locked()
|
||||
|
||||
mocker.patch.object(smart_scan_thing, "_run_scan", side_effect=check_locked)
|
||||
|
||||
if adjust_initial_state is not None:
|
||||
adjust_initial_state(smart_scan_thing)
|
||||
|
||||
exec_info = None
|
||||
try:
|
||||
smart_scan_thing.sample_scan(scan_name="FooBar")
|
||||
|
||||
except Exception as e:
|
||||
exec_info = e
|
||||
|
||||
assert not smart_scan_thing._scan_lock.locked()
|
||||
|
||||
# Return the mock thing for further state testing, and the
|
||||
# exec_info of any uncaught exceptions that were raised
|
||||
return smart_scan_thing, exec_info
|
||||
|
||||
|
||||
def test_outer_scan(smart_scan_thing, mocker):
|
||||
"""Test setup and teardown of the scan."""
|
||||
mock_ss_thing, exec_info = _run_only_outer_scan(smart_scan_thing, mocker)
|
||||
assert exec_info is None
|
||||
# Checked the mocked _run_scan was run exactly once
|
||||
assert mock_ss_thing._run_scan.call_count == 1
|
||||
|
||||
|
||||
def test_outer_scan_wo_sample_skip(smart_scan_thing, mocker):
|
||||
"""Test setup and teardown of the scan."""
|
||||
|
||||
def _set_skip_background(mock_ss_thing):
|
||||
# As the Thing is not connected to a server we set the setting via the internal
|
||||
# __dict__ to avoid triggering property emits that require a server
|
||||
mock_ss_thing.__dict__["skip_background"] = False
|
||||
|
||||
mock_ss_thing, exec_info = _run_only_outer_scan(
|
||||
smart_scan_thing, mocker, _set_skip_background
|
||||
)
|
||||
|
||||
assert exec_info is None
|
||||
# Checked the mocked _run_scan was run exactly once
|
||||
assert mock_ss_thing._run_scan.call_count == 1
|
||||
|
||||
|
||||
MOCK_SCAN_NAME = "test_name_0001"
|
||||
MOCK_SCAN_DIR = "scans/test_name_0001/images/"
|
||||
MOCK_START_POS = {"x": 123, "y": 456, "z": 789}
|
||||
|
||||
|
||||
def _expected_scan_data():
|
||||
"""Return the expected ScanData object for a SmartScan with default properties."""
|
||||
expected_dict = {
|
||||
"scan_name": MOCK_SCAN_NAME,
|
||||
"starting_position": MOCK_START_POS,
|
||||
"overlap": 0.45,
|
||||
"max_dist": 45000,
|
||||
"dx": 100,
|
||||
"dy": 100,
|
||||
"autofocus_dz": 1000,
|
||||
"autofocus_on": True,
|
||||
"skip_background": True,
|
||||
"stitch_automatically": True,
|
||||
"correlation_resize": 0.5,
|
||||
"save_resolution": (1640, 1232),
|
||||
}
|
||||
return ScanData(start_time=datetime.now(), **expected_dict)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_thing_mocked_for_scan_data(smart_scan_thing, mocker):
|
||||
"""Return a scan thing that is mocked so that _collect_scan_data will run."""
|
||||
# Set the lock so it thinks the scan is running
|
||||
with smart_scan_thing._scan_lock:
|
||||
mocker.patch.object(
|
||||
smart_scan_thing,
|
||||
"_calc_displacement_from_test_image",
|
||||
return_value=[100, 100],
|
||||
)
|
||||
|
||||
smart_scan_thing._stage.position = MOCK_START_POS
|
||||
|
||||
mock_ongoing_scan = mocker.Mock()
|
||||
mock_ongoing_scan.name = MOCK_SCAN_NAME
|
||||
mock_ongoing_scan.images_dir = MOCK_SCAN_DIR
|
||||
smart_scan_thing._ongoing_scan = mock_ongoing_scan
|
||||
|
||||
yield smart_scan_thing
|
||||
|
||||
|
||||
def test_collect_scan_data(scan_thing_mocked_for_scan_data):
|
||||
"""Run _collect_scan_data, and check the ScanData object has the expected values."""
|
||||
scan_thing = scan_thing_mocked_for_scan_data
|
||||
|
||||
data = scan_thing._collect_scan_data()
|
||||
expected_data = _expected_scan_data()
|
||||
time_diff = expected_data.start_time - data.start_time
|
||||
assert abs(time_diff.total_seconds()) < 1
|
||||
# Set times to exactly the same before final comparison
|
||||
expected_data.start_time = data.start_time
|
||||
assert data == expected_data
|
||||
|
||||
|
||||
def test_save_final_scan_data(scan_thing_mocked_for_scan_data):
|
||||
"""Run _save_final_scan_data, check save is called with final results in ScanData."""
|
||||
scan_thing = scan_thing_mocked_for_scan_data
|
||||
|
||||
scan_thing._scan_data = scan_thing._collect_scan_data()
|
||||
scan_thing._scan_data.image_count = 44
|
||||
scan_thing._save_final_scan_data("Mocked!")
|
||||
# _ongoing_scan is a mock so we can check that save_scan data was called and get
|
||||
# the value
|
||||
scan_thing._ongoing_scan.save_scan_data.assert_called()
|
||||
final_data = scan_thing._ongoing_scan.save_scan_data.call_args[0][0]
|
||||
assert isinstance(final_data, ScanData)
|
||||
assert final_data.scan_result == "Mocked!"
|
||||
assert final_data.image_count == 44
|
||||
assert final_data.duration.total_seconds() < 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_thing_mocked_for_run_scan(scan_thing_mocked_for_scan_data, mocker):
|
||||
"""Return a scan_thing mocked so _run_scan works.
|
||||
|
||||
main_scan_loop(), _return_to_starting_position(), and _perform_final_stitch() are
|
||||
Mocks and _cam is a MockCameraThing
|
||||
"""
|
||||
scan_thing = scan_thing_mocked_for_scan_data
|
||||
mocker.patch.object(scan_thing, "_main_scan_loop")
|
||||
mocker.patch.object(scan_thing, "_return_to_starting_position")
|
||||
mocker.patch.object(scan_thing, "_perform_final_stitch")
|
||||
|
||||
return scan_thing
|
||||
|
||||
|
||||
def check_run_scan(scan_thing, caplog, expected_exception=None):
|
||||
"""Run _run_scan with a mocked scan_thing, capture logs and call counts.
|
||||
|
||||
This can be used to check how run_scan executes in different exit modes.
|
||||
A separate tests is above for scan completing with no exception.
|
||||
|
||||
:returns: a tuple of:
|
||||
|
||||
* The final result as reported in scan_data
|
||||
* The logging records
|
||||
* a dictionary of call counts
|
||||
"""
|
||||
if expected_exception is None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
scan_thing._scan_data = scan_thing._run_scan()
|
||||
else:
|
||||
with pytest.raises(expected_exception), caplog.at_level(logging.WARNING):
|
||||
scan_thing._scan_data = scan_thing._run_scan()
|
||||
# The preview stitcher object should still exist. And images dir should be set.
|
||||
assert scan_thing._preview_stitcher.images_dir == MOCK_SCAN_DIR
|
||||
|
||||
final_scan_data = scan_thing._ongoing_scan.save_scan_data.call_args[0][0]
|
||||
calls = {
|
||||
"cam_start_streaming_calls": scan_thing._cam.start_streaming.call_count,
|
||||
"main_scan_loop_calls": scan_thing._main_scan_loop.call_count,
|
||||
"return_to_start_calls": scan_thing._return_to_starting_position.call_count,
|
||||
"perform_final_stitch_calls": scan_thing._perform_final_stitch.call_count,
|
||||
"save_scan_data_calls": scan_thing._ongoing_scan.save_scan_data.call_count,
|
||||
}
|
||||
return final_scan_data.scan_result, caplog.records, calls
|
||||
|
||||
|
||||
def test_run_scan(scan_thing_mocked_for_run_scan, caplog):
|
||||
"""Run _save_final_scan_data, check save is called with final results in ScanData."""
|
||||
result, logs, calls = check_run_scan(scan_thing_mocked_for_run_scan, caplog)
|
||||
|
||||
assert result == "success"
|
||||
assert len(logs) == 0
|
||||
|
||||
expected_calls_numbers = {
|
||||
"cam_start_streaming_calls": 2,
|
||||
"main_scan_loop_calls": 1,
|
||||
"return_to_start_calls": 1,
|
||||
"perform_final_stitch_calls": 1,
|
||||
"save_scan_data_calls": 2,
|
||||
}
|
||||
assert calls == expected_calls_numbers
|
||||
|
||||
|
||||
def test_run_scan_err_in_main_loop(scan_thing_mocked_for_run_scan, caplog, mocker):
|
||||
"""Check correct methods called if main_loop errors."""
|
||||
scan_thing = scan_thing_mocked_for_run_scan
|
||||
mocker.patch.object(
|
||||
scan_thing, "_main_scan_loop", side_effect=FileNotFoundError("mocked")
|
||||
)
|
||||
|
||||
result, logs, calls = check_run_scan(scan_thing, caplog, FileNotFoundError)
|
||||
|
||||
assert result.startswith("FileNotFoundError:")
|
||||
assert len(logs) == 1
|
||||
assert logs[0].levelno == logging.ERROR
|
||||
|
||||
# Main loop not run, nor are return to start, final stitch, or purging of empty
|
||||
# scans. Save scan data is still called twice
|
||||
expected_calls_numbers = {
|
||||
"cam_start_streaming_calls": 2,
|
||||
"main_scan_loop_calls": 1,
|
||||
"return_to_start_calls": 0,
|
||||
"perform_final_stitch_calls": 0,
|
||||
"save_scan_data_calls": 2,
|
||||
}
|
||||
assert calls == expected_calls_numbers
|
||||
|
||||
|
||||
def test_run_scan_cancelled(scan_thing_mocked_for_run_scan, caplog, mocker):
|
||||
"""Check correct methods called scan is cancelled."""
|
||||
scan_thing = scan_thing_mocked_for_run_scan
|
||||
mocker.patch.object(
|
||||
scan_thing, "_main_scan_loop", side_effect=InvocationCancelledError()
|
||||
)
|
||||
|
||||
result, logs, calls = check_run_scan(scan_thing, caplog)
|
||||
|
||||
assert result == "cancelled by user"
|
||||
# No logs at warning level.
|
||||
assert len(logs) == 0
|
||||
|
||||
# Main loop not run, nor are return to start, final stitch, or purging of empty
|
||||
# scans. Save scan data is still called twice
|
||||
expected_calls_numbers = {
|
||||
"cam_start_streaming_calls": 2,
|
||||
"main_scan_loop_calls": 1,
|
||||
"return_to_start_calls": 1,
|
||||
"perform_final_stitch_calls": 1,
|
||||
"save_scan_data_calls": 2,
|
||||
}
|
||||
assert calls == expected_calls_numbers
|
||||
|
||||
|
||||
def test_run_scan_fill_disk(scan_thing_mocked_for_run_scan, caplog, mocker):
|
||||
"""Check correct methods called if disk fills up."""
|
||||
scan_thing = scan_thing_mocked_for_run_scan
|
||||
mocker.patch.object(
|
||||
scan_thing, "_main_scan_loop", side_effect=NotEnoughFreeSpaceError()
|
||||
)
|
||||
|
||||
result, logs, calls = check_run_scan(scan_thing, caplog, NotEnoughFreeSpaceError)
|
||||
|
||||
assert result.startswith("NotEnoughFreeSpaceError:")
|
||||
assert len(logs) == 1
|
||||
assert logs[0].levelno == logging.ERROR
|
||||
|
||||
# Main loop not run, nor are return to start, final stitch, or purging of empty
|
||||
# scans. Save scan data is still called twice
|
||||
expected_calls_numbers = {
|
||||
"cam_start_streaming_calls": 2,
|
||||
"main_scan_loop_calls": 1,
|
||||
"return_to_start_calls": 0,
|
||||
"perform_final_stitch_calls": 0,
|
||||
"save_scan_data_calls": 2,
|
||||
}
|
||||
assert calls == expected_calls_numbers
|
||||
654
tests/unit_tests/test_stack.py
Normal file
654
tests/unit_tests/test_stack.py
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
"""Tests for the smart and fast stacking."""
|
||||
|
||||
import logging
|
||||
from random import randint
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from labthings_fastapi.testing import create_thing_without_server
|
||||
|
||||
from openflexure_microscope_server.scan_directories import IMAGE_REGEX
|
||||
from openflexure_microscope_server.things.autofocus import (
|
||||
EXTRA_STACK_CAPTURES,
|
||||
MAX_TEST_IMAGE_COUNT,
|
||||
MIN_TEST_IMAGE_COUNT,
|
||||
AutofocusThing,
|
||||
CaptureInfo,
|
||||
NotAPeakError,
|
||||
StackParams,
|
||||
_count_turning_points,
|
||||
_get_capture_by_id,
|
||||
_get_capture_index_by_id,
|
||||
_get_peak_turning_point,
|
||||
)
|
||||
|
||||
RANDOM_GENERATOR = np.random.default_rng()
|
||||
|
||||
|
||||
def odd_integers(min_value=0, max_value=1000):
|
||||
"""Return a hypothesis strategy for odd integers."""
|
||||
min_base = (min_value) // 2
|
||||
max_base = (max_value - 1) // 2
|
||||
# Ensure the range allows at least one odd number
|
||||
if min_base > max_base:
|
||||
return st.nothing()
|
||||
return st.integers(min_value=min_base, max_value=max_base).map(lambda x: 2 * x + 1)
|
||||
|
||||
|
||||
def even_integers(min_value=0, max_value=1000):
|
||||
"""Return a hypothesis strategy for even integers."""
|
||||
min_base = (min_value + 1) // 2
|
||||
max_base = (max_value) // 2
|
||||
# Ensure the range allows at least one even number
|
||||
if min_base > max_base:
|
||||
return st.nothing()
|
||||
return st.integers(min_value=min_base, max_value=max_base).map(lambda x: 2 * x)
|
||||
|
||||
|
||||
@given(
|
||||
save_ims=odd_integers(min_value=1, max_value=9),
|
||||
extra_ims=even_integers(min_value=0, max_value=10),
|
||||
)
|
||||
def test_stack_params_validation(save_ims, extra_ims):
|
||||
"""Test the validation of the image numbers for a stack for valid combinations.
|
||||
|
||||
save_ims is the number to save (must be odd and positive)
|
||||
extra_ims is how many more images there are in min_images_to_test than
|
||||
images_to_save. (even so that, min_images_to_test is odd and larger than
|
||||
images_to_save
|
||||
"""
|
||||
# Coerce min_images_to_test as the max extra ims depends on save_ims so is hard
|
||||
# to do automatically in hypothesis. This clamps the number between 3 and 9.
|
||||
min_images_to_test = max(min(save_ims + extra_ims, 9), 3)
|
||||
StackParams(
|
||||
stack_dz=50,
|
||||
images_to_save=save_ims,
|
||||
min_images_to_test=min_images_to_test,
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
save_ims=odd_integers(min_value=0, max_value=10),
|
||||
extra_ims=even_integers(min_value=-10, max_value=-1),
|
||||
)
|
||||
def test_stack_params_not_enough_test_images(save_ims, extra_ims):
|
||||
"""Test error is raised if min_images_to_test is smaller than images_to_save.
|
||||
|
||||
``extra_ims`` negative so that min_images_to_test is smaller than images_to_save.
|
||||
|
||||
For arguments see test_stack_params_validation
|
||||
"""
|
||||
# Depending on the values multiple messages are possible
|
||||
match = (
|
||||
"(Can't test for focus with fewer than 3 images|"
|
||||
"Can't save more images than the minimum number tested)"
|
||||
)
|
||||
with pytest.raises(ValueError, match=match):
|
||||
StackParams(
|
||||
stack_dz=50,
|
||||
images_to_save=save_ims,
|
||||
min_images_to_test=save_ims + extra_ims,
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
save_ims=odd_integers(min_value=-10, max_value=-1),
|
||||
extra_ims=even_integers(min_value=0, max_value=10),
|
||||
)
|
||||
def test_stack_params_negative_images_to_save(save_ims, extra_ims):
|
||||
"""save_ims is negative so images_to_save is negative, failing validation.
|
||||
|
||||
For arguments see test_stack_params_validation
|
||||
"""
|
||||
# Depending on the values multiple messages are possible
|
||||
match = (
|
||||
"(Can't test for focus with fewer than 3 images|"
|
||||
"Images to save must be positive and odd)"
|
||||
)
|
||||
with pytest.raises(ValueError, match=match):
|
||||
StackParams(
|
||||
stack_dz=50,
|
||||
images_to_save=save_ims,
|
||||
min_images_to_test=save_ims + extra_ims,
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
save_ims=odd_integers(min_value=0, max_value=10),
|
||||
extra_ims=odd_integers(min_value=0, max_value=10),
|
||||
)
|
||||
def test_even_min_images_to_test(save_ims, extra_ims):
|
||||
"""extra_ims is odd so min_images_to_test is even, failing validation.
|
||||
|
||||
For arguments see test_stack_params_validation
|
||||
"""
|
||||
# Depending on the values multiple messages are possible
|
||||
match = (
|
||||
"(Can't test for focus with fewer than 3 images|"
|
||||
"Testing with more than 9 images is|" # may give more than 9 which errors first
|
||||
"Minimum number of images to test should be positive and odd)"
|
||||
)
|
||||
with pytest.raises(ValueError, match=match):
|
||||
StackParams(
|
||||
stack_dz=50,
|
||||
images_to_save=save_ims,
|
||||
min_images_to_test=save_ims + extra_ims,
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
save_ims=even_integers(min_value=0, max_value=10),
|
||||
extra_ims=odd_integers(min_value=0, max_value=10),
|
||||
)
|
||||
def test_even_images_to_save(save_ims, extra_ims):
|
||||
"""save_ims is even so images_to_save is even, failing validation.
|
||||
|
||||
For arguments see test_stack_params_validation
|
||||
"""
|
||||
match = (
|
||||
"(Can't test for focus with fewer than 3 images|"
|
||||
"Images to save must be positive and odd)"
|
||||
)
|
||||
with pytest.raises(ValueError, match=match):
|
||||
StackParams(
|
||||
stack_dz=50,
|
||||
images_to_save=save_ims,
|
||||
min_images_to_test=save_ims + extra_ims,
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
)
|
||||
|
||||
|
||||
def test_computed_stack_params():
|
||||
"""Test StackParams computed properties are as expected.
|
||||
|
||||
Not using hypothesis or we will just copy in the same formulas.
|
||||
"""
|
||||
stack_parameters = StackParams(
|
||||
stack_dz=50,
|
||||
images_to_save=5,
|
||||
min_images_to_test=9,
|
||||
autofocus_dz=2000,
|
||||
images_dir="/this/is/fake",
|
||||
save_resolution=(1640, 1232),
|
||||
)
|
||||
|
||||
assert stack_parameters.stack_z_range == 8 * 50
|
||||
|
||||
assert stack_parameters.steps_undershoot == stack_parameters.img_undershoot * 50
|
||||
|
||||
assert stack_parameters.max_images_to_test == 9 + 15
|
||||
|
||||
sharpnesses = [50, 77, 234, 324, 390, 496, 569, 454, 333, 222, 178, 70]
|
||||
max_ind = np.argmax(sharpnesses)
|
||||
slice_to_save = stack_parameters.slice_to_save(max_ind)
|
||||
# Check the slice corresponds to the index for the 5 images centred on the sharpest
|
||||
assert sharpnesses[slice_to_save] == [390, 496, 569, 454, 333]
|
||||
|
||||
# For a failed smart stack the slice may be truncated. Check it truncates correctly
|
||||
sharpnesses = [496, 569, 454, 333, 222, 178, 70, 69, 66, 50, 45, 40]
|
||||
max_ind = np.argmax(sharpnesses)
|
||||
slice_to_save = stack_parameters.slice_to_save(max_ind)
|
||||
# Check the slice corresponds to the index for the first 4 images
|
||||
assert sharpnesses[slice_to_save] == [496, 569, 454, 333]
|
||||
|
||||
# And again for sharpest at the end
|
||||
sharpnesses = [13, 21, 26, 31, 39, 49, 50, 77, 234, 324, 390, 496, 569]
|
||||
max_ind = np.argmax(sharpnesses)
|
||||
slice_to_save = stack_parameters.slice_to_save(max_ind)
|
||||
# Check the slice corresponds to the index for the final 3 images
|
||||
assert sharpnesses[slice_to_save] == [390, 496, 569]
|
||||
|
||||
|
||||
def random_capture(set_id: Optional[int] = None):
|
||||
"""Create a capture with random values.
|
||||
|
||||
:param set_id: Optional, use to set a fixed id rather than a random one
|
||||
"""
|
||||
buffer_id = set_id if set_id is not None else randint(0, 1000)
|
||||
return CaptureInfo(
|
||||
buffer_id=buffer_id,
|
||||
position={
|
||||
"x": randint(-100000, 100000),
|
||||
"y": randint(-100000, 100000),
|
||||
"z": randint(-100000, 100000),
|
||||
},
|
||||
sharpness=randint(0, 100000),
|
||||
)
|
||||
|
||||
|
||||
def test_capture_filename_matches_regex():
|
||||
"""For 100 random captures check the image always matches the regex."""
|
||||
for _ in range(100):
|
||||
assert IMAGE_REGEX.search(random_capture().filename)
|
||||
|
||||
|
||||
@given(st.integers(min_value=0, max_value=5000))
|
||||
def test_retrieval_of_captures(start):
|
||||
"""For 20 random captures, check each can be retrieved correctly by id."""
|
||||
captures = [random_capture(start + i) for i in range(20)]
|
||||
|
||||
for i, capture in enumerate(captures):
|
||||
buffer_id = capture.buffer_id
|
||||
assert _get_capture_index_by_id(captures, buffer_id) == i
|
||||
assert _get_capture_by_id(captures, buffer_id) is capture
|
||||
|
||||
# Check errors are raised when supplying ids that aren't in the list
|
||||
with pytest.raises(ValueError, match="No capture has a buffer id of"):
|
||||
_get_capture_index_by_id(captures, start - 1)
|
||||
with pytest.raises(ValueError, match="No capture has a buffer id of"):
|
||||
_get_capture_index_by_id(captures, start + 21)
|
||||
with pytest.raises(ValueError, match="No capture has a buffer id of"):
|
||||
_get_capture_by_id(captures, start - 1)
|
||||
with pytest.raises(ValueError, match="No capture has a buffer id of"):
|
||||
_get_capture_by_id(captures, start + 21)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def autofocus_thing():
|
||||
"""Return an autofocus thing connected to a server."""
|
||||
return create_thing_without_server(AutofocusThing, mock_all_slots=True)
|
||||
|
||||
|
||||
def test_create_stack(autofocus_thing, caplog):
|
||||
"""Run create stack with default values and check there is no coercion or logging."""
|
||||
initial_min_images_to_test = autofocus_thing.stack_min_images_to_test
|
||||
initial_images_to_save = autofocus_thing.stack_images_to_save
|
||||
with caplog.at_level(logging.INFO):
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
|
||||
assert len(caplog.records) == 0
|
||||
assert autofocus_thing.stack_min_images_to_test == initial_min_images_to_test
|
||||
assert autofocus_thing.stack_images_to_save == initial_images_to_save
|
||||
assert stack_params.min_images_to_test == autofocus_thing.stack_min_images_to_test
|
||||
assert stack_params.images_to_save == autofocus_thing.stack_images_to_save
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial_test_ims", "coerced_test_ims", "expected_log_start"),
|
||||
[
|
||||
(0, MIN_TEST_IMAGE_COUNT, "Cannot test only 0"),
|
||||
(-1, MIN_TEST_IMAGE_COUNT, "Cannot test only -1"),
|
||||
(10, MAX_TEST_IMAGE_COUNT, "Testing 10 images will"),
|
||||
(4, 5, "Minimum number of images to test should be odd"),
|
||||
],
|
||||
)
|
||||
def test_coercing_stack_test_ims(
|
||||
initial_test_ims, coerced_test_ims, expected_log_start, autofocus_thing, caplog
|
||||
):
|
||||
"""Run create stack with images to test set to values requiring coercion, and check result."""
|
||||
autofocus_thing.stack_min_images_to_test = initial_test_ims
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert str(caplog.records[0].msg).startswith(expected_log_start)
|
||||
# Check the value is coerced in the stack_params
|
||||
assert stack_params.min_images_to_test == coerced_test_ims
|
||||
# Check that the setting in the Thing was updated to the coerced value
|
||||
assert stack_params.min_images_to_test == autofocus_thing.stack_min_images_to_test
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial_save_ims", "coerced_save_ims", "expected_log_start"),
|
||||
[
|
||||
(0, 1, "At least 1 images must be saved"),
|
||||
(-1, 1, "At least 1 images must be saved"),
|
||||
(10, 9, "Cannot save 10 images"),
|
||||
(4, 5, "Images to save should be odd, setting to 5"),
|
||||
],
|
||||
)
|
||||
def test_coercing_stack_save_ims(
|
||||
initial_save_ims, coerced_save_ims, expected_log_start, autofocus_thing, caplog
|
||||
):
|
||||
"""Run create stack with images to save set to values requiring coercion, and check result."""
|
||||
autofocus_thing.stack_images_to_save = initial_save_ims
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert str(caplog.records[0].msg).startswith(expected_log_start)
|
||||
# Check the value is coerced in the stack_params
|
||||
assert stack_params.images_to_save == coerced_save_ims
|
||||
# Check that the setting in the Thing was updated to the coerced value
|
||||
assert stack_params.images_to_save == autofocus_thing.stack_images_to_save
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pass_on", [1, 2, 3, 4])
|
||||
def test_run_smart_stack(pass_on, autofocus_thing, mocker):
|
||||
"""Test Running smart stack with the stack passing on different attempts."""
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
assert stack_params.max_attempts == 3
|
||||
|
||||
# Set up returns from z-stack
|
||||
fake_captures = [
|
||||
CaptureInfo(
|
||||
buffer_id="first", position={"x": 0, "y": 0, "z": -99}, sharpness=123
|
||||
),
|
||||
CaptureInfo(
|
||||
buffer_id="pick_me", position={"x": 0, "y": 0, "z": 555}, sharpness=456
|
||||
),
|
||||
CaptureInfo(
|
||||
buffer_id="last", position={"x": 0, "y": 0, "z": 999}, sharpness=123
|
||||
),
|
||||
]
|
||||
|
||||
successful_return = (True, fake_captures, "pick_me")
|
||||
failed_return = (False, fake_captures, "pick_me")
|
||||
return_list = [failed_return] * (pass_on - 1) + [successful_return]
|
||||
|
||||
# Mock z_stack and looping_autofocus
|
||||
autofocus_thing.z_stack = mocker.Mock(side_effect=return_list)
|
||||
autofocus_thing.looping_autofocus = mocker.Mock()
|
||||
|
||||
# Run it
|
||||
success, final_z = autofocus_thing.run_smart_stack(
|
||||
stack_parameters=stack_params,
|
||||
save_on_failure=False,
|
||||
check_turning_points=True,
|
||||
)
|
||||
|
||||
# Only passes if the attempt it passes on is less than max attempts
|
||||
assert success == (pass_on <= stack_params.max_attempts)
|
||||
# Final z is the one from the id returned by the stack "pick_me"
|
||||
assert final_z == 555
|
||||
|
||||
# z_stack should run up until the time it passes. Running no more than max_attempts
|
||||
n_stacks = min(pass_on, stack_params.max_attempts)
|
||||
assert autofocus_thing.z_stack.call_count == n_stacks
|
||||
# Move absolute should be 1 less time that the number of times z_stack_run
|
||||
assert autofocus_thing._stage.move_absolute.call_count == n_stacks - 1
|
||||
# As should looping autofocus
|
||||
assert autofocus_thing.looping_autofocus.call_count == n_stacks - 1
|
||||
|
||||
# Check rest stack is moving to the first image in the stack.
|
||||
if n_stacks > 1:
|
||||
assert autofocus_thing._stage.move_absolute.call_args.kwargs["z"] == -99
|
||||
|
||||
# Mock called to save image
|
||||
assert autofocus_thing._cam.save_from_memory.call_count == (1 if success else 0)
|
||||
|
||||
|
||||
def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing, mocker):
|
||||
"""Set up a z_stack, run it, and return the result.
|
||||
|
||||
:param check_returns: The return values from check_stack_result. Note that if this
|
||||
is a list, it will be set as a side effect (and should be a list of tuples of
|
||||
results). If it a tuple (or anything else), it is set as a return value.
|
||||
"""
|
||||
stack_params = autofocus_thing.create_stack_params(
|
||||
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
|
||||
)
|
||||
stack_params.settling_time = 0 # Don't settle or tests take forever.
|
||||
|
||||
autofocus_thing.capture_stack_image = mocker.Mock()
|
||||
if isinstance(check_returns, list):
|
||||
autofocus_thing.check_stack_result = mocker.Mock(side_effect=check_returns)
|
||||
else:
|
||||
autofocus_thing.check_stack_result = mocker.Mock(return_value=check_returns)
|
||||
return autofocus_thing.z_stack(
|
||||
stack_parameters=stack_params,
|
||||
check_turning_points=check_turning_points,
|
||||
)
|
||||
|
||||
|
||||
def test_z_stack_turning_toggle_passed(autofocus_thing, mocker):
|
||||
"""Check that the toggling of turning points is passed to the check."""
|
||||
check_returns = ("success", "mock_id")
|
||||
for check_turning in [True, False]:
|
||||
setup_and_run_z_stack(check_returns, check_turning, autofocus_thing, mocker)
|
||||
check_kwargs = autofocus_thing.check_stack_result.call_args.kwargs
|
||||
assert check_kwargs["check_turning_points"] == check_turning
|
||||
|
||||
|
||||
def test_z_stack_returns_on_success_and_restart(autofocus_thing, mocker):
|
||||
"""Check that if the check returns success or restart then the stack exits with correct return value."""
|
||||
for result in ["success", "restart"]:
|
||||
check_returns = (result, "mock_id")
|
||||
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
|
||||
assert autofocus_thing.check_stack_result.call_count == 1
|
||||
# Check the number of images taken is exactly the call count.
|
||||
ims_taken = autofocus_thing.capture_stack_image.call_count
|
||||
assert ims_taken == autofocus_thing.stack_min_images_to_test
|
||||
# And the result is as expected.
|
||||
assert ret[0] == (result == "success")
|
||||
|
||||
|
||||
def test_z_stack_exits_if_focus_never_found(autofocus_thing, mocker):
|
||||
"""Check that if the check returns continue the stack exits eventually with a failure."""
|
||||
check_returns = ("continue", "mock_id")
|
||||
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
|
||||
|
||||
assert autofocus_thing.check_stack_result.call_count == EXTRA_STACK_CAPTURES + 1
|
||||
# Check the number of images taken is the maximum possible, set by the min images to
|
||||
# test and the number of extra images that can be taken
|
||||
ims_taken = autofocus_thing.capture_stack_image.call_count
|
||||
max_ims = autofocus_thing.stack_min_images_to_test + EXTRA_STACK_CAPTURES
|
||||
assert ims_taken == max_ims
|
||||
# And the result is as expected.
|
||||
assert not ret[0]
|
||||
|
||||
|
||||
def test_z_stack_return(autofocus_thing, mocker):
|
||||
"""Check z-stack returns as expected for more complex cases the fixed results above."""
|
||||
for i in range(2, EXTRA_STACK_CAPTURES):
|
||||
check_returns = [
|
||||
("restart" if j == i - 1 else "continue", f"id_{j}") for j in range(i)
|
||||
]
|
||||
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
|
||||
# Calculate images taken
|
||||
images_taken = autofocus_thing.stack_min_images_to_test + i - 1
|
||||
assert autofocus_thing.capture_stack_image.call_count == images_taken
|
||||
# Check it reports a failure
|
||||
assert not ret[0]
|
||||
|
||||
# Repeat ending with a success rather than a failure
|
||||
check_returns = [
|
||||
("success" if j == i - 1 else "continue", f"id_{j}") for j in range(i)
|
||||
]
|
||||
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
|
||||
# Calculate images taken
|
||||
assert autofocus_thing.capture_stack_image.call_count == images_taken
|
||||
# Check it reports a success
|
||||
assert ret[0]
|
||||
|
||||
|
||||
def test_capture_stack_image(autofocus_thing):
|
||||
"""Check that capture stack image calls the expected functions and returns the expected data."""
|
||||
autofocus_thing._stage.position = {"x": 123, "y": 456, "z": 789}
|
||||
autofocus_thing._cam.capture_to_memory.return_value = "fake_buffer_id"
|
||||
autofocus_thing._cam.grab_jpeg_size.return_value = 54321
|
||||
buffer_max = 11
|
||||
|
||||
info = autofocus_thing.capture_stack_image(buffer_max=buffer_max)
|
||||
assert autofocus_thing._cam.capture_to_memory.call_count == 1
|
||||
assert autofocus_thing._cam.grab_jpeg_size.call_count == 1
|
||||
assert info.buffer_id == "fake_buffer_id"
|
||||
assert info.position == {"x": 123, "y": 456, "z": 789}
|
||||
assert info.sharpness == 54321
|
||||
|
||||
|
||||
def mock_capture(buffer_id: int, sharpness: int) -> CaptureInfo:
|
||||
"""Create a CaptureInfo instance with a dummy position."""
|
||||
return CaptureInfo(
|
||||
buffer_id=buffer_id,
|
||||
position={"x": 0, "y": 0, "z": buffer_id},
|
||||
sharpness=sharpness,
|
||||
)
|
||||
|
||||
|
||||
def test_check_stack_single_image_returns_success(autofocus_thing):
|
||||
"""A single image is always successful."""
|
||||
captures = [mock_capture("mock-id", 10)]
|
||||
result, cap_id = autofocus_thing.check_stack_result(
|
||||
captures, check_turning_points=False
|
||||
)
|
||||
assert result == "success"
|
||||
assert cap_id == "mock-id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sharpnesses", "expected"),
|
||||
[
|
||||
([5, 10, 3], "success"),
|
||||
([10, 4, 2], "restart"),
|
||||
([1, 2, 10], "continue"),
|
||||
],
|
||||
)
|
||||
def test_check_stack_three_image_logic(sharpnesses, expected, autofocus_thing):
|
||||
"""For 3 images, success is the highest one is central."""
|
||||
captures = [mock_capture(i, s) for i, s in enumerate(sharpnesses)]
|
||||
result, _ = autofocus_thing.check_stack_result(captures, check_turning_points=False)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def _run_check_stack_with_good_peak(autofocus_thing, count_turnings=False):
|
||||
"""Run check stack on a good peak that should pass, and return the result.
|
||||
|
||||
This can be used to check how other mocked results of subfunctions affects the
|
||||
result.
|
||||
"""
|
||||
# Create an obvious peak that would normally pass.
|
||||
sharpnesses = [1, 2, 4, 7, 12, 7, 4, 2, 1]
|
||||
captures = [mock_capture(i, s) for i, s in enumerate(sharpnesses)]
|
||||
|
||||
result, cap_id = autofocus_thing.check_stack_result(
|
||||
captures, check_turning_points=count_turnings
|
||||
)
|
||||
# Nothing a mocked function does should change which is the sharpest image.
|
||||
assert cap_id == 4
|
||||
return result
|
||||
|
||||
|
||||
def test_check_stack_continues_if_no_tuning_point(autofocus_thing, mocker):
|
||||
"""Check that continue is returned if no turning point is found."""
|
||||
# Mock to simulate not finding a peak
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.things.autofocus._get_peak_turning_point",
|
||||
side_effect=NotAPeakError,
|
||||
)
|
||||
result = _run_check_stack_with_good_peak(autofocus_thing)
|
||||
# Check that the NotAPeakError causes it to continue instead.
|
||||
assert result == "continue"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("location", "expected"),
|
||||
[
|
||||
(-10, "restart"), # Restart if lower than 1.5 (halfway between im 2 and 3)
|
||||
(-1, "restart"),
|
||||
(0, "restart"),
|
||||
(1, "restart"),
|
||||
(1.49, "restart"),
|
||||
(1.5, "success"), # Success up to 6.5 (as we have 9 images, final index is 8)
|
||||
(2.5, "success"),
|
||||
(4.5, "success"),
|
||||
(6.5, "success"),
|
||||
(6.51, "continue"), # Continue if thrung point is after 6.5
|
||||
(7, "continue"),
|
||||
(8.1, "continue"),
|
||||
(123, "continue"),
|
||||
],
|
||||
)
|
||||
def test_check_stack_affected_by_turning_point_location(
|
||||
location, expected, autofocus_thing, mocker
|
||||
):
|
||||
"""Check that the turning point location affects the return as expected."""
|
||||
# Mock to give the turning point location specified
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.things.autofocus._get_peak_turning_point",
|
||||
return_value=location,
|
||||
)
|
||||
result = _run_check_stack_with_good_peak(autofocus_thing)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_check_stack_affected_by_number_of_turning_points(autofocus_thing, mocker):
|
||||
"""Check that the turning point location affects the return as expected."""
|
||||
# Set the turning point to the centre
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.things.autofocus._get_peak_turning_point",
|
||||
return_value=5,
|
||||
)
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.things.autofocus._count_turning_points",
|
||||
return_value=1,
|
||||
)
|
||||
result = _run_check_stack_with_good_peak(autofocus_thing, count_turnings=True)
|
||||
# Successful with 1 peak
|
||||
assert result == "success"
|
||||
|
||||
# Change return to be 2 peaks
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.things.autofocus._count_turning_points",
|
||||
return_value=2,
|
||||
)
|
||||
result = _run_check_stack_with_good_peak(autofocus_thing, count_turnings=True)
|
||||
# Continue with 2 peaks
|
||||
assert result == "continue"
|
||||
# Unless this check is turned off
|
||||
result = _run_check_stack_with_good_peak(autofocus_thing, count_turnings=False)
|
||||
assert result == "success"
|
||||
|
||||
|
||||
def test_get_peak_turning_point():
|
||||
"""Check that the peak fitting returns expected value (or error)."""
|
||||
with pytest.raises(NotAPeakError):
|
||||
_get_peak_turning_point(np.ones(9))
|
||||
|
||||
linear = np.arange(9)
|
||||
u_shape = 2 * (linear - 4) ** 2 + 17
|
||||
peak = -2 * (linear - 4) ** 2 + 55
|
||||
|
||||
with pytest.raises(NotAPeakError):
|
||||
_get_peak_turning_point(linear)
|
||||
|
||||
with pytest.raises(NotAPeakError):
|
||||
_get_peak_turning_point(u_shape)
|
||||
|
||||
# Should be 4 to within a fitting error
|
||||
assert abs(_get_peak_turning_point(peak) - 4) < 1e-7
|
||||
|
||||
|
||||
def test_count_turning_points():
|
||||
"""Check the turing point count works as expected."""
|
||||
linear = np.arange(9)
|
||||
u_shape = 2 * (linear - 4) ** 2 + 17
|
||||
peak = -2 * (linear - 4) ** 2 + 55
|
||||
assert _count_turning_points(np.ones(9)) == 0
|
||||
assert _count_turning_points(linear) == 0
|
||||
assert _count_turning_points(u_shape) == 1
|
||||
assert _count_turning_points(peak) == 1
|
||||
|
||||
assert _count_turning_points(np.array([1, 2, 3, 4, 5, 4, 3, 2, 1])) == 1
|
||||
# Double peak is 3 points
|
||||
assert _count_turning_points(np.array([1, 2, 3, 4, 2, 4, 3, 2, 1])) == 3
|
||||
# But only one if the dip isn't prominent
|
||||
assert _count_turning_points(np.array([1, 2, 3, 4, 3.8, 4, 3, 2, 1])) == 1
|
||||
312
tests/unit_tests/test_stage.py
Normal file
312
tests/unit_tests/test_stage.py
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
"""Test the stage without creating a full HTTP server and socket connection."""
|
||||
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
import labthings_fastapi as lt
|
||||
from labthings_fastapi.testing import create_thing_without_server
|
||||
|
||||
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
||||
from openflexure_microscope_server.things.stage import (
|
||||
BaseStage,
|
||||
RedefinedBaseMovementError,
|
||||
)
|
||||
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
||||
|
||||
from .utilities.lt_test_utils import LabThingsTestEnv
|
||||
|
||||
# Keep the size and number of moves fairly small or the tests can take forever
|
||||
point3d = st.tuples(
|
||||
st.integers(min_value=-100, max_value=100),
|
||||
st.integers(min_value=-100, max_value=100),
|
||||
st.integers(min_value=-100, max_value=100),
|
||||
)
|
||||
|
||||
path3d = st.lists(point3d, min_size=5, max_size=10)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_stage():
|
||||
"""Return a dummy stage with a very low step time."""
|
||||
return create_thing_without_server(DummyStage, step_time=0.000001)
|
||||
|
||||
|
||||
def test_override_base_movement():
|
||||
"""Child classes of stage should implement functions in the hardware reference frame.
|
||||
|
||||
``move_absolute`` and ``move_relative`` are in the program reference frame and
|
||||
convert to the hardware frame. It is recommended to override
|
||||
``_hardware_move_relative`` and ``_hardware_move_absolute`` instead.
|
||||
|
||||
Check that the expected error is raised if the methods are overridden.
|
||||
"""
|
||||
|
||||
class BadStage1(BaseStage):
|
||||
@lt.action
|
||||
def move_relative(self, block_cancellation: bool = False, **kwargs: int):
|
||||
pass
|
||||
|
||||
with pytest.raises(RedefinedBaseMovementError):
|
||||
create_thing_without_server(BadStage1)
|
||||
|
||||
class BadStage2(BaseStage):
|
||||
@lt.action
|
||||
def move_absolute(self, block_cancellation: bool = False, **kwargs: int):
|
||||
pass
|
||||
|
||||
with pytest.raises(RedefinedBaseMovementError):
|
||||
create_thing_without_server(BadStage2)
|
||||
|
||||
|
||||
def test_apply_axis_direction_all_pos(dummy_stage):
|
||||
"""Test the apply axis direction function behaves as expected when axis +v3."""
|
||||
# Directly create a stage not through a ThingServer to access private methods
|
||||
dummy_stage.axis_inverted = {"x": False, "y": False, "z": False}
|
||||
|
||||
# A list of positions to try
|
||||
positions = [
|
||||
[1, 2, 3], # list
|
||||
{"x": 1, "y": 2, "z": 3}, # mapping
|
||||
{"x": 1, "z": 2, "y": 3}, # mapping out of order
|
||||
{"x": 3}, # Mapping with only 1 value
|
||||
{"x": 1, "z": 2}, # Mapping with only 2 values
|
||||
]
|
||||
for pos in positions:
|
||||
assert dummy_stage._apply_axis_direction(pos) == pos
|
||||
|
||||
# Check tuple separately as it gets converted to list
|
||||
assert dummy_stage._apply_axis_direction((1, 2, 0)) == [1, 2, 0]
|
||||
|
||||
|
||||
def test_apply_axis_direction_mixed(dummy_stage):
|
||||
"""Test the apply axis direction function behaves as expected when axis dirs are mixed."""
|
||||
# Make x and z negative
|
||||
dummy_stage.axis_inverted = {"x": True, "y": False, "z": True}
|
||||
|
||||
# A list of (input position, output position) to try
|
||||
position_pairs = [
|
||||
([1, 2, 3], [-1, 2, -3]), # list
|
||||
([1, "2", "3"], [-1, 2, -3]), # list with strings
|
||||
((1, 2, 0), [-1, 2, 0]), # tuple (gets converted to list)
|
||||
({"x": 1, "y": 2, "z": 3}, {"x": -1, "y": 2, "z": -3}), # mapping
|
||||
({"x": 1, "z": 2, "y": 3}, {"x": -1, "z": -2, "y": 3}), # mapping out of order
|
||||
({"x": 1, "y": "2", "z": "3"}, {"x": -1, "y": 2, "z": -3}), # mapping w strings
|
||||
({"x": 3}, {"x": -3}), # Mapping with only 1 value
|
||||
({"x": 1, "z": 2}, {"x": -1, "z": -2}), # Mapping with only 2 values
|
||||
]
|
||||
for pos, expected_pos in position_pairs:
|
||||
assert dummy_stage._apply_axis_direction(pos) == expected_pos
|
||||
|
||||
|
||||
def test_apply_axis_errors(dummy_stage):
|
||||
"""Test the apply axis direction returns appropriate errors."""
|
||||
with pytest.raises(TypeError):
|
||||
dummy_stage._apply_axis_direction(None)
|
||||
with pytest.raises(TypeError):
|
||||
dummy_stage._apply_axis_direction("Onwards!")
|
||||
with pytest.raises(KeyError):
|
||||
dummy_stage._apply_axis_direction({"x": -1, "y": 2, "z": 4, "up": -3})
|
||||
with pytest.raises(KeyError):
|
||||
dummy_stage._apply_axis_direction({"x": -1, "y": 2, "up": -3})
|
||||
|
||||
|
||||
def test_default_values(dummy_stage):
|
||||
"""Check the default values for the dummy stage."""
|
||||
# axes are x, y, z (note that going through the thing, client the tuple is
|
||||
# converted to a list.
|
||||
assert dummy_stage.axis_names == ("x", "y", "z")
|
||||
# position starts at 0, 0, 0
|
||||
assert dummy_stage.position == {"x": 0, "y": 0, "z": 0}
|
||||
# axis direction starts is -1, 1, 1 for the dummy stage
|
||||
assert dummy_stage.axis_inverted == {"x": True, "y": False, "z": False}
|
||||
# And check the thing state
|
||||
assert dummy_stage.thing_state == {"position": {"x": 0, "y": 0, "z": 0}}
|
||||
|
||||
|
||||
def test_direction_inversion(dummy_stage):
|
||||
"""Check axes invert as expected when called."""
|
||||
# Check initial value
|
||||
assert dummy_stage.axis_inverted == {"x": True, "y": False, "z": False}
|
||||
# Start inverting
|
||||
dummy_stage.invert_axis_direction(axis="x")
|
||||
assert dummy_stage.axis_inverted == {"x": False, "y": False, "z": False}
|
||||
dummy_stage.invert_axis_direction(axis="x")
|
||||
assert dummy_stage.axis_inverted == {"x": True, "y": False, "z": False}
|
||||
dummy_stage.invert_axis_direction(axis="y")
|
||||
assert dummy_stage.axis_inverted == {"x": True, "y": True, "z": False}
|
||||
dummy_stage.invert_axis_direction(axis="y")
|
||||
assert dummy_stage.axis_inverted == {"x": True, "y": False, "z": False}
|
||||
dummy_stage.invert_axis_direction(axis="z")
|
||||
assert dummy_stage.axis_inverted == {"x": True, "y": False, "z": True}
|
||||
dummy_stage.invert_axis_direction(axis="z")
|
||||
assert dummy_stage.axis_inverted == {"x": True, "y": False, "z": False}
|
||||
|
||||
|
||||
def test_direction_errors_local_and_http():
|
||||
"""Check for expected errors both locally and over http."""
|
||||
thing_conf = {"camera": SimulatedCamera, "stage": DummyStage}
|
||||
with LabThingsTestEnv(things=thing_conf) as test_env:
|
||||
dummy_stage = test_env.get_thing_by_type(DummyStage)
|
||||
stage_client = test_env.get_thing_client("stage")
|
||||
|
||||
assert stage_client.axis_inverted == {"x": True, "y": False, "z": False}
|
||||
# Can't set an arbitrary value via a client as read only:
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
stage_client.axis_inverted = {"x": 2, "y": 1, "z": 1}
|
||||
# Read only should set a 405 error code ...
|
||||
assert excinfo.value.response.status_code == 405
|
||||
|
||||
# ... and should not modify the initial value
|
||||
assert stage_client.axis_inverted == {"x": True, "y": False, "z": False}
|
||||
|
||||
# Should error if axis doesn't exist, this is a KeyError in the server
|
||||
with pytest.raises(KeyError):
|
||||
dummy_stage.invert_axis_direction(axis="theta")
|
||||
# But a 422 over HTTP
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
stage_client.invert_axis_direction(axis="theta")
|
||||
assert excinfo.value.response.status_code == 422
|
||||
|
||||
|
||||
def _test_move_relative(dummy_stage, axis_inverted, path):
|
||||
"""Test moving relative, ensuring position and hardware position behave as expected.
|
||||
|
||||
:param axis_inverted: Is used to set the inversion.
|
||||
:param path: The 3d path to move over, generated by hypothesis.
|
||||
"""
|
||||
dummy_stage.axis_inverted = axis_inverted
|
||||
# Explicitly do axes calculation here to check logic in main code.
|
||||
x_dir = -1 if axis_inverted["x"] else 1
|
||||
y_dir = -1 if axis_inverted["y"] else 1
|
||||
z_dir = -1 if axis_inverted["z"] else 1
|
||||
|
||||
position = list(dummy_stage.position.values())
|
||||
for movement in path:
|
||||
dummy_stage.move_relative(x=movement[0], y=movement[1], z=movement[2])
|
||||
position = [pos + move for pos, move in zip(position, movement, strict=True)]
|
||||
stage_pos = dummy_stage.get_xyz_position()
|
||||
hw_pos = dummy_stage._hardware_position
|
||||
assert position[0] == stage_pos[0] == hw_pos["x"] * x_dir
|
||||
assert position[1] == stage_pos[1] == hw_pos["y"] * y_dir
|
||||
assert position[2] == stage_pos[2] == hw_pos["z"] * z_dir
|
||||
|
||||
|
||||
@given(path=path3d)
|
||||
@settings(
|
||||
max_examples=3,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
deadline=10000,
|
||||
)
|
||||
def test_move_relative(dummy_stage, path):
|
||||
"""Loop over different inversion options and check that the stage moves as expected.
|
||||
|
||||
3 paths are tried for each case of axis inversion. This checks both that the
|
||||
reported position changes as is input in path, and that the hardware position is
|
||||
inverted when appropriate.
|
||||
"""
|
||||
# Note that the fixture is not reset. This is fine, because the stage_should work
|
||||
# no matter the starting position.
|
||||
|
||||
axis_names = dummy_stage.axis_names
|
||||
# Create every combination of True/False for x,y,z.
|
||||
inversion_combinations = [
|
||||
dict(zip(axis_names, inverted, strict=True))
|
||||
for inverted in itertools.product([True, False], repeat=len(axis_names))
|
||||
]
|
||||
print(path)
|
||||
for axis_inverted in inversion_combinations:
|
||||
_test_move_relative(
|
||||
dummy_stage=dummy_stage,
|
||||
axis_inverted=axis_inverted,
|
||||
path=path,
|
||||
)
|
||||
|
||||
|
||||
def _test_move_absolute(dummy_stage, axis_inverted, path):
|
||||
"""Test moving relative, ensuring position and hardware position behave as expected.
|
||||
|
||||
:param axis_inverted: Is used to set the inversion.
|
||||
:param path: The 3d path to move over, generated by hypothesis.
|
||||
"""
|
||||
dummy_stage.axis_inverted = axis_inverted
|
||||
# Explicitly do axes calculation here to check logic in main code.
|
||||
x_dir = -1 if axis_inverted["x"] else 1
|
||||
y_dir = -1 if axis_inverted["y"] else 1
|
||||
z_dir = -1 if axis_inverted["z"] else 1
|
||||
position = list(dummy_stage.position.values())
|
||||
for move_to in path:
|
||||
dummy_stage.move_absolute(x=move_to[0], y=move_to[1], z=move_to[2])
|
||||
position = move_to
|
||||
stage_pos = dummy_stage.get_xyz_position()
|
||||
hw_pos = dummy_stage._hardware_position
|
||||
assert position[0] == stage_pos[0] == hw_pos["x"] * x_dir
|
||||
assert position[1] == stage_pos[1] == hw_pos["y"] * y_dir
|
||||
assert position[2] == stage_pos[2] == hw_pos["z"] * z_dir
|
||||
|
||||
|
||||
@given(path=path3d)
|
||||
@settings(
|
||||
max_examples=3,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
deadline=10000,
|
||||
)
|
||||
def test_move_absolute(dummy_stage, path):
|
||||
"""Loop over different inversion options and check that the stage moves as expected.
|
||||
|
||||
3 paths are tried for each case of axis inversion. This checks both that the
|
||||
reported position changes as is input in path, and that the hardware position is
|
||||
inverted when appropriate.
|
||||
"""
|
||||
# Note that the fixture is not reset. This is fine, because the stage_should work
|
||||
# no matter the starting position.
|
||||
|
||||
axis_names = dummy_stage.axis_names
|
||||
# Create every combination of True/False for x,y,z.
|
||||
inversion_combinations = [
|
||||
dict(zip(axis_names, inverted, strict=True))
|
||||
for inverted in itertools.product([True, False], repeat=len(axis_names))
|
||||
]
|
||||
for axis_inverted in inversion_combinations:
|
||||
_test_move_absolute(
|
||||
dummy_stage=dummy_stage,
|
||||
axis_inverted=axis_inverted,
|
||||
path=path,
|
||||
)
|
||||
|
||||
|
||||
def test_thing_description_equivalence(dummy_stage, mocker):
|
||||
"""Stop extra actions getting added to child classes without explicit approval.
|
||||
|
||||
To add an extra action to a stage this test needs to be updated, highlighting it at
|
||||
review. This tests should explain why the action isn't on the general base class.
|
||||
"""
|
||||
mock_sangaboard = mocker.Mock()
|
||||
mocker.patch.dict("sys.modules", {"sangaboard": mock_sangaboard})
|
||||
from openflexure_microscope_server.things.stage.sangaboard import SangaboardThing
|
||||
|
||||
# Flash LED isn't a standard stage action, most stages do not control illumination.
|
||||
extra_sanga_actions = ["flash_led"]
|
||||
|
||||
base_td = create_thing_without_server(BaseStage).thing_description()
|
||||
base_actions = set(base_td.actions.keys())
|
||||
base_properties = set(base_td.properties.keys())
|
||||
|
||||
dummy_td = dummy_stage.thing_description()
|
||||
dummy_actions = set(dummy_td.actions.keys())
|
||||
dummy_properties = set(dummy_td.properties.keys())
|
||||
|
||||
sanga_td = create_thing_without_server(SangaboardThing).thing_description()
|
||||
# Remove known extra actions
|
||||
sanga_actions = list(sanga_td.actions.keys())
|
||||
for action in extra_sanga_actions:
|
||||
index = sanga_actions.index(action)
|
||||
sanga_actions.pop(index)
|
||||
sanga_actions = set(sanga_actions)
|
||||
sanga_properties = set(sanga_td.properties.keys())
|
||||
|
||||
assert sanga_actions == dummy_actions == base_actions
|
||||
assert sanga_properties == dummy_properties == base_properties
|
||||
733
tests/unit_tests/test_stage_measure.py
Normal file
733
tests/unit_tests/test_stage_measure.py
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
"""File contains unit tests for stage_measure."""
|
||||
|
||||
import logging
|
||||
from copy import copy
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from labthings_fastapi.testing import create_thing_without_server
|
||||
|
||||
from openflexure_microscope_server.things import stage_measure
|
||||
from openflexure_microscope_server.things.camera_stage_mapping import (
|
||||
csm_img_to_stage,
|
||||
csm_stage_to_img,
|
||||
)
|
||||
|
||||
|
||||
# Useful generators
|
||||
def increasing_xy_dict_generator(*_args, **_kwargs):
|
||||
"""Generate x-y dictionaries of incrementing sizes.
|
||||
|
||||
These don't simulate expected effects, but allow checking sequential reads of a
|
||||
function/property were used.
|
||||
"""
|
||||
i = 0
|
||||
while True:
|
||||
yield {"x": i, "y": i}
|
||||
i += 1
|
||||
|
||||
|
||||
def increasing_xyz_dict_generator(*_args, **_kwargs):
|
||||
"""Generate x-y-z dictionaries of incrementing sizes.
|
||||
|
||||
These don't simulate expected effects, but allow checking sequential reads of a
|
||||
function/property were used.
|
||||
"""
|
||||
i = 0
|
||||
while True:
|
||||
yield {"x": i, "y": i, "z": i}
|
||||
i += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def csm_matrix():
|
||||
"""Return an example CSM matrix."""
|
||||
return [
|
||||
[0.03061156624485296, -1.8031242270940833],
|
||||
[1.773236372778601, 0.006660431608601435],
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def example_rom_data():
|
||||
"""Return some example data in a RomDataTracker."""
|
||||
mock_positions = [
|
||||
{"x": 0, "y": 0, "z": 42},
|
||||
{"x": 727, "y": 2, "z": 154},
|
||||
{"x": 1454, "y": 4, "z": 248},
|
||||
{"x": 2181, "y": 6, "z": 351},
|
||||
{"x": 2908, "y": 8, "z": 430},
|
||||
{"x": 3635, "y": 10, "z": 490},
|
||||
]
|
||||
rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# loop through mock positions recording them with an offset.
|
||||
for position in mock_positions:
|
||||
offset = {"x": 54.4, "y": 0}
|
||||
rom_data.record_movement(position, offset)
|
||||
return rom_data
|
||||
|
||||
|
||||
def test_predict_z(example_rom_data):
|
||||
"""Check that the prediction for the next z position is correct."""
|
||||
mock_z_diff = example_rom_data.predict_z_displacement(
|
||||
axis="x",
|
||||
stage_movement={"x": 5243, "y": 0},
|
||||
stage_position={"x": 3635, "y": 10, "z": 490},
|
||||
)
|
||||
expected_z_diff = 153
|
||||
assert mock_z_diff == expected_z_diff
|
||||
|
||||
|
||||
def test_find_tuning_point(example_rom_data):
|
||||
"""Check that the prediction for the tuning point and z position."""
|
||||
mock_turn = example_rom_data.find_turning_point(axis="x")
|
||||
expected_turning = {"x": 7580, "y": 10, "z": 661}
|
||||
assert mock_turn == expected_turning
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("movement", "axis", "other_axis"),
|
||||
[
|
||||
({"x": 2908, "y": 0}, "x", "y"),
|
||||
({"x": -29, "y": 0}, "x", "y"),
|
||||
({"x": 0, "y": 123}, "y", "x"),
|
||||
({"x": 0, "y": -456}, "y", "x"),
|
||||
],
|
||||
)
|
||||
def test_axis_from_movement_dict(movement, axis, other_axis):
|
||||
"""Test that _axis_from_movement_dict identifies the correct axes."""
|
||||
assert stage_measure._axis_from_movement_dict(movement) == axis
|
||||
|
||||
ret_axes = stage_measure._axis_from_movement_dict(movement, return_other=True)
|
||||
assert ret_axes == (axis, other_axis)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("movement", [{"x": 0, "y": 0}, {"x": 10, "y": 10}])
|
||||
def test_error_on_axis_from_movement_dict(movement):
|
||||
"""Check _axis_from_movement_dict errors if both axes are zero, or both are non-zero."""
|
||||
with pytest.raises(ValueError, match="Either x or y movement should be zero"):
|
||||
assert stage_measure._axis_from_movement_dict(movement)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("par_fraction", "too_high"),
|
||||
[
|
||||
(-0.20, True),
|
||||
(-0.11, True),
|
||||
(-0.09, False),
|
||||
(-0.05, False),
|
||||
(0.00, False),
|
||||
(0.05, False),
|
||||
(0.09, False),
|
||||
(0.11, True),
|
||||
(0.20, True),
|
||||
],
|
||||
)
|
||||
def test_parasitic_detect(par_fraction, too_high):
|
||||
"""Check parasitic motion is detected if the fraction of parasitic motion is too high."""
|
||||
movement = {"x": 2908, "y": 0}
|
||||
|
||||
offset = copy(movement)
|
||||
offset["y"] = movement["x"] * par_fraction
|
||||
|
||||
detected = stage_measure._parasitic_motion_detected(
|
||||
movement=movement, offset=offset
|
||||
)
|
||||
assert detected == too_high
|
||||
|
||||
|
||||
def test_error_if_no_stream_res_set_when_requesting_img_coords():
|
||||
"""Check a RuntimeError thrown when requesting image coordinates if resolution unset."""
|
||||
rom_thing = create_thing_without_server(
|
||||
stage_measure.RangeofMotionThing, mock_all_slots=True
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Stream resolution must be set"):
|
||||
rom_thing._img_percentage_to_img_coords(20, "x")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rom_thing(example_rom_data, csm_matrix) -> stage_measure.RangeofMotionThing:
|
||||
"""Yield a RangeofMotionThing already populated with some example rom_data."""
|
||||
rom_thing = create_thing_without_server(
|
||||
stage_measure.RangeofMotionThing, mock_all_slots=True
|
||||
)
|
||||
rom_thing._stream_resolution = [800, 600]
|
||||
rom_thing._rom_data = example_rom_data
|
||||
|
||||
def apply_csm(x: float, y: float, **_kwargs: float) -> dict[str, int]:
|
||||
"""Convert image coordinates to stage coordinates."""
|
||||
return csm_img_to_stage(csm_matrix, x=x, y=y)
|
||||
|
||||
def un_apply_csm(x: float, y: float, **_kwargs: float) -> dict[str, float]:
|
||||
"""Convert stage coordinates to image coordinates."""
|
||||
return csm_stage_to_img(csm_matrix, x=x, y=y)
|
||||
|
||||
rom_thing._cam.image_is_sample.return_value = (True, "Mocked not measured.")
|
||||
|
||||
# Set up mock csm to return a CSM matrix
|
||||
rom_thing._csm.image_to_stage_displacement_matrix = csm_matrix
|
||||
rom_thing._csm.convert_image_to_stage_coordinates.side_effect = apply_csm
|
||||
rom_thing._csm.convert_stage_to_image_coordinates.side_effect = un_apply_csm
|
||||
return rom_thing
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pos", "target_pos", "axis", "expected_dir"),
|
||||
[
|
||||
({"x": 5000, "y": 0, "z": 0}, {"x": 0, "y": 0, "z": 0}, "x", -1),
|
||||
({"x": -5000, "y": 0, "z": 0}, {"x": 0, "y": 0, "z": 0}, "x", 1),
|
||||
({"x": 0, "y": 0, "z": 0}, {"x": 5000, "y": 0, "z": 0}, "x", 1),
|
||||
# Y is flipped due to CSM sign
|
||||
({"x": 0, "y": 5000, "z": 0}, {"x": 0, "y": 0, "z": 0}, "y", 1),
|
||||
],
|
||||
)
|
||||
def test_img_dir_from_stage_coords(pos, target_pos, axis, expected_dir, rom_thing):
|
||||
"""Check image direction is correctly generated."""
|
||||
rom_thing._stage.position = pos
|
||||
direction = rom_thing._img_dir_from_stage_coords(target_pos, axis)
|
||||
assert direction == expected_dir
|
||||
|
||||
|
||||
def test_distance_in_img_percentage_err(rom_thing):
|
||||
"""Check that _distance_in_img_percentage throws error if stream res is not set."""
|
||||
rom_thing._stream_resolution = None
|
||||
with pytest.raises(RuntimeError):
|
||||
rom_thing._distance_in_img_percentage({"x": 0, "y": 0, "z": 0}, "x")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pos", "target_pos", "axis", "expected_perc"),
|
||||
[
|
||||
({"x": 5000, "y": 0, "z": 0}, {"x": 0, "y": 0, "z": 0}, "x", -352.44),
|
||||
({"x": -5000, "y": 0, "z": 0}, {"x": 0, "y": 0, "z": 0}, "x", 352.44),
|
||||
({"x": 0, "y": 0, "z": 0}, {"x": 5000, "y": 0, "z": 0}, "x", 352.44),
|
||||
# Y is flipped due to CSM sign
|
||||
({"x": 0, "y": 5000, "z": 0}, {"x": 0, "y": 0, "z": 0}, "y", 462.131),
|
||||
],
|
||||
)
|
||||
def test_distance_in_img_percentage(pos, target_pos, axis, expected_perc, rom_thing):
|
||||
"""Check _distance_in_img_percentage calculates expected values based on mock CSM."""
|
||||
rom_thing._stage.position = pos
|
||||
img_perc = rom_thing._distance_in_img_percentage(target_pos, axis)
|
||||
assert round(img_perc, 3) == expected_perc
|
||||
|
||||
|
||||
def test_offset_from(rom_thing, mocker):
|
||||
"""Check the calls and returns for RangeofMotionThing._offset_from."""
|
||||
# Set up mock for the FFT displacement
|
||||
disp_between_route = (
|
||||
"openflexure_microscope_server.things.stage_measure."
|
||||
"fft_image_tracking.displacement_between_images"
|
||||
)
|
||||
mock_disp_between = mocker.patch(
|
||||
disp_between_route,
|
||||
return_value=[123, 456],
|
||||
)
|
||||
|
||||
# Run it
|
||||
offset = rom_thing._offset_from(before_img="MOCK_IMAGE")
|
||||
|
||||
# Check the offset is a dictionary with the correct values for the axes
|
||||
assert offset["x"] == 456
|
||||
assert offset["y"] == 123
|
||||
# Check 1 image was taken
|
||||
assert rom_thing._cam.grab_as_array.call_count == 1
|
||||
mock_after_image = rom_thing._cam.grab_as_array.return_value
|
||||
# Check the FFT displacement was called once
|
||||
assert mock_disp_between.call_count == 1
|
||||
displacement_kwargs = mock_disp_between.call_args.kwargs
|
||||
# Check the image inputs
|
||||
assert displacement_kwargs["image_0"] == "MOCK_IMAGE"
|
||||
assert displacement_kwargs["image_1"] == mock_after_image
|
||||
|
||||
|
||||
@pytest.mark.parametrize("perform_autofocus", [True, False])
|
||||
def test_move_and_measure(perform_autofocus, rom_thing, mocker):
|
||||
"""Test _move_and_measure with and without initial autofocus checking call counts.
|
||||
|
||||
This doesn't test with the repeate autofocus if motion isn't detected
|
||||
"""
|
||||
mock_offset_value = {"x": 100, "y": 3}
|
||||
mocker.patch.object(rom_thing, "_offset_from", return_value=mock_offset_value)
|
||||
movement = {"x": 100, "y": 0}
|
||||
offset = rom_thing._move_and_measure(
|
||||
movement=movement, perform_autofocus=perform_autofocus
|
||||
)
|
||||
|
||||
# Check exactly 1 move
|
||||
assert rom_thing._csm.move_in_image_coordinates.call_count == 1
|
||||
# The kwargs of the call should movement dict
|
||||
assert rom_thing._csm.move_in_image_coordinates.call_args.kwargs == movement
|
||||
# Check autofocus call count
|
||||
expected_af_count = 1 if perform_autofocus else 0
|
||||
assert rom_thing._autofocus.looping_autofocus.call_count == expected_af_count
|
||||
# And check final return
|
||||
assert offset == mock_offset_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("x_offsets", "n_offset_measures", "expected_return"),
|
||||
[
|
||||
([5.1, 0.2], 1, {"x": 5.1, "y": 0}),
|
||||
([-5.1, 0.2], 1, {"x": -5.1, "y": 0}),
|
||||
([0.1, 5.2, 0.3, 0.4, 0.5], 2, {"x": 5.2, "y": 0}),
|
||||
([0.1, 0.2, 5.3, 0.4, 0.5], 3, {"x": 5.3, "y": 0}),
|
||||
([0.1, 0.2, 0.3, 5.4, 0.5], 4, {"x": 5.4, "y": 0}),
|
||||
# n_offset_measures shouldn't go higher than 4 as max autofocus repeats is 3
|
||||
([0.1, 0.2, 0.3, 0.4, 5.5], 4, {"x": 0.4, "y": 0}),
|
||||
],
|
||||
)
|
||||
def test_move_and_measure_with_refocus(
|
||||
x_offsets, n_offset_measures, expected_return, rom_thing, mocker
|
||||
):
|
||||
"""Test _move_and_measure with final refocus if offset is too small."""
|
||||
return_dicts = tuple({"x": x, "y": 0} for x in x_offsets)
|
||||
offset_from_mock = mocker.patch.object(
|
||||
rom_thing, "_offset_from", side_effect=return_dicts
|
||||
)
|
||||
movement = {"x": 10, "y": 0}
|
||||
offset = rom_thing._move_and_measure(
|
||||
movement=movement,
|
||||
perform_autofocus=False,
|
||||
max_autofocus_repeats=3,
|
||||
abs_min_offset=5,
|
||||
)
|
||||
# Check exactly 1 move
|
||||
assert rom_thing._csm.move_in_image_coordinates.call_count == 1
|
||||
# Check expected _offset_from calls
|
||||
assert offset_from_mock.call_count == n_offset_measures
|
||||
# The kwargs of the call should movement dict
|
||||
assert rom_thing._csm.move_in_image_coordinates.call_args.kwargs == movement
|
||||
# Check autofocus call count is 1 less than number of offset measures as no autofocus
|
||||
# is performed before the first one
|
||||
expected_af_count = n_offset_measures - 1
|
||||
assert rom_thing._autofocus.looping_autofocus.call_count == expected_af_count
|
||||
# And check final return
|
||||
assert offset == expected_return
|
||||
|
||||
|
||||
def test_move_and_measure_with_bad_refocus_args(rom_thing, mocker):
|
||||
"""Check error if abs_min_offset is not positive when using it to determine if to autofocus."""
|
||||
offset_from_mock = mocker.patch.object(
|
||||
rom_thing, "_offset_from", return_value={"x": 0, "y": 0}
|
||||
)
|
||||
|
||||
# First check with abs_min_offset not set. The default should be zero.
|
||||
with pytest.raises(ValueError, match="abs_min_offset must be positive"):
|
||||
rom_thing._move_and_measure(
|
||||
movement={"x": 10, "y": 0},
|
||||
perform_autofocus=False,
|
||||
max_autofocus_repeats=3,
|
||||
)
|
||||
# Then check with abs_min_offset negative, this might happen if the the expected
|
||||
# move calculation is not made absolute.
|
||||
with pytest.raises(ValueError, match="abs_min_offset must be positive"):
|
||||
rom_thing._move_and_measure(
|
||||
movement={"x": 10, "y": 0},
|
||||
perform_autofocus=False,
|
||||
max_autofocus_repeats=3,
|
||||
abs_min_offset=-123.456,
|
||||
)
|
||||
# Should have never measured and offset. Just error straight away.
|
||||
assert offset_from_mock.call_count == 0
|
||||
|
||||
|
||||
def test_move_back_until_motion_detected(rom_thing, mocker):
|
||||
"""Check that _move_back_until_motion_detected is making increasing negative moves.
|
||||
|
||||
The moves for this method should be in opposite direction to the direction
|
||||
specified as this is moving back after the stage reaches end of its movement.
|
||||
"""
|
||||
mock_move_n_meas = mocker.patch.object(
|
||||
rom_thing, "_move_and_measure", return_value={"x": 0, "y": 0}
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cannot detect motion again"):
|
||||
rom_thing._move_back_until_motion_detected("y", -1)
|
||||
|
||||
max_tries = int(1.5 * stage_measure.BIG_STEP / stage_measure.SMALL_STEP)
|
||||
expected_step = 800 * stage_measure.SMALL_STEP / 100
|
||||
assert mock_move_n_meas.call_count == max_tries
|
||||
for _i, call_args in enumerate(mock_move_n_meas.call_args_list):
|
||||
call_args.kwargs["movement"] = {"x": 0, "y": expected_step}
|
||||
call_args.kwargs["perform_autofocus"] = False
|
||||
|
||||
## Reset mock and change the side effect
|
||||
mock_move_n_meas.reset_mock()
|
||||
mock_move_n_meas.side_effect = (
|
||||
{"x": 0, "y": 0},
|
||||
{"x": 0, "y": 0},
|
||||
{"x": expected_step * 0.8, "y": 0},
|
||||
)
|
||||
|
||||
# Other axis and direction this time
|
||||
rom_thing._move_back_until_motion_detected("x", 1)
|
||||
|
||||
# Should only be called 3 times
|
||||
assert mock_move_n_meas.call_count == 3
|
||||
for _i, call_args in enumerate(mock_move_n_meas.call_args_list):
|
||||
call_args.kwargs["movement"] = {"x": -expected_step, "y": 0}
|
||||
call_args.kwargs["perform_autofocus"] = False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("good_moves", "expected_to_detect_motion", "offset_calls"),
|
||||
[
|
||||
([0, 1, 2], True, 3), # First 3 pass all good
|
||||
([1, 2, 3], True, 4), # First is bad, will refocus, still complete
|
||||
([2, 3, 4], True, 5), # First 2 are bad, will refocus twice, still complete
|
||||
([3, 4, 5], True, 6), # First 3 are bad, will refocus 3 times, still complete
|
||||
([4, 5, 6], False, 4), # First 4 are bad, fails
|
||||
# Check second and 3rd measurement can fail after first fails 3 times
|
||||
([3, 5, 7], True, 8),
|
||||
([3, 6, 9], True, 10),
|
||||
([3, 7, 11], True, 12),
|
||||
# But they can't fail 4 times
|
||||
([3, 8, 9], False, 8),
|
||||
([3, 7, 12], False, 12),
|
||||
],
|
||||
)
|
||||
def test_stage_still_moves(
|
||||
good_moves,
|
||||
expected_to_detect_motion,
|
||||
offset_calls,
|
||||
rom_thing,
|
||||
mocker,
|
||||
):
|
||||
"""Test _stage_still_moves correctly detects stage movement."""
|
||||
min_offset = 800 * stage_measure.SMALL_STEP / 100 * stage_measure.DETECT_MOTION_TOL
|
||||
|
||||
def gen_offsets(*_args, **_kwargs):
|
||||
"""Generate offset dictionaries with small moves unless count matches ``good_moves``."""
|
||||
i = 0
|
||||
while True:
|
||||
x = min_offset * 1.2 if i in good_moves else 0.1
|
||||
yield {"x": x, "y": 0}
|
||||
i += 1
|
||||
|
||||
mock_offset_from = mocker.patch.object(
|
||||
rom_thing, "_offset_from", side_effect=gen_offsets()
|
||||
)
|
||||
|
||||
still_moves = rom_thing._stage_still_moves(axis="x", direction=1)
|
||||
assert still_moves is expected_to_detect_motion
|
||||
assert mock_offset_from.call_count == offset_calls
|
||||
|
||||
|
||||
def test_big_z_corrected_movement(rom_thing):
|
||||
"""Check big z corrected move moves in x/y and z the expected distances."""
|
||||
rom_thing._stage.position = {"x": 5000, "y": 30, "z": 500}
|
||||
|
||||
rom_thing._big_z_corrected_movement("x", direction=1)
|
||||
|
||||
expected_movement = {"x": 800 * stage_measure.BIG_STEP / 100, "y": 0}
|
||||
|
||||
# Check there is one z move in steps
|
||||
assert rom_thing._stage.move_relative.call_count == 1
|
||||
move_kwargs = rom_thing._stage.move_relative.call_args.kwargs
|
||||
assert "x" not in move_kwargs
|
||||
assert "y" not in move_kwargs
|
||||
assert "z" in move_kwargs
|
||||
assert move_kwargs["z"] == 160
|
||||
|
||||
# And one move in image coordinates
|
||||
assert rom_thing._csm.move_in_image_coordinates.call_count == 1
|
||||
lat_mov_kwargs = rom_thing._csm.move_in_image_coordinates.call_args.kwargs
|
||||
assert lat_mov_kwargs == expected_movement
|
||||
|
||||
|
||||
def test_moves_for_z_prediction(rom_thing, mocker):
|
||||
"""Check the initial moves are of the correct size and are recorded."""
|
||||
# Mock the _offset_from and stage.position to return generated dictionaries that
|
||||
# increment each time they are called. (All values 0 the first time, all values 1
|
||||
# the second time ...)
|
||||
mocker.patch.object(
|
||||
rom_thing, "_offset_from", side_effect=increasing_xy_dict_generator()
|
||||
)
|
||||
type(rom_thing._stage).position = mocker.PropertyMock(
|
||||
side_effect=increasing_xyz_dict_generator()
|
||||
)
|
||||
|
||||
expected_movement = {"x": -800 * stage_measure.MEDIUM_STEP / 100, "y": 0}
|
||||
|
||||
# Remove the mock RomData before starting
|
||||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# Run it!
|
||||
rom_thing._moves_for_z_prediction("x", direction=-1)
|
||||
|
||||
# Check that _rom_data now contains the 5 mocked returns in order.
|
||||
assert rom_thing._rom_data.offsets == [{"x": i, "y": i} for i in range(5)]
|
||||
assert rom_thing._rom_data.stage_coords == [
|
||||
{"x": i, "y": i, "z": i} for i in range(5)
|
||||
]
|
||||
|
||||
# Check that the csm movement function is called 5 times
|
||||
assert rom_thing._csm.move_in_image_coordinates.call_count == 5
|
||||
# Each time with the expected movement
|
||||
for arg_list in rom_thing._csm.move_in_image_coordinates.call_args_list:
|
||||
assert arg_list.kwargs == expected_movement
|
||||
|
||||
|
||||
def test_move_until_edge_error(rom_thing, mocker):
|
||||
"""Check that if there is an error while moving to the edge the stage returns to start."""
|
||||
mock_position_dict = {"x": 123, "y": 456, "z": 789}
|
||||
type(rom_thing._stage).position = mocker.PropertyMock(
|
||||
return_value=mock_position_dict
|
||||
)
|
||||
mocker.patch.object(
|
||||
rom_thing, "_moves_for_z_prediction", side_effect=RuntimeError("Mock")
|
||||
)
|
||||
|
||||
# Remove the mock RomData before starting
|
||||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# Error should be raised even though it is in the Try:
|
||||
with pytest.raises(RuntimeError, match="Mock"):
|
||||
rom_thing._move_until_edge("y", direction=-1)
|
||||
# However the "finally" should have executed returning to the starting position
|
||||
assert rom_thing._stage.move_absolute.call_count == 1
|
||||
abs_move_kwargs = rom_thing._stage.move_absolute.call_args.kwargs
|
||||
expected_abs_move_kwargs = dict(**mock_position_dict, block_cancellation=True)
|
||||
assert abs_move_kwargs == expected_abs_move_kwargs
|
||||
|
||||
|
||||
def test_move_until_edge(rom_thing, mocker):
|
||||
"""Check move until edge runs the correct movement sequence."""
|
||||
# Remove the mock RomData before starting
|
||||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
rom_thing._stage.position = {"x": "mock", "y": "starting", "z": "pos"}
|
||||
|
||||
def add_fake_initial_positions(*_args, **_kwargs):
|
||||
"""Rather than run initial moves just add some fake data."""
|
||||
for i in range(5):
|
||||
rom_thing._rom_data.record_movement(f"mock-init-pos{i + 1}", "mock-offset")
|
||||
|
||||
def update_stage_pos_on_big_move(*_args, **_kwargs):
|
||||
"""With big moves update the stage position."""
|
||||
big_move_count = 1
|
||||
while True:
|
||||
rom_thing._stage.position = f"mocked-big-move-pos{big_move_count}"
|
||||
yield
|
||||
big_move_count += 1
|
||||
|
||||
def set_final_pos(*_args, **_kwargs):
|
||||
"""When move_back_until_motion_detected is run set a final stage position."""
|
||||
rom_thing._stage.position = "mock-final-pos"
|
||||
|
||||
# Mock the main movement functions
|
||||
mock_init_moves = mocker.patch.object(
|
||||
rom_thing,
|
||||
"_moves_for_z_prediction",
|
||||
side_effect=add_fake_initial_positions,
|
||||
)
|
||||
mock_big_moves = mocker.patch.object(
|
||||
rom_thing,
|
||||
"_big_z_corrected_movement",
|
||||
side_effect=update_stage_pos_on_big_move(),
|
||||
)
|
||||
mock_move_check = mocker.patch.object(
|
||||
rom_thing, "_stage_still_moves", side_effect=[True] * 9 + [False]
|
||||
)
|
||||
mock_move_back = mocker.patch.object(
|
||||
rom_thing, "_move_back_until_motion_detected", side_effect=set_final_pos
|
||||
)
|
||||
|
||||
# Remove the mock RomData before starting
|
||||
rom_thing._rom_data = stage_measure.RomDataTracker()
|
||||
|
||||
# Run function
|
||||
rom_thing._move_until_edge("y", direction=-1)
|
||||
|
||||
# Check the call counts are as expected
|
||||
# One call of initial moves
|
||||
assert mock_init_moves.call_count == 1
|
||||
# Big moves and the check the stage is moving are each called 10 times as the mock
|
||||
# for _stage_still_moves replies False on the 10th call.
|
||||
assert mock_big_moves.call_count == 10
|
||||
assert mock_move_check.call_count == 10
|
||||
# And one call of _move_back_until_motion_detected
|
||||
assert mock_move_back.call_count == 1
|
||||
|
||||
assert rom_thing._rom_data.stage_coords == [
|
||||
{"x": "mock", "y": "starting", "z": "pos"},
|
||||
"mock-init-pos1",
|
||||
"mock-init-pos2",
|
||||
"mock-init-pos3",
|
||||
"mock-init-pos4",
|
||||
"mock-init-pos5",
|
||||
"mocked-big-move-pos1",
|
||||
"mocked-big-move-pos2",
|
||||
"mocked-big-move-pos3",
|
||||
"mocked-big-move-pos4",
|
||||
"mocked-big-move-pos5",
|
||||
"mocked-big-move-pos6",
|
||||
"mocked-big-move-pos7",
|
||||
"mocked-big-move-pos8",
|
||||
"mocked-big-move-pos9",
|
||||
"mock-final-pos", # This overwrites the 10th big move position recording.
|
||||
]
|
||||
|
||||
|
||||
def test_perform_rom_actions_locked(rom_thing):
|
||||
"""Check the error if running one of the calibration actions while locked."""
|
||||
# Not an RLock so no need to thread.
|
||||
rom_thing._lock.acquire()
|
||||
err_msg = "Trying to run ROM test when a test is already running."
|
||||
with pytest.raises(RuntimeError, match=err_msg):
|
||||
rom_thing.perform_rom_test()
|
||||
err_msg = "Trying to run recentre when a test is already running."
|
||||
with pytest.raises(RuntimeError, match=err_msg):
|
||||
rom_thing.perform_recentre()
|
||||
|
||||
|
||||
def test_perform_rom_test_(rom_thing, mocker):
|
||||
"""Check that perform Rom Test runs the expected high level algorithm."""
|
||||
|
||||
def check_and_modify_rom_data(axis: str, direction: int, **_kwargs):
|
||||
"""Check the rom_data is empty each time, and add a final position."""
|
||||
# check rom_data was cleared at the start of this run
|
||||
assert len(rom_thing._rom_data.stage_coords) == 0
|
||||
dist = 1111 if axis == "x" else 2222
|
||||
final_pos = {"x": 0, "y": 0}
|
||||
final_pos[axis] = dist * direction
|
||||
rom_thing._rom_data.stage_coords.append(final_pos)
|
||||
|
||||
mock_move_until_edge = mocker.patch.object(
|
||||
rom_thing, "_move_until_edge", side_effect=check_and_modify_rom_data
|
||||
)
|
||||
|
||||
mocker.patch.object(
|
||||
rom_thing._cam, "grab_as_array", return_value=np.zeros([123, 456, 3])
|
||||
)
|
||||
final_dict = rom_thing.perform_rom_test()
|
||||
|
||||
# This should take less than 1 sec
|
||||
assert final_dict["Time"] <= 1
|
||||
# CSM should be 2x2 list
|
||||
assert isinstance(final_dict["CSM Matrix"], list)
|
||||
assert len(final_dict["CSM Matrix"]) == 2
|
||||
assert len(final_dict["CSM Matrix"][0]) == 2
|
||||
assert len(final_dict["CSM Matrix"][1]) == 2
|
||||
# And step range should be [2222, 4444]
|
||||
assert final_dict["Step Range"] == [2222, 4444]
|
||||
|
||||
# Check that the axes were called in the expected order.
|
||||
assert mock_move_until_edge.call_count == 4
|
||||
|
||||
assert mock_move_until_edge.call_args_list[0].kwargs["axis"] == "x"
|
||||
assert mock_move_until_edge.call_args_list[0].kwargs["direction"] == 1
|
||||
|
||||
assert mock_move_until_edge.call_args_list[1].kwargs["axis"] == "x"
|
||||
assert mock_move_until_edge.call_args_list[1].kwargs["direction"] == -1
|
||||
|
||||
assert mock_move_until_edge.call_args_list[2].kwargs["axis"] == "y"
|
||||
assert mock_move_until_edge.call_args_list[2].kwargs["direction"] == 1
|
||||
|
||||
assert mock_move_until_edge.call_args_list[3].kwargs["axis"] == "y"
|
||||
assert mock_move_until_edge.call_args_list[3].kwargs["direction"] == -1
|
||||
|
||||
|
||||
def test_perform_recenter(rom_thing, mocker, caplog):
|
||||
"""Check that performing the recentre runs through expected operations."""
|
||||
|
||||
def check_lock(*_args, **_kwargs):
|
||||
"""Check the thing is locked."""
|
||||
assert not rom_thing._lock.acquire(blocking=False)
|
||||
|
||||
mock_set_stream_res = mocker.patch.object(
|
||||
rom_thing, "_set_stream_resolution", side_effect=check_lock
|
||||
)
|
||||
mock_recentre_axis = mocker.patch.object(rom_thing, "_recentre_axis")
|
||||
|
||||
# Set a mock stage position, as we patch _recentre_axis this should be logged
|
||||
# as the centre.
|
||||
rom_thing._stage.position = {"x": 4321, "y": 1234, "z": 0}
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
rom_thing.perform_recentre()
|
||||
|
||||
assert len(caplog.messages) == 3
|
||||
assert caplog.messages[0] == "Recentring the stage."
|
||||
assert caplog.messages[1] == "Centre is estimated at (4321, 1234)."
|
||||
assert caplog.messages[2] == "Position reset to (0, 0, 0)."
|
||||
|
||||
# Check the lock was freed
|
||||
assert rom_thing._lock.acquire(blocking=False)
|
||||
rom_thing._lock.release()
|
||||
|
||||
assert mock_set_stream_res.call_count == 1
|
||||
|
||||
# Recentre called first in x then in y
|
||||
assert mock_recentre_axis.call_count == 2
|
||||
assert mock_recentre_axis.call_args_list[0].args[0] == "x"
|
||||
assert mock_recentre_axis.call_args_list[1].args[0] == "y"
|
||||
|
||||
# check that the position set to zero once
|
||||
assert rom_thing._stage.set_zero_position.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("true_on", [1, 4, 10, 11])
|
||||
def test_recentre_axis(true_on, rom_thing, mocker):
|
||||
"""Test the high level algorithm of recentring an axis.
|
||||
|
||||
This doesn't include deciding if we are centred or choosing the direction to move.
|
||||
"""
|
||||
## Start such that movement starts negative
|
||||
rom_thing._stage.position = {"x": 10000, "y": 10000, "z": 0}
|
||||
mock_moves = mocker.patch.object(rom_thing, "_moves_for_z_prediction")
|
||||
|
||||
# Mock _recentre_decision so it returns (False, 1) a number of times then finally
|
||||
# (True, 1).
|
||||
decisions = [(False, 1)] * (true_on - 1) + [(True, 1)]
|
||||
mock_recentre_decision = mocker.patch.object(
|
||||
rom_thing, "_recentre_decision", side_effect=decisions
|
||||
)
|
||||
|
||||
if true_on > 10:
|
||||
with pytest.raises(RuntimeError, match="Couldn't find centre"):
|
||||
rom_thing._recentre_axis("x")
|
||||
else:
|
||||
rom_thing._recentre_axis("x")
|
||||
|
||||
# _recentre_decision is called until True or exits after 10 attempts
|
||||
assert mock_recentre_decision.call_count == min(true_on, 10)
|
||||
|
||||
# The _moves_for_z_prediction is called once before any decision, and not called
|
||||
# after a decision of centred. The max call count is 10
|
||||
assert mock_moves.call_count == min(true_on, 10)
|
||||
for i, arg_list in enumerate(mock_moves.call_args_list):
|
||||
# First direction is -ve due to starting pos, then it changes direction
|
||||
# as the mock always returns 1
|
||||
expected_dir = -1 if i < 1 else 1
|
||||
assert arg_list.kwargs["direction"] == expected_dir
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dist", "direction", "expected_decision"),
|
||||
[
|
||||
(500, 1, (False, 1)), # Big step is 200% this is over, movement is positive.
|
||||
(-500, -1, (False, -1)),
|
||||
(200, 1, (False, 1)),
|
||||
(199, 1, (True, 1)), # Less than 200% FOV movement return centred=True
|
||||
(-199, -1, (True, 1)), # Always return 1 when c
|
||||
],
|
||||
)
|
||||
def test_recentre_decision(dist, direction, expected_decision, rom_thing, mocker):
|
||||
"""What the algorithm decides to do in different situations."""
|
||||
# Mock find turning point just because there is no _rom_data
|
||||
mocker.patch.object(rom_thing._rom_data, "find_turning_point")
|
||||
|
||||
# As _distance_in_img_percentage and _img_dir_from_stage_coords are tested
|
||||
# this test mocks their values and checks the return is as expected
|
||||
mocker.patch.object(rom_thing, "_distance_in_img_percentage", return_value=dist)
|
||||
mocker.patch.object(rom_thing, "_img_dir_from_stage_coords", return_value=direction)
|
||||
|
||||
assert rom_thing._recentre_decision("x") == expected_decision
|
||||
|
||||
# Check that we make an absolute move (to the centre) before returning centred.
|
||||
centred = expected_decision[0]
|
||||
rom_thing._stage.move_absolute.call_count == 1 if centred else 0
|
||||
329
tests/unit_tests/test_stitching.py
Normal file
329
tests/unit_tests/test_stitching.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"""Test that the code that talks to the external stitching process acts as expected.
|
||||
|
||||
This does not actually run stitching. Instead it checks that the expected commands are
|
||||
generated, and the subprocess calling works as expected.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from copy import copy
|
||||
|
||||
import pytest
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
from openflexure_microscope_server.stitching import (
|
||||
STITCHING_RESOLUTION,
|
||||
BaseStitcher,
|
||||
FinalStitcher,
|
||||
PreviewStitcher,
|
||||
StitcherValidationError,
|
||||
)
|
||||
|
||||
from .utilities.lt_test_utils import LabThingsTestEnv
|
||||
|
||||
# A global logger pretending to the logger from a thing
|
||||
LOGGER = logging.getLogger("mock-thing_logger")
|
||||
FAKE_DIR: list[str] = os.path.join("a", "dir", "that", "is", "fake")
|
||||
THIS_DIR: str = os.path.dirname(os.path.realpath(__file__))
|
||||
MOCK_STITCHER: str = os.path.join(THIS_DIR, "mock_stitching", "mock-stitch.py")
|
||||
|
||||
|
||||
def test_base_stitcher():
|
||||
"""Test the logic in BaseStitcher.
|
||||
|
||||
Pretty much the only logic in base stitcher is forming a command, and calculating
|
||||
the min_overlap from overlap.
|
||||
|
||||
The BaseStitcher can't start as the start method is explicitly NotImplemented.
|
||||
"""
|
||||
# Overlaps and expected minimum overlap to be in command line argument.
|
||||
overlaps = [(0.1, "0.09"), (0.4, "0.36"), (0.8, "0.72")]
|
||||
|
||||
for overlap, min_overlap in overlaps:
|
||||
expected_command = [
|
||||
"openflexure-stitch",
|
||||
"--stitching_mode",
|
||||
"all",
|
||||
"--minimum_overlap",
|
||||
min_overlap,
|
||||
"--resize",
|
||||
"0.5",
|
||||
FAKE_DIR,
|
||||
]
|
||||
stitcher = BaseStitcher(FAKE_DIR, overlap=overlap, correlation_resize=0.5)
|
||||
assert stitcher.command == expected_command
|
||||
|
||||
|
||||
def test_preview_stitcher_command():
|
||||
"""Check preview stitcher command for a specific example."""
|
||||
expected_command = [
|
||||
"openflexure-stitch",
|
||||
"--stitching_mode",
|
||||
"preview_stitch",
|
||||
"--minimum_overlap",
|
||||
"0.09",
|
||||
"--resize",
|
||||
"0.5",
|
||||
FAKE_DIR,
|
||||
]
|
||||
stitcher = PreviewStitcher(FAKE_DIR, overlap=0.1, correlation_resize=0.5)
|
||||
assert stitcher.command == expected_command
|
||||
|
||||
|
||||
FINAL_EXPECTED_COMMAND = [
|
||||
"openflexure-stitch",
|
||||
"--stitching_mode",
|
||||
"all",
|
||||
"--stitch_dzi",
|
||||
"--no-stitch_tiff",
|
||||
"--tile_size",
|
||||
"8192",
|
||||
"--minimum_overlap",
|
||||
"0.09",
|
||||
"--resize",
|
||||
"0.5",
|
||||
FAKE_DIR,
|
||||
]
|
||||
|
||||
|
||||
def test_final_stitcher_command_defaults(caplog):
|
||||
"""Check the FinalStitcher stitches with expected default values.
|
||||
|
||||
It should warn when default values are used as they are a fallback.
|
||||
"""
|
||||
n_logs = 0
|
||||
# Test with no dictionary data and with irrelevant dictionary data.
|
||||
with caplog.at_level(logging.WARNING):
|
||||
for data_dict in [None, {"irrelevant": "data"}]:
|
||||
stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER, scan_data_dict=data_dict)
|
||||
# Should log for overlap being None and correlation_resize being None
|
||||
n_logs += 2
|
||||
assert len(caplog.records) == n_logs
|
||||
assert stitcher.command == FINAL_EXPECTED_COMMAND
|
||||
|
||||
|
||||
def test_final_stitcher_command_tiff(caplog):
|
||||
"""Check that the tiff can be requested."""
|
||||
# Modify defaults
|
||||
expected_command = copy(FINAL_EXPECTED_COMMAND)
|
||||
expected_command[4] = "--stitch_tiff"
|
||||
stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER, stitch_tiff=True)
|
||||
# Should log for overlap being None and correlation_resize being None
|
||||
assert len(caplog.records) == 2
|
||||
assert stitcher.command == expected_command
|
||||
|
||||
|
||||
def test_final_stitcher_command_set_val_directly():
|
||||
"""Check that values are set as expected when directly input."""
|
||||
# Modify defaults
|
||||
expected_command = copy(FINAL_EXPECTED_COMMAND)
|
||||
expected_command[8] = "0.36"
|
||||
expected_command[10] = "0.25"
|
||||
# Test with no data dictionary, irrelevant data, and also the wrong data
|
||||
# When wrong data is submitted, it should take the directly input data.
|
||||
dict_vals = [
|
||||
None,
|
||||
{"irrelevant": "data"},
|
||||
{"overlap": 0.2, "save_resolution": [5, 5]},
|
||||
]
|
||||
for data_dict in dict_vals:
|
||||
stitcher = FinalStitcher(
|
||||
FAKE_DIR,
|
||||
logger=LOGGER,
|
||||
overlap=0.4,
|
||||
correlation_resize=0.25,
|
||||
scan_data_dict=data_dict,
|
||||
)
|
||||
assert stitcher.command == expected_command
|
||||
|
||||
|
||||
def test_final_stitcher_command_set_with_dict():
|
||||
"""Check that values are set as expected when set from a ScanData dictionary."""
|
||||
# Modify defaults
|
||||
expected_command = copy(FINAL_EXPECTED_COMMAND)
|
||||
expected_command[8] = "0.36"
|
||||
expected_command[10] = "0.25"
|
||||
# Check same thing works with a dictionary, resize is calculated from the saved image
|
||||
# resolution. Make 4x bigger than STITCHING_RESOLUTION to get 0.25
|
||||
resolution = [dim * 4 for dim in STITCHING_RESOLUTION]
|
||||
# Check legacy key as well as current one:
|
||||
for resolution_key in ["save_resolution", "capture resolution"]:
|
||||
stitcher = FinalStitcher(
|
||||
FAKE_DIR,
|
||||
logger=LOGGER,
|
||||
scan_data_dict={"overlap": 0.4, resolution_key: resolution},
|
||||
)
|
||||
assert stitcher.command == expected_command
|
||||
|
||||
|
||||
def _validation_error_tester(scan_path, **kwargs):
|
||||
"""Check each type of stitcher throws a validation error for the given init args."""
|
||||
# If scan_data_dict is in the kwargs only test the scan_data_dict
|
||||
if "scan_data_dict" not in kwargs:
|
||||
with pytest.raises(StitcherValidationError):
|
||||
BaseStitcher(scan_path, **kwargs).command
|
||||
with pytest.raises(StitcherValidationError):
|
||||
PreviewStitcher(scan_path, **kwargs).command
|
||||
with pytest.raises(StitcherValidationError):
|
||||
FinalStitcher(scan_path, logger=LOGGER, **kwargs).command
|
||||
|
||||
|
||||
def test_validation_error():
|
||||
"""Test a number of ways to try to inject malicious arguments into the stitcher.
|
||||
|
||||
The stitcher should throw a validation error each attempt.
|
||||
"""
|
||||
_validation_error_tester("/dir;rm -rf /;", overlap=".2", correlation_resize=".25")
|
||||
_validation_error_tester(FAKE_DIR, overlap=".2", correlation_resize=".25;rm -rf /;")
|
||||
_validation_error_tester(FAKE_DIR, overlap=".2;rm -rf /;", correlation_resize=".25")
|
||||
_validation_error_tester(FAKE_DIR, scan_data_dict={"overlap": ".2;rm -rf /;"})
|
||||
|
||||
|
||||
def test_extra_arg_validation():
|
||||
"""Test that malicious arguments in extra_args also throw validation error.
|
||||
|
||||
Currently extra args do not come from user input. But this makes checks more
|
||||
future-proof.
|
||||
"""
|
||||
stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER)
|
||||
stitcher._extra_args = ["&&rm -rf /&&"]
|
||||
with pytest.raises(StitcherValidationError):
|
||||
stitcher.command
|
||||
|
||||
|
||||
def test_preview_stitching_command(caplog, mocker):
|
||||
"""Check the preview process runs in a background thread and doesn't log."""
|
||||
mock_cmd = "python -m mock_command.py"
|
||||
|
||||
mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
stitcher = PreviewStitcher(FAKE_DIR, overlap=0.1, correlation_resize=0.5)
|
||||
stitcher.start()
|
||||
# Should take a second or so to run so will still be running
|
||||
assert stitcher.running
|
||||
# Can't start another time, instead get a runtime error
|
||||
with pytest.raises(RuntimeError):
|
||||
stitcher.start()
|
||||
# Wait for it to complete
|
||||
stitcher.wait()
|
||||
# It is now not running
|
||||
assert not stitcher.running
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
|
||||
class StitchingTestThing(lt.Thing):
|
||||
"""A Thing for running stitching in invocation threads.
|
||||
|
||||
This is needed to check cancellation behaviour.
|
||||
"""
|
||||
|
||||
@lt.action
|
||||
def run_preview(self):
|
||||
"""Run the preview stitcher."""
|
||||
stitcher = PreviewStitcher(FAKE_DIR, overlap=0.1, correlation_resize=0.5)
|
||||
# Send in the argument HANG to mock-stitch and it just hang for 10s
|
||||
stitcher._extra_args = ["HANG"]
|
||||
stitcher.start()
|
||||
stitcher.wait()
|
||||
|
||||
@lt.action
|
||||
def run_final(self):
|
||||
"""Run the final stitcher."""
|
||||
stitcher = FinalStitcher(FAKE_DIR, logger=self.logger)
|
||||
# Send in the argument HANG to mock-stitch and it just hang for 10s
|
||||
stitcher.run()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stitching_test_env():
|
||||
"""Return a test environment for a server with just StitchingTestThing."""
|
||||
with LabThingsTestEnv(things={"stitcher": StitchingTestThing}) as env:
|
||||
yield env
|
||||
|
||||
|
||||
def test_preview_stitching_cancelled(stitching_test_env, mocker):
|
||||
"""Check that preview stitch can be cancelled."""
|
||||
mock_cmd = f"python {MOCK_STITCHER}"
|
||||
|
||||
mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd)
|
||||
|
||||
t_start = time.time()
|
||||
# Start the action
|
||||
response = stitching_test_env.start_action("stitcher", "run_preview")
|
||||
# Sleep long enough for at least 1 log.
|
||||
time.sleep(0.5)
|
||||
# Cancel using a DELETE request
|
||||
stitching_test_env.cancel_action(response)
|
||||
invocation_data = stitching_test_env.poll_action(response)
|
||||
|
||||
# If it wasn't cancelled it would hang for 10 s. Here we check the cancel killed
|
||||
# it within 2s.
|
||||
assert time.time() - t_start < 2
|
||||
assert invocation_data["status"] == "cancelled"
|
||||
logs = invocation_data["log"]
|
||||
assert len(logs) == 1
|
||||
assert re.match(r"^Invocation [0-9a-f-]+ was cancelled", logs[0]["message"])
|
||||
|
||||
|
||||
def test_final_stitching_command(caplog, mocker):
|
||||
"""Check the final stitch runs until completion, and print statements are logged."""
|
||||
mock_cmd = f"python {MOCK_STITCHER}"
|
||||
|
||||
mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
# Input values to prevent logging
|
||||
stitcher = FinalStitcher(
|
||||
FAKE_DIR, logger=LOGGER, overlap=0.1, correlation_resize=0.5
|
||||
)
|
||||
# For the final stitcher it will always complete before returning.
|
||||
stitcher.run()
|
||||
# The mock command logs the inputs (but not the initial command) and the
|
||||
# stitcher logs # "Stitching complete" when it ends.
|
||||
assert len(caplog.records) == len(FINAL_EXPECTED_COMMAND)
|
||||
for i, record in enumerate(caplog.records):
|
||||
msg = record.message.strip()
|
||||
if i == len(FINAL_EXPECTED_COMMAND) - 1:
|
||||
assert msg == "Stitching complete"
|
||||
else:
|
||||
assert msg == FINAL_EXPECTED_COMMAND[i + 1]
|
||||
|
||||
|
||||
def test_final_stitching_command_cancelled(stitching_test_env, mocker):
|
||||
"""Check that final stitch can be cancelled."""
|
||||
mock_cmd = f"python {MOCK_STITCHER}"
|
||||
|
||||
mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd)
|
||||
|
||||
# Start the action
|
||||
response = stitching_test_env.start_action("stitcher", "run_final")
|
||||
# Sleep long enough for at least 1 log.
|
||||
time.sleep(0.5)
|
||||
# Cancel using a DELETE request
|
||||
stitching_test_env.cancel_action(response)
|
||||
invocation_data = stitching_test_env.poll_action(response)
|
||||
|
||||
assert invocation_data["status"] == "cancelled"
|
||||
logs = invocation_data["log"]
|
||||
assert len(logs) < len(FINAL_EXPECTED_COMMAND) + 1
|
||||
assert logs[-2]["message"] == "Stitching cancelled by user"
|
||||
assert re.match(r"^Invocation [0-9a-f-]+ was cancelled", logs[-1]["message"])
|
||||
|
||||
|
||||
def test_final_stitching_command_error(caplog, mocker):
|
||||
"""Check that ChildProcessError is raised if the final stitch errors."""
|
||||
mock_cmd = f"python {MOCK_STITCHER}"
|
||||
|
||||
mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER)
|
||||
# Send in the argument ERROR to mock-stitch and it will raise an error rather
|
||||
# than echo.
|
||||
stitcher._extra_args = ["ERROR"]
|
||||
with pytest.raises(ChildProcessError):
|
||||
stitcher.run()
|
||||
151
tests/unit_tests/test_system_thing.py
Normal file
151
tests/unit_tests/test_system_thing.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Test the OpenFlexureSystem Thing *without* connecting it to a LabThings Server."""
|
||||
|
||||
import os
|
||||
from signal import SIGTERM
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from labthings_fastapi.testing import create_thing_without_server
|
||||
|
||||
from openflexure_microscope_server.things import system
|
||||
from openflexure_microscope_server.utilities import VersionData, robust_version_strings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def system_thing():
|
||||
"""Return a OpenFlexureSystem with a mocked server interface."""
|
||||
return create_thing_without_server(system.OpenFlexureSystem)
|
||||
|
||||
|
||||
def _is_raspberrypi() -> bool:
|
||||
"""Check if the machine running the test is a Raspberry Pi.
|
||||
|
||||
This information is needed to check if the ``is_raspberrypi`` property of the Thing
|
||||
is correct. This is implemented in a different way to in the main code.
|
||||
"""
|
||||
# Check if the file specifying the Raspberry Pi model exists
|
||||
if not os.path.isfile("/proc/device-tree/model"):
|
||||
return False
|
||||
with open("/proc/device-tree/model", "r", encoding="utf-8") as model_file:
|
||||
model_name = model_file.read()
|
||||
return "Raspberry Pi" in model_name
|
||||
|
||||
|
||||
def test_is_raspberry(system_thing):
|
||||
"""Check the thing property reports whether this is a Raspberry Pi correctly."""
|
||||
assert system_thing.is_raspberrypi == _is_raspberrypi()
|
||||
|
||||
|
||||
def test_version_data(system_thing):
|
||||
"""Check VersionData is returned.
|
||||
|
||||
The content of robust_version_strings() is tested elsewhere.
|
||||
"""
|
||||
data = system_thing.version_data
|
||||
assert isinstance(data, VersionData)
|
||||
assert data == robust_version_strings()
|
||||
|
||||
fake_data = VersionData(version="fake", version_source="fake")
|
||||
system_thing._version_data = fake_data
|
||||
data = system_thing.version_data
|
||||
assert data == fake_data
|
||||
|
||||
|
||||
def test_hostname(system_thing, mocker):
|
||||
"""Check the hostname matches what socket.gethostname() returns."""
|
||||
mock_gethostname = mocker.patch("socket.gethostname", return_value="foobar")
|
||||
assert system_thing.hostname == "foobar"
|
||||
mock_gethostname.assert_called_once_with()
|
||||
|
||||
|
||||
def test_microscope_id(system_thing):
|
||||
"""Check the microscope UUID is a valid UUID and doesn't change when read again."""
|
||||
microscope_id = system_thing.microscope_id
|
||||
assert isinstance(microscope_id, UUID)
|
||||
assert microscope_id == system_thing.microscope_id
|
||||
|
||||
|
||||
def test_thing_state(system_thing, mocker):
|
||||
"""Check the thing state contains version data, hostname, and a UUID string."""
|
||||
mocker.patch("socket.gethostname", return_value="foobar")
|
||||
version_data = robust_version_strings()
|
||||
state_dict = system_thing.thing_state
|
||||
assert state_dict["hostname"] == "foobar"
|
||||
# Check the UUID in the dictionary is a string
|
||||
assert isinstance(state_dict["microscope-uuid"], str)
|
||||
# Check uuid string can be converted to valid UUID
|
||||
UUID(state_dict["microscope-uuid"])
|
||||
assert state_dict["version"] == version_data.version
|
||||
assert state_dict["version_source"] == version_data.version_source
|
||||
|
||||
|
||||
class MockPiSystem(system.OpenFlexureSystem):
|
||||
"""A OpenFlexureSystem Thing that always claims to be a Raspberry Pi."""
|
||||
|
||||
is_raspberrypi: bool = True
|
||||
|
||||
|
||||
class MockNonPiSystem(system.OpenFlexureSystem):
|
||||
"""A OpenFlexureSystem Thing that never claims to be a Raspberry Pi."""
|
||||
|
||||
is_raspberrypi: bool = False
|
||||
|
||||
|
||||
def test_pi_shutdown(mocker):
|
||||
"""Check that on a Pi will receive the correct shutdown command."""
|
||||
# Check the shutdown command is as expected.
|
||||
assert system.SHUTDOWN_CMD == ["sudo", "shutdown", "-h", "now"]
|
||||
# Mock the shutdown command as we don't want to shutdown when running tests.
|
||||
mocker.patch.object(system, "SHUTDOWN_CMD", new=["echo", "shutdown"])
|
||||
# Call shutdown on a MockPiSystem
|
||||
system_thing = create_thing_without_server(MockPiSystem)
|
||||
result = system_thing.shutdown()
|
||||
|
||||
# Check the result of the echo mock command was returned
|
||||
assert result.output.strip() == "shutdown"
|
||||
assert result.error.strip() == ""
|
||||
|
||||
|
||||
def test_pi_reboot(mocker):
|
||||
"""Check that on a Pi will receive the correct reboot command."""
|
||||
# Check the reboot command is as expected.
|
||||
assert system.REBOOT_CMD == ["sudo", "shutdown", "-r", "now"]
|
||||
# Mock the reboot command as we don't want to reboot when running tests.
|
||||
mocker.patch.object(system, "REBOOT_CMD", new=["echo", "restart"])
|
||||
# Call reboot on a MockPiSystem
|
||||
system_thing = create_thing_without_server(MockPiSystem)
|
||||
result = system_thing.reboot()
|
||||
|
||||
# Check the result of the echo mock command was returned
|
||||
assert result.output.strip() == "restart"
|
||||
assert result.error.strip() == ""
|
||||
|
||||
|
||||
def test_non_pi_shutdown(mocker):
|
||||
"""Check that a server not on a pi runs os.kill when asked to shutdown.
|
||||
|
||||
It should do this instead of running the shutdown command in a subprocess.
|
||||
"""
|
||||
# Mock os.kill
|
||||
mock_kill = mocker.patch("os.kill")
|
||||
# Get this subprocess
|
||||
pid = os.getpid()
|
||||
system_thing = create_thing_without_server(MockNonPiSystem)
|
||||
result = system_thing.shutdown()
|
||||
|
||||
# Check it tried to kill this process with SIGTERM
|
||||
mock_kill.assert_called_once_with(pid, SIGTERM)
|
||||
# Check output is an appropriate error message (that we have not shut down!)
|
||||
assert result.output.strip() == ""
|
||||
assert "but the server has not shutdown" in result.error
|
||||
|
||||
|
||||
def test_non_pi_reboot():
|
||||
"""Check that a server not on a pi refuses to restart."""
|
||||
system_thing = create_thing_without_server(MockNonPiSystem)
|
||||
result = system_thing.reboot()
|
||||
|
||||
# Check output is an appropriate error message
|
||||
assert result.output.strip() == ""
|
||||
assert result.error.strip() == "Restart is only available on Raspberry Pi."
|
||||
275
tests/unit_tests/test_version_strings.py
Normal file
275
tests/unit_tests/test_version_strings.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"""Tests that version strings can be produced reliably."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
from hypothesis import strategies as st
|
||||
from hypothesis.errors import NonInteractiveExampleWarning
|
||||
|
||||
from openflexure_microscope_server import utilities
|
||||
|
||||
# Useful for really finding the repo dir when we mock where it is.
|
||||
THIS_DIR = os.path.dirname(__file__)
|
||||
TRUE_REPO_DIR = os.path.dirname(os.path.dirname(THIS_DIR))
|
||||
|
||||
# For explicit version checking.
|
||||
VER_STRING = "3.0.0-alpha4"
|
||||
|
||||
# This is the regex provided by https://semver.org/
|
||||
SEMVER_REGEX = re.compile(
|
||||
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
||||
r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
|
||||
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
||||
r"(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
|
||||
)
|
||||
|
||||
# Use hypothesis to generate random filenames
|
||||
FILE_NAME_GEN = st.from_regex(r"[a-zA-Z_-]{5,20}\.[a-z]{1,5}", fullmatch=True)
|
||||
# Use hypothesis to generate random data for files
|
||||
FILE_DATA_GEN = st.from_regex(r"[a-zA-Z_-]{5,20}\.[a-z]{1,5}", fullmatch=True)
|
||||
|
||||
|
||||
def create_fake_file() -> None:
|
||||
"""Create a file with a random name and random content in the working directory."""
|
||||
with warnings.catch_warnings():
|
||||
# Ignore hypothesis warning as we are not using for hypothesis testing but just
|
||||
# as a convenient way to create random files for Git.
|
||||
warnings.filterwarnings("ignore", category=NonInteractiveExampleWarning)
|
||||
|
||||
fname = FILE_NAME_GEN.example()
|
||||
with open(fname, "w", encoding="utf-8") as file_obj:
|
||||
file_obj.write(FILE_DATA_GEN.example())
|
||||
|
||||
|
||||
def _git(command: str) -> str:
|
||||
"""Run a git command in a subprocess."""
|
||||
git_command = ["git"] + command.split()
|
||||
# Use check=True so an error is raised if git has a problem. Only read STDOUT
|
||||
result = subprocess.run(git_command, check=True, capture_output=True)
|
||||
return result.stdout.decode().strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Return the path of a temporary directory (set as working dir)."""
|
||||
working_dir = os.getcwd()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.chdir(tmpdir)
|
||||
yield tmpdir
|
||||
finally:
|
||||
# Return to original working dir
|
||||
os.chdir(working_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_repo(temp_dir):
|
||||
"""Return a git repo path (set as working dir) with couple of commits."""
|
||||
# Initialise, create files and commit a couple of times
|
||||
_git("init")
|
||||
create_fake_file()
|
||||
create_fake_file()
|
||||
_git("add -A")
|
||||
_git("commit -m 'message'")
|
||||
create_fake_file()
|
||||
create_fake_file()
|
||||
_git("add -A")
|
||||
_git("commit -m 'message2'")
|
||||
|
||||
# Yielding here as tempdir is yielding and we want to stay in the tempdir
|
||||
# context manager. Silencing ruff saying it should be return.
|
||||
yield temp_dir # noqa: PT022
|
||||
|
||||
|
||||
def test_version_data_git_and_toml(mocker, git_repo):
|
||||
"""Check expected version reported if git repo and pyproject.toml are available."""
|
||||
# Patch the variable for this repo to the repo provided by the fixture.
|
||||
mocker.patch.object(utilities, "REPO_DIR", new=git_repo)
|
||||
git_hash = _git("rev-parse HEAD")
|
||||
shutil.copy(os.path.join(TRUE_REPO_DIR, "pyproject.toml"), git_repo)
|
||||
version_data = utilities.robust_version_strings()
|
||||
# Check versions are as expected. Prepending the v to the semantic version.
|
||||
assert version_data.version == "v" + VER_STRING
|
||||
assert version_data.version_source == git_hash
|
||||
|
||||
|
||||
def test_version_data_toml_only(mocker, temp_dir):
|
||||
"""In the case of a pyproject.toml but not git dir, check version is reported."""
|
||||
# Patch the variable for this repo to the temp dir provided by the fixture.
|
||||
mocker.patch.object(utilities, "REPO_DIR", new=temp_dir)
|
||||
shutil.copy(os.path.join(TRUE_REPO_DIR, "pyproject.toml"), temp_dir)
|
||||
version_data = utilities.robust_version_strings()
|
||||
# Check versions are as expected. Prepending the v to the semantic version.
|
||||
assert version_data.version == "v" + VER_STRING
|
||||
assert version_data.version_source == "TOML"
|
||||
|
||||
|
||||
def test_version_data_git_only(mocker, git_repo, caplog):
|
||||
"""In the odd case of a git repo and no pyproject.toml. Check the hash is reported.
|
||||
|
||||
The version string should just be reported as "Undefined". This is weird, so we
|
||||
expect there to be errors logged.
|
||||
"""
|
||||
mocker.patch.object(utilities, "REPO_DIR", new=git_repo)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
git_hash = _git("rev-parse HEAD")
|
||||
version_data = utilities.robust_version_strings()
|
||||
# Check versions are as expected. Prepending the v to the semantic version.
|
||||
assert version_data.version == "Undefined"
|
||||
assert version_data.version_source == git_hash
|
||||
# There should be 1 error level log
|
||||
assert len(caplog.records) == 1
|
||||
|
||||
|
||||
def test_version_distribution(mocker, temp_dir):
|
||||
"""Simulate a distribution package with no pyproject.toml or git directory.
|
||||
|
||||
Check the returned result is from importlib.
|
||||
"""
|
||||
mocker.patch.object(utilities, "REPO_DIR", new=temp_dir)
|
||||
# As `openflexure_microscope_server.utilities` imported `version` from
|
||||
# `importlib.metadata` to mock the already imported:
|
||||
# `openflexure_microscope_server.utilities.version`, this is `version` from
|
||||
# `importlib.metadata`
|
||||
mocker.patch(
|
||||
"openflexure_microscope_server.utilities.version", return_value="oobar"
|
||||
)
|
||||
version_data = utilities.robust_version_strings()
|
||||
# Expect the version is the output from importlib.metadata.version, prepended by a
|
||||
# "v". So, voobar, as we mocked the version to be oobar.!
|
||||
assert version_data.version == "voobar"
|
||||
assert version_data.version_source == "Dist"
|
||||
|
||||
|
||||
def test_reading_hash_from_git():
|
||||
"""Use create a Git repo and check that hash can be read.
|
||||
|
||||
The performs a series of directory and git operations, commented as they go.
|
||||
"""
|
||||
working_dir = os.getcwd()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.chdir(tmpdir)
|
||||
git_dir = os.path.join(tmpdir, ".git")
|
||||
# Git dir not created yet. Returns Undefined
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == "Undefined"
|
||||
|
||||
# Initialised. Should still be undefined.
|
||||
_git("init")
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == "Undefined"
|
||||
|
||||
# Create some fake files and commit them.
|
||||
create_fake_file()
|
||||
create_fake_file()
|
||||
_git("add -A")
|
||||
_git("commit -m 'message'")
|
||||
git_hash1 = _git("rev-parse HEAD")
|
||||
# Check we read the hash the same as the git executable
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == git_hash1
|
||||
|
||||
# Create more fake files and check again.
|
||||
create_fake_file()
|
||||
create_fake_file()
|
||||
_git("add -A")
|
||||
_git("commit -m 'message2'")
|
||||
git_hash2 = _git("rev-parse HEAD")
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == git_hash2
|
||||
|
||||
# Save the branch name (robust to the default branch name changing)
|
||||
branch_name = _git("rev-parse --abbrev-ref HEAD")
|
||||
|
||||
# Check out the old commit in detached HEAD and check it is correct
|
||||
_git(f"checkout {git_hash1}")
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == git_hash1
|
||||
|
||||
# Check out the branch and check commit changed back
|
||||
_git(f"checkout {branch_name}")
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == git_hash2
|
||||
finally:
|
||||
# Return to original working dir
|
||||
os.chdir(working_dir)
|
||||
|
||||
|
||||
def test_reading_hash_from_broken_git_dir(git_repo):
|
||||
"""Break a git dir and check the hash is "Undefined"."""
|
||||
git_dir = os.path.join(git_repo, ".git")
|
||||
|
||||
# Break the git directory and check the hash is undefined
|
||||
shutil.move(
|
||||
os.path.join(git_dir, "refs"),
|
||||
os.path.join(git_dir, "refs1"),
|
||||
copy_function=shutil.copytree,
|
||||
)
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == "Undefined"
|
||||
|
||||
# Fix it and check it works again
|
||||
shutil.move(
|
||||
os.path.join(git_dir, "refs1"),
|
||||
os.path.join(git_dir, "refs"),
|
||||
copy_function=shutil.copytree,
|
||||
)
|
||||
|
||||
commit_hash = utilities._get_hash_from_git_dir(git_dir)
|
||||
assert utilities.COMMIT_REGEX.match(commit_hash) is not None
|
||||
|
||||
# Break the git directory differently and check the hash is undefined
|
||||
os.remove(os.path.join(git_dir, "HEAD"))
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == "Undefined"
|
||||
|
||||
|
||||
def test_reading_hash_from_broken_git_ref_file(git_repo):
|
||||
"""Break the git ref file and check the hash is "Undefined"."""
|
||||
git_dir = os.path.join(git_repo, ".git")
|
||||
branch_name = _git("rev-parse --abbrev-ref HEAD")
|
||||
ref_path = os.path.join(git_dir, "refs", "heads", branch_name)
|
||||
# Check commit can be read
|
||||
commit_hash = utilities._get_hash_from_git_dir(git_dir)
|
||||
assert utilities.COMMIT_REGEX.match(commit_hash) is not None
|
||||
# Write something into the ref file
|
||||
with open(ref_path, "w", encoding="utf-8") as head_file:
|
||||
head_file.write("This is now broken")
|
||||
# It is now undefined
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == "Undefined"
|
||||
|
||||
|
||||
def test_reading_hash_from_broken_git_head_file(git_repo):
|
||||
"""Break the git HEAD file and check the hash is "Undefined"."""
|
||||
git_dir = os.path.join(git_repo, ".git")
|
||||
head_path = os.path.join(git_dir, "HEAD")
|
||||
# Check commit can be read
|
||||
commit_hash = utilities._get_hash_from_git_dir(git_dir)
|
||||
assert utilities.COMMIT_REGEX.match(commit_hash) is not None
|
||||
# Write something into the ref file
|
||||
with open(head_path, "w", encoding="utf-8") as ref_file:
|
||||
ref_file.write("This is now broken")
|
||||
# It is now undefined
|
||||
assert utilities._get_hash_from_git_dir(git_dir) == "Undefined"
|
||||
|
||||
|
||||
def test_reading_version_from_toml():
|
||||
"""Check version string can be read from this project's TOML file.
|
||||
|
||||
As we only expect to ever read our own TOML file there is not a need to do
|
||||
excessive testing on different generated TOML files.
|
||||
"""
|
||||
project_toml_path = os.path.join(utilities.REPO_DIR, "pyproject.toml")
|
||||
ver_string = utilities._get_version_from_toml(project_toml_path)
|
||||
# Check version does follow semantic versioning.
|
||||
assert SEMVER_REGEX.match(ver_string)
|
||||
# Explicit check
|
||||
assert ver_string == VER_STRING
|
||||
|
||||
|
||||
def test_error_reading_version_from_toml():
|
||||
"""Check Undefined is returned if error reading TOML file."""
|
||||
# Just give it the wrong path.
|
||||
project_toml_path = os.path.join(utilities.REPO_DIR, "pyppproject.toml")
|
||||
ver_string = utilities._get_version_from_toml(project_toml_path)
|
||||
# Explicit check
|
||||
assert ver_string == "Undefined"
|
||||
26
tests/unit_tests/utilities/__init__.py
Normal file
26
tests/unit_tests/utilities/__init__.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Utilities for testing and debugging.
|
||||
|
||||
At the top level are some basic testing functions. More specific testing utilities are
|
||||
provided by modules inside the package.
|
||||
"""
|
||||
|
||||
from collections.abc import Hashable
|
||||
from typing import Iterable, Protocol
|
||||
|
||||
|
||||
class SizedIterableHashable(Iterable[Hashable], Protocol):
|
||||
"""A protocol for sized iterable of hashable objects."""
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Add a len function to protocol so Python knows the object is sized."""
|
||||
|
||||
|
||||
def assert_unique_of_length(data: SizedIterableHashable, length: int) -> None:
|
||||
"""Assert that a list (or other iterable) has unique contents of a given length.
|
||||
|
||||
:param data: A list or other sized iterable of hashable objects. To be checked
|
||||
for unique contents and length.
|
||||
:param length: The expected length of data
|
||||
"""
|
||||
assert len(data) == len(set(data))
|
||||
assert len(data) == length
|
||||
BIN
tests/unit_tests/utilities/example_smart_spiral_core.pkl
Normal file
BIN
tests/unit_tests/utilities/example_smart_spiral_core.pkl
Normal file
Binary file not shown.
BIN
tests/unit_tests/utilities/example_smart_spiral_lobed.pkl
Normal file
BIN
tests/unit_tests/utilities/example_smart_spiral_lobed.pkl
Normal file
Binary file not shown.
BIN
tests/unit_tests/utilities/example_smart_spiral_regular.pkl
Normal file
BIN
tests/unit_tests/utilities/example_smart_spiral_regular.pkl
Normal file
Binary file not shown.
225
tests/unit_tests/utilities/lt_test_utils.py
Normal file
225
tests/unit_tests/utilities/lt_test_utils.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""Test utilities for interacting with the labthings Server."""
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
from types import TracebackType
|
||||
from typing import Any, Mapping, Optional, Self, TypeVar
|
||||
|
||||
import requests
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
ThingSubclass = TypeVar("ThingSubclass", bound=lt.Thing)
|
||||
|
||||
ACTION_RUNNING_KEYWORDS = ["idle", "pending", "running"]
|
||||
|
||||
|
||||
class LabThingsTestEnv:
|
||||
"""A Labthings Server running in a FastAPI Test Client.
|
||||
|
||||
This can be used to create ThingClients for testing, to to enable direct access to
|
||||
the server, or things on the server. It also provides high level functions to
|
||||
start actions without blocking the test thread.
|
||||
|
||||
Use this as a context manager:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with LabThingsTestEnv(things={"counter", CountingThing}) as env:
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
things: Mapping[str, lt.Thing | str],
|
||||
settings_folder: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Initialise the test environment.
|
||||
|
||||
The server and FastAPI TestClient are server are not created until this is used
|
||||
as a context manager:
|
||||
|
||||
:param things: The thing configuration dictionary used to initialise the server.
|
||||
:param settings_folder: The settings folder to use.
|
||||
"""
|
||||
self._server: Optional[lt.ThingServer]
|
||||
self._test_client: Optional[TestClient]
|
||||
self._things_config = things
|
||||
self._settings_folder = settings_folder
|
||||
self._tmp_dir_obj: Optional[tempfile.TemporaryDirectory] = None
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
"""Create server and run it in the TestClient."""
|
||||
if self._settings_folder is None:
|
||||
self._tmp_dir_obj = tempfile.TemporaryDirectory()
|
||||
self._settings_folder = self._tmp_dir_obj.name
|
||||
self._server = lt.ThingServer(
|
||||
things=self._things_config, settings_folder=self._settings_folder
|
||||
)
|
||||
self._test_client = TestClient(self._server.app)
|
||||
self._test_client.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException],
|
||||
exc_value: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> None:
|
||||
"""Close the TestClient and cleanup."""
|
||||
self._test_client.__exit__(exc_type, exc_value, traceback)
|
||||
if self._tmp_dir_obj is not None:
|
||||
self._tmp_dir_obj.cleanup()
|
||||
|
||||
@property
|
||||
def server(self) -> lt.ThingServer:
|
||||
"""The LabThings Server."""
|
||||
if self._server is None:
|
||||
raise RuntimeError("No server was started.")
|
||||
return self._server
|
||||
|
||||
@property
|
||||
def client(self) -> TestClient:
|
||||
"""The FastAPI TestClient running the server."""
|
||||
if self._test_client is None:
|
||||
raise RuntimeError("No server was started.")
|
||||
return self._test_client
|
||||
|
||||
def check_thing_exists(self, thing_name: str):
|
||||
"""Raise a ValueError if no thing of with a matching name exists."""
|
||||
if thing_name not in self.server.things:
|
||||
raise ValueError(f"No Thing named {thing_name}")
|
||||
|
||||
def get_thing_by_name(self, thing_name: str) -> lt.Thing:
|
||||
"""Get a Thing from the server by name.
|
||||
|
||||
:param thing_name: The name of the thing to on the server.
|
||||
|
||||
:return: The Thing with the specified name.
|
||||
"""
|
||||
self.check_thing_exists(thing_name)
|
||||
return self.server.things[thing_name]
|
||||
|
||||
def get_thing_by_type(self, thing_class: type[ThingSubclass]) -> ThingSubclass:
|
||||
"""Get a thing by type.
|
||||
|
||||
:param thing_class: The subclass of thing to match.
|
||||
|
||||
:return: The Thing that matches the subclass.
|
||||
|
||||
:raises RuntimeError: If there are multiple things of the same type, or no
|
||||
matching thing. If there are multiple things of this type use
|
||||
``get_thing_by_name`` or ``get_all_things_by_type``.
|
||||
"""
|
||||
matching = self.get_all_things_by_type(thing_class)
|
||||
n_things = len(matching)
|
||||
if n_things == 0:
|
||||
raise RuntimeError(f"No Thing of type {thing_class} on this server.")
|
||||
if n_things > 1:
|
||||
raise RuntimeError(
|
||||
f"Cannot get Thing by type as there are {n_things} of type "
|
||||
f"{thing_class} on this server."
|
||||
)
|
||||
return next(iter(matching.values()))
|
||||
|
||||
def get_all_things_by_type(
|
||||
self, thing_class: type[ThingSubclass]
|
||||
) -> Mapping[str, ThingSubclass]:
|
||||
"""Get a dictionary of all things by matching a type.
|
||||
|
||||
:param thing_class: The subclass of thing to match.
|
||||
|
||||
:return: A dictionary of Things that match the subclass. If none match this
|
||||
will be and empty dictionary.
|
||||
"""
|
||||
matching = {}
|
||||
for thing_name, thing in self.server.things.items():
|
||||
if isinstance(thing, thing_class):
|
||||
matching[thing_name] = thing
|
||||
return matching
|
||||
|
||||
def get_thing_client(self, thing_name: str) -> lt.ThingClient:
|
||||
"""Get a ThingClient for a Thing by name.
|
||||
|
||||
:param thing_name: The name of the thing to on the server.
|
||||
|
||||
:return: A LabThings ThingClient for the Thing with the specified name.
|
||||
"""
|
||||
self.check_thing_exists(thing_name)
|
||||
thing = self.server.things[thing_name]
|
||||
return lt.ThingClient.from_url(thing.path, self.client)
|
||||
|
||||
def start_action(
|
||||
self,
|
||||
thing_name: str,
|
||||
action_name: str,
|
||||
action_kwargs: Optional[Mapping[str, Any]] = None,
|
||||
) -> requests.Response:
|
||||
"""Start an action and return the server response.
|
||||
|
||||
For most purposes the best way to run an action is to use ``get_thing_client``
|
||||
to create a ThingClient. At this point any actions can be run with a similar
|
||||
Python API to calling directly. However, using ThingClient blocks the test
|
||||
thread.
|
||||
|
||||
This function provides an alternative way to start actions without blocking the
|
||||
test thread. It will return the HTTP response, this response can be used to
|
||||
poll or cancel the action. Use this method if you:
|
||||
|
||||
* Want to test cancelling an action.
|
||||
* Want to inspect the actions logs exactly as they would come to a web client
|
||||
* Direcltly interact with the HTTP API
|
||||
|
||||
:param thing_name: The name of the Thing on the server.
|
||||
:param action_name: The name of the action to start.
|
||||
:action_kwargs: The keyword inputs to the action.
|
||||
:return: A Response object with the HTTP response.
|
||||
"""
|
||||
self.check_thing_exists(thing_name)
|
||||
url = f"/{thing_name}/{action_name}"
|
||||
json_payload = {} if action_kwargs is None else action_kwargs
|
||||
response = self.client.post(url, json=json_payload)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def poll_action(
|
||||
self, response: requests.Response, interval: float = 0.01
|
||||
) -> Mapping[str, Any]:
|
||||
"""Poll an action until it completes and return the final response data.
|
||||
|
||||
:param response: The response from starting this action with ``start_action``.
|
||||
"""
|
||||
invocation_data = response.json()
|
||||
|
||||
if "status" not in invocation_data:
|
||||
raise ValueError(f"Response json has no status: {invocation_data}")
|
||||
first_run = True
|
||||
while invocation_data["status"] in ACTION_RUNNING_KEYWORDS:
|
||||
if first_run:
|
||||
first_run = False
|
||||
else:
|
||||
time.sleep(interval)
|
||||
response = self.client.get(_invocation_href(invocation_data))
|
||||
response.raise_for_status()
|
||||
invocation_data = response.json()
|
||||
return invocation_data
|
||||
|
||||
def cancel_action(self, response: requests.Response) -> None:
|
||||
"""Cancel an ongoing action.
|
||||
|
||||
:param response: The response from starting this action with ``start_action``.
|
||||
"""
|
||||
invocation_data = response.json()
|
||||
response = self.client.delete(_invocation_href(invocation_data))
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def _get_link(obj: Mapping[str, Any], rel: str) -> Mapping[str, Any]:
|
||||
"""Retrieve a link from an object's `links` list, by its `rel` attribute."""
|
||||
return next(link for link in obj["links"] if link["rel"] == rel)
|
||||
|
||||
|
||||
def _invocation_href(invocation_data: Mapping[str, Any]) -> str:
|
||||
"""Get the invocation href from the invocation response data."""
|
||||
return _get_link(invocation_data, "self")["href"]
|
||||
270
tests/unit_tests/utilities/scan_test_helpers.py
Executable file
270
tests/unit_tests/utilities/scan_test_helpers.py
Executable file
|
|
@ -0,0 +1,270 @@
|
|||
#! /usr/bin/env python3
|
||||
|
||||
"""Utility functions for testing scan planners.
|
||||
|
||||
These including fake sample creation, scan path visualisation, and
|
||||
persistent storage of expected scan paths for samples.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import pickle
|
||||
|
||||
import numpy as np
|
||||
from matplotlib import pyplot as plt
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.patches import PathPatch
|
||||
from matplotlib.path import Path as MatPath
|
||||
from scipy import interpolate
|
||||
|
||||
from openflexure_microscope_server import scan_planners
|
||||
|
||||
THIS_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
ALL_SAMPLE_NAMES = ("lobed", "regular", "core")
|
||||
|
||||
|
||||
class FakeSample:
|
||||
"""A fake sample to test scan algorithms.
|
||||
|
||||
The sample is able to return whether a given position is sample, there is no image
|
||||
associated with the sample
|
||||
"""
|
||||
|
||||
def __init__(self, xy_points: list[tuple[int, int]]):
|
||||
"""Create the sample from a spline interpolation around the given points."""
|
||||
self._sample_perimeter = interp_closed_path(xy_points, 500)
|
||||
|
||||
def is_sample(self, pos: tuple[int, int], im_size: tuple[int, int]) -> bool:
|
||||
"""Return True if an image at a given location is on the sample.
|
||||
|
||||
The image size is specified to check if it overlaps the sample. It doesn't
|
||||
check the entire image field as this is designed to be used where the fake
|
||||
sample is much larger than the image and has smooth edges. It just checks the
|
||||
4 corners.
|
||||
"""
|
||||
img_corners = [
|
||||
(pos[0] + im_size[0], pos[1] + im_size[1]),
|
||||
(pos[0] + im_size[0], pos[1] - im_size[1]),
|
||||
(pos[0] - im_size[0], pos[1] + im_size[1]),
|
||||
(pos[0] - im_size[0], pos[1] - im_size[1]),
|
||||
]
|
||||
return any(
|
||||
self._sample_perimeter.contains_point(corner) for corner in img_corners
|
||||
)
|
||||
|
||||
@property
|
||||
def patch(self) -> PathPatch:
|
||||
"""The sample as a matplotlib patch for plotting."""
|
||||
patch = PathPatch(self._sample_perimeter)
|
||||
patch.set(color=(1.0, 0.8, 1.0, 1.0))
|
||||
return patch
|
||||
|
||||
|
||||
def visualise_scan(sample: FakeSample, planner: scan_planners.ScanPlanner) -> Figure:
|
||||
"""For a given sample and scanner object return a matplotlib figure of the scan."""
|
||||
fig, ax = plt.subplots(figsize=(8, 8))
|
||||
ax.add_artist(sample.patch)
|
||||
xh, yh = zip(*planner.path_history, strict=True)
|
||||
xi, yi, _zi = zip(*planner.imaged_locations, strict=True)
|
||||
if planner.secondary_locations:
|
||||
xs, ys, _zs = zip(*planner.secondary_locations, strict=True)
|
||||
else:
|
||||
xs, ys, _zs = [], [], []
|
||||
|
||||
# convert history to numpy array so can calculate quiver arrows
|
||||
xh = np.array(xh)
|
||||
yh = np.array(yh)
|
||||
|
||||
plt.quiver(
|
||||
xh[:-1],
|
||||
yh[:-1],
|
||||
xh[1:] - xh[:-1],
|
||||
yh[1:] - yh[:-1],
|
||||
scale_units="xy",
|
||||
angles="xy",
|
||||
scale=1,
|
||||
)
|
||||
plt.plot(xh, yh, "r.")
|
||||
plt.plot(xi, yi, "g*")
|
||||
plt.plot(xs, ys, "o", mfc="none", mec="blue")
|
||||
ax.axis("equal")
|
||||
return fig
|
||||
|
||||
|
||||
def interp_closed_path(xy_points: list[tuple[int, int]], n_points: int) -> MatPath:
|
||||
"""Interpolate an n_point closed curve from a lists of xy_points.
|
||||
|
||||
This can be used to create an arbitrary sample shape for testing a scan planner.
|
||||
|
||||
Modified from:
|
||||
https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy
|
||||
"""
|
||||
# Use zip to separate x and y points into tuples
|
||||
x, y = zip(*xy_points, strict=True)
|
||||
|
||||
# Append first point and convert to array
|
||||
x = np.array(x + (x[0],))
|
||||
y = np.array(y + (y[0],))
|
||||
|
||||
# fit splines to x=f(u) and y=g(u), treating both as periodic. also note that s=0
|
||||
# is needed in order to force the spline fit to pass through all the input points.
|
||||
spline_data, *_unused = interpolate.splprep([x, y], s=0, per=True)
|
||||
|
||||
# evaluate the spline
|
||||
xi, yi = interpolate.splev(np.linspace(0, 1, n_points), spline_data)
|
||||
|
||||
# Convert to a matplotlib closed path
|
||||
path_points = [[xp, yp] for xp, yp in (zip(xi, yi, strict=True))]
|
||||
return MatPath(path_points, closed=True)
|
||||
|
||||
|
||||
def example_smart_spiral(
|
||||
sample_name: str = "lobed",
|
||||
) -> tuple[FakeSample, scan_planners.ScanPlanner]:
|
||||
"""Run an example scan.
|
||||
|
||||
:returns: The sample scanned and the planner object after scan is complete.
|
||||
"""
|
||||
xy_sample_points = load_sample_points(sample_name)
|
||||
sample = FakeSample(xy_sample_points)
|
||||
img_size = (1000, 1000)
|
||||
initial_position = (0, 0)
|
||||
planner_settings = {"dx": 1200, "dy": 800, "max_dist": 100000}
|
||||
planner = scan_planners.SmartSpiral(
|
||||
initial_position=initial_position, planner_settings=planner_settings
|
||||
)
|
||||
|
||||
while not planner.scan_complete:
|
||||
xy_pos, _ = planner.get_next_location_and_z_estimate()
|
||||
xyz_pos = (xy_pos[0], xy_pos[1], 0)
|
||||
imaged = sample.is_sample(xy_pos, img_size)
|
||||
planner.mark_location_visited(xyz_pos, imaged=imaged, focused=imaged)
|
||||
return sample, planner
|
||||
|
||||
|
||||
def profile_example_smart_spiral():
|
||||
"""Profile running an example scan and print the cumulative profile stats."""
|
||||
import cProfile
|
||||
import pstats
|
||||
|
||||
profiler = cProfile.Profile()
|
||||
profiler.runcall(example_smart_spiral)
|
||||
|
||||
stats_fname = os.path.join(THIS_DIR, "scan_example_stats.pstats")
|
||||
profiler.dump_stats(stats_fname)
|
||||
|
||||
run_stats = pstats.Stats(stats_fname)
|
||||
run_stats.strip_dirs()
|
||||
run_stats.sort_stats("cumulative")
|
||||
run_stats.print_stats("scan_planners.py")
|
||||
|
||||
|
||||
def plot_all_examples():
|
||||
"""Plot all examples as png files."""
|
||||
for sample_name in ALL_SAMPLE_NAMES:
|
||||
sample, planner = example_smart_spiral(sample_name)
|
||||
png_fname = os.path.join(THIS_DIR, f"scan_example_plot_{sample_name}.png")
|
||||
fig = visualise_scan(sample, planner)
|
||||
fig.savefig(png_fname, dpi=200)
|
||||
|
||||
|
||||
def update_example_smart_spiral_pickles():
|
||||
"""Pickle the ScanPlanner for the example_smart_spiral().
|
||||
|
||||
This is done so the history can be compared by testing to check
|
||||
the algorithm is unchanged.
|
||||
|
||||
If the algorithm is purposefully changed then this will need to be
|
||||
run to update the pickle for the test to pass.
|
||||
|
||||
Takes sample, the sample type we have generated, so we can make a
|
||||
pickle for each sample type
|
||||
"""
|
||||
for sample_name in ALL_SAMPLE_NAMES:
|
||||
pkl_fname = os.path.join(THIS_DIR, f"example_smart_spiral_{sample_name}.pkl")
|
||||
_, planner = example_smart_spiral(sample_name)
|
||||
with open(pkl_fname, "wb") as pkl_file_obj:
|
||||
pickle.dump(planner, pkl_file_obj, pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
|
||||
def get_expected_result_for_example_smart_spiral(
|
||||
sample_name: str,
|
||||
) -> scan_planners.ScanPlanner:
|
||||
"""Return the expected ScanPlanner object for the example_smart_spiral().
|
||||
|
||||
This is loaded from a pickle so that the object can be committed to the repo.
|
||||
"""
|
||||
pkl_fname = os.path.join(THIS_DIR, f"example_smart_spiral_{sample_name}.pkl")
|
||||
with open(pkl_fname, "rb") as pkl_file_obj:
|
||||
return pickle.load(pkl_file_obj)
|
||||
|
||||
|
||||
def load_sample_points(sample_name: str):
|
||||
"""Return the points to generate the FakeSample corresponding to the given input name.
|
||||
|
||||
Options are "lobed", "regular", and "core".
|
||||
"""
|
||||
sample_options = {
|
||||
"lobed": [
|
||||
(-5000, -5000),
|
||||
(-2000, 16000),
|
||||
(1000, 2000),
|
||||
(6000, 7000),
|
||||
(9000, 2000),
|
||||
],
|
||||
"regular": [
|
||||
(-5000, -5000),
|
||||
(-5000, 5000),
|
||||
(5000, 5000),
|
||||
(5000, -5000),
|
||||
],
|
||||
"core": [
|
||||
(-12000, 2000),
|
||||
(-12000, 1000),
|
||||
(0, -2000),
|
||||
(10000, -2000),
|
||||
(10000, -1000),
|
||||
(1000, 0),
|
||||
],
|
||||
}
|
||||
if sample_name not in sample_options:
|
||||
all_samples = ", ".join(sample_options.keys())
|
||||
raise ValueError(
|
||||
f"{sample_name} is not a valid sample name. Valid names : {all_samples}"
|
||||
)
|
||||
return sample_options[sample_name]
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the profiler, the plotting, or update the pickles based on command line input.
|
||||
|
||||
This only runs if run as a command line script.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="Simulated scan-planning utility")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# profile
|
||||
subparsers.add_parser("profile", help="Run simulation under cProfile")
|
||||
|
||||
# plot
|
||||
subparsers.add_parser("plot", help="Run simulation and produce plots")
|
||||
|
||||
# update
|
||||
subparsers.add_parser("update", help="Update stored reference pickles")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "profile":
|
||||
profile_example_smart_spiral()
|
||||
elif args.command == "plot":
|
||||
plot_all_examples()
|
||||
elif args.command == "update":
|
||||
update_example_smart_spiral_pickles()
|
||||
else:
|
||||
parser.error(f"Unknown command: {args.command}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue