Move unit tests to their own sub-dir in tests

This commit is contained in:
Julian Stirling 2025-12-18 17:08:02 +00:00
parent 62b414af3c
commit d08d4cd325
35 changed files with 6 additions and 7 deletions

View 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!.
"""

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

View 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

View 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

View 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

View 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

View 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

View 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

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

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

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

View 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

View 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

View 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

View 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

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

View 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

View 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

View 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"

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

View 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

View 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

View 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

View 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

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

View 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."

View 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"

View 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

View 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"]

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