Merge branch 'fix-movements' into 'v3'
Fix movements axes Closes #498 See merge request openflexure/openflexure-microscope-server!338
This commit is contained in:
commit
73beb2b09d
8 changed files with 562 additions and 57 deletions
|
|
@ -11,33 +11,71 @@ As the object will be used as a context manager create the hardware connection i
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from collections.abc import Sequence, Mapping
|
from collections.abc import Sequence, Mapping
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
import labthings_fastapi as lt
|
import labthings_fastapi as lt
|
||||||
|
|
||||||
|
|
||||||
|
class RedefinedBaseMovementError(RuntimeError):
|
||||||
|
"""The subclass of BaseStage has overridden ``move_relative`` or ``move_absolute``.
|
||||||
|
|
||||||
|
Overriding ``move_relative`` or ``move_absolute`` can be problematic as these use the
|
||||||
|
external position not the hardware position. It is recommended to override
|
||||||
|
``_hardware_move_relative`` and ``_hardware_move_absolute`` instead.
|
||||||
|
|
||||||
|
The BaseStage will raise this on ``__init__``, it is the last thing ``__init__``
|
||||||
|
does. As such, this exception can be captured by ``try`` if a stage needs to
|
||||||
|
override these for a specific reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BaseStage(lt.Thing):
|
class BaseStage(lt.Thing):
|
||||||
"""A base stage class for OpenFlexure translation stages.
|
"""A base stage class for OpenFlexure translation stages.
|
||||||
|
|
||||||
This can't be used directly but should reduce boilerplate code when
|
This can't be used directly but should reduce boilerplate code when
|
||||||
implementing new stages. A minimal working stage must implement
|
implementing new stages.
|
||||||
``move_relative`` and ``move_absolute`` actions, which update the
|
|
||||||
``position`` property on completion, and provide ``set_zero_position``.
|
Note that the coordinate system used for the microscope may need to have different
|
||||||
|
axis direction as those used by the underlying stage controller.
|
||||||
|
|
||||||
|
A minimal working stage must implement ``_hardware_move_relative``
|
||||||
|
and ``_hardware_move_absolute`` actions, which update the ``_hardware_position``
|
||||||
|
attribute on completion, and also should implement ``set_zero_position``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_axis_names = ("x", "y", "z")
|
_axis_names = ("x", "y", "z")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialise the stage.
|
||||||
|
|
||||||
|
:raises RedefinedBaseMovementError: if ``move_relative`` and/or
|
||||||
|
``move_absolute`` are overridden. It is recommended to override
|
||||||
|
``_hardware_move_relative`` and/or ``_hardware_move_absolute`` instead so
|
||||||
|
that all code in the child class uses the hardware reference frame.
|
||||||
|
"""
|
||||||
|
self._hardware_position = dict.fromkeys(self._axis_names, 0)
|
||||||
|
|
||||||
|
# This must be the last thing the function does in case it is caught in a try.
|
||||||
|
if (
|
||||||
|
self.__class__.move_relative.func is not BaseStage.move_relative.func
|
||||||
|
or self.__class__.move_absolute.func is not BaseStage.move_absolute.func
|
||||||
|
):
|
||||||
|
raise RedefinedBaseMovementError(
|
||||||
|
"move_relative and/or move_absolute has been overridden. This may "
|
||||||
|
"cause issues as the base methods implement converting from program "
|
||||||
|
"coordinates to hardware coordinates. Consider overriding "
|
||||||
|
"_hardware_move_relative and/or _hardware_move_absolute instead."
|
||||||
|
)
|
||||||
|
|
||||||
@lt.thing_property
|
@lt.thing_property
|
||||||
def axis_names(self) -> Sequence[str]:
|
def axis_names(self) -> Sequence[str]:
|
||||||
"""The names of the stage's axes, in order."""
|
"""The names of the stage's axes, in order."""
|
||||||
return self._axis_names
|
return self._axis_names
|
||||||
|
|
||||||
position = lt.ThingProperty(
|
@lt.thing_property
|
||||||
Mapping[str, int],
|
def position(self) -> Mapping[str, int]:
|
||||||
dict.fromkeys(_axis_names, 0),
|
"""Current position of the stage."""
|
||||||
readonly=True,
|
return self._apply_axis_direction(self._hardware_position)
|
||||||
observable=True,
|
|
||||||
)
|
|
||||||
"""Current position of the stage."""
|
|
||||||
|
|
||||||
moving = lt.ThingProperty(
|
moving = lt.ThingProperty(
|
||||||
bool,
|
bool,
|
||||||
|
|
@ -47,11 +85,56 @@ class BaseStage(lt.Thing):
|
||||||
)
|
)
|
||||||
"""Whether the stage is in motion."""
|
"""Whether the stage is in motion."""
|
||||||
|
|
||||||
|
axis_inverted = lt.ThingSetting(
|
||||||
|
initial_value={"x": False, "y": False, "z": False},
|
||||||
|
model=Mapping[str, bool],
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
"""Used to convert coordinates between the program frame and the hardware frame."""
|
||||||
|
|
||||||
|
def _apply_axis_direction(
|
||||||
|
self, position: list[int] | tuple[int] | Mapping[str, int]
|
||||||
|
) -> list[int] | Mapping[str, int]:
|
||||||
|
if isinstance(position, (list, tuple)):
|
||||||
|
return [
|
||||||
|
-int(pos) if inverted else int(pos)
|
||||||
|
for pos, inverted in zip(position, self.axis_inverted.values())
|
||||||
|
]
|
||||||
|
if isinstance(position, Mapping):
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
ax: -int(position[ax])
|
||||||
|
if self.axis_inverted[ax]
|
||||||
|
else int(position[ax])
|
||||||
|
for ax in position
|
||||||
|
}
|
||||||
|
except KeyError as e:
|
||||||
|
raise KeyError(
|
||||||
|
f"One or more axis in {position.keys()} is not defined."
|
||||||
|
) from e
|
||||||
|
raise TypeError(
|
||||||
|
"Position must be a sequence of positions or a mapping from axis to position."
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thing_state(self):
|
def thing_state(self):
|
||||||
"""Summary metadata describing the current state of the stage."""
|
"""Summary metadata describing the current state of the stage."""
|
||||||
return {"position": self.position}
|
return {"position": self.position}
|
||||||
|
|
||||||
|
@lt.thing_action
|
||||||
|
def invert_axis_direction(self, axis: Literal["x", "y", "z"]):
|
||||||
|
"""Invert the direction setting of the given axis.
|
||||||
|
|
||||||
|
:param axis: The axis name (x, y or z) to invert.
|
||||||
|
"""
|
||||||
|
# Not mutating in place so that setting is saved on change.
|
||||||
|
direction = self.axis_inverted
|
||||||
|
try:
|
||||||
|
direction[axis] = not direction[axis]
|
||||||
|
except KeyError as e:
|
||||||
|
raise KeyError(f"The axis {axis} is not defined.") from e
|
||||||
|
self.axis_inverted = direction
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def move_relative(
|
def move_relative(
|
||||||
self,
|
self,
|
||||||
|
|
@ -60,8 +143,24 @@ class BaseStage(lt.Thing):
|
||||||
**kwargs: Mapping[str, int],
|
**kwargs: Mapping[str, int],
|
||||||
):
|
):
|
||||||
"""Make a relative move. Keyword arguments should be axis names."""
|
"""Make a relative move. Keyword arguments should be axis names."""
|
||||||
|
self._hardware_move_relative(
|
||||||
|
cancel=cancel,
|
||||||
|
block_cancellation=block_cancellation,
|
||||||
|
**self._apply_axis_direction(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _hardware_move_relative(
|
||||||
|
self,
|
||||||
|
cancel: lt.deps.CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
|
"""Make a relative move in the coordinate system used by the physical hardware.
|
||||||
|
|
||||||
|
Make sure to use and update ``self._hardware_position`` not ``self.position``.
|
||||||
|
"""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"StageThings must define their own move_relative method"
|
"StageThings must define their own _hardware_move_relative method"
|
||||||
)
|
)
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
|
|
@ -72,6 +171,22 @@ class BaseStage(lt.Thing):
|
||||||
**kwargs: Mapping[str, int],
|
**kwargs: Mapping[str, int],
|
||||||
):
|
):
|
||||||
"""Make an absolute move. Keyword arguments should be axis names."""
|
"""Make an absolute move. Keyword arguments should be axis names."""
|
||||||
|
self._hardware_move_absolute(
|
||||||
|
cancel=cancel,
|
||||||
|
block_cancellation=block_cancellation,
|
||||||
|
**self._apply_axis_direction(kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _hardware_move_absolute(
|
||||||
|
self,
|
||||||
|
cancel: lt.deps.CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
|
"""Make a absolute move in the coordinate system used by the physical hardware.
|
||||||
|
|
||||||
|
Make sure to use and update ``self._hardware_position`` not ``self.position``.
|
||||||
|
"""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"StageThings must define their own move_absolute method"
|
"StageThings must define their own move_absolute method"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,23 @@ class DummyStage(BaseStage):
|
||||||
"""
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.step_time = step_time
|
self.step_time = step_time
|
||||||
|
self.instantaneous_position = self._hardware_position
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""Register the stage position when the Thing context manager is opened."""
|
"""Register the stage position when the Thing context manager is opened."""
|
||||||
self.instantaneous_position = self.position
|
self.instantaneous_position = self._hardware_position
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||||
"""Nothing to do when the Thing context manager is closed."""
|
"""Nothing to do when the Thing context manager is closed."""
|
||||||
|
|
||||||
@lt.thing_action
|
axis_inverted = lt.ThingSetting(
|
||||||
def move_relative(
|
initial_value={"x": True, "y": False, "z": False},
|
||||||
|
model=Mapping[str, bool],
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
"""Used to convert coordinates between the program frame and the hardware frame."""
|
||||||
|
|
||||||
|
def _hardware_move_relative(
|
||||||
self,
|
self,
|
||||||
cancel: lt.deps.CancelHook,
|
cancel: lt.deps.CancelHook,
|
||||||
block_cancellation: bool = False,
|
block_cancellation: bool = False,
|
||||||
|
|
@ -57,8 +64,8 @@ class DummyStage(BaseStage):
|
||||||
cancel.sleep(dt)
|
cancel.sleep(dt)
|
||||||
fraction_complete = (time.time() - start_time) / (dt * max_displacement)
|
fraction_complete = (time.time() - start_time) / (dt * max_displacement)
|
||||||
self.instantaneous_position = {
|
self.instantaneous_position = {
|
||||||
k: self.position[k] + int(fraction_complete * v)
|
ax: self._hardware_position[ax] + int(fraction_complete * disp)
|
||||||
for k, v in zip(self.axis_names, displacement)
|
for ax, disp in zip(self.axis_names, displacement)
|
||||||
}
|
}
|
||||||
fraction_complete = 1.0
|
fraction_complete = 1.0
|
||||||
except lt.exceptions.InvocationCancelledError as e:
|
except lt.exceptions.InvocationCancelledError as e:
|
||||||
|
|
@ -68,14 +75,13 @@ class DummyStage(BaseStage):
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
self.moving = False
|
self.moving = False
|
||||||
self.position = {
|
self._hardware_position = {
|
||||||
k: self.position[k] + int(fraction_complete * v)
|
ax: self._hardware_position[ax] + int(fraction_complete * disp)
|
||||||
for k, v in zip(self.axis_names, displacement)
|
for ax, disp in zip(self.axis_names, displacement)
|
||||||
}
|
}
|
||||||
self.instantaneous_position = self.position
|
self.instantaneous_position = self._hardware_position
|
||||||
|
|
||||||
@lt.thing_action
|
def _hardware_move_absolute(
|
||||||
def move_absolute(
|
|
||||||
self,
|
self,
|
||||||
cancel: lt.deps.CancelHook,
|
cancel: lt.deps.CancelHook,
|
||||||
block_cancellation: bool = False,
|
block_cancellation: bool = False,
|
||||||
|
|
@ -83,11 +89,11 @@ class DummyStage(BaseStage):
|
||||||
):
|
):
|
||||||
"""Make an absolute move. Keyword arguments should be axis names."""
|
"""Make an absolute move. Keyword arguments should be axis names."""
|
||||||
displacement = {
|
displacement = {
|
||||||
axis: int(pos) - self.position[axis]
|
axis: int(pos) - self._hardware_position[axis]
|
||||||
for axis, pos in kwargs.items()
|
for axis, pos in kwargs.items()
|
||||||
if axis in self.axis_names
|
if axis in self.axis_names
|
||||||
}
|
}
|
||||||
self.move_relative(
|
self._hardware_move_relative(
|
||||||
cancel, block_cancellation=block_cancellation, **displacement
|
cancel, block_cancellation=block_cancellation, **displacement
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -99,5 +105,5 @@ class DummyStage(BaseStage):
|
||||||
It is intended for use after manually or automatically recentring the
|
It is intended for use after manually or automatically recentring the
|
||||||
stage.
|
stage.
|
||||||
"""
|
"""
|
||||||
self.position = dict.fromkeys(self.axis_names, 0)
|
self._hardware_position = dict.fromkeys(self.axis_names, 0)
|
||||||
self.instantaneous_position = self.position
|
self.instantaneous_position = self._hardware_position
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from copy import copy
|
||||||
from typing import Iterator, Literal
|
from typing import Iterator, Literal
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
@ -36,8 +37,9 @@ class SangaboardThing(BaseStage):
|
||||||
Sangaboard class
|
Sangaboard class
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.sangaboard_kwargs = kwargs
|
self.sangaboard_kwargs = copy(kwargs)
|
||||||
self.sangaboard_kwargs["port"] = port
|
self.sangaboard_kwargs["port"] = port
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""Connect to the sangaboard when the Thing context manager is opened."""
|
"""Connect to the sangaboard when the Thing context manager is opened."""
|
||||||
|
|
@ -65,19 +67,25 @@ class SangaboardThing(BaseStage):
|
||||||
with self._sangaboard_lock:
|
with self._sangaboard_lock:
|
||||||
yield self._sangaboard
|
yield self._sangaboard
|
||||||
|
|
||||||
|
axis_inverted = lt.ThingSetting(
|
||||||
|
initial_value={"x": True, "y": False, "z": True},
|
||||||
|
model=Mapping[str, bool],
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
"""Used to convert coordinates between the program frame and the hardware frame."""
|
||||||
|
|
||||||
def update_position(self) -> None:
|
def update_position(self) -> None:
|
||||||
"""Read position from the stage and set the corresponding property."""
|
"""Read position from the stage and set the corresponding property."""
|
||||||
with self.sangaboard() as sb:
|
with self.sangaboard() as sb:
|
||||||
self.position = dict(zip(self.axis_names, sb.position))
|
self._hardware_position = dict(zip(self.axis_names, sb.position))
|
||||||
|
|
||||||
@lt.thing_action
|
def _hardware_move_relative(
|
||||||
def move_relative(
|
|
||||||
self,
|
self,
|
||||||
cancel: lt.deps.CancelHook,
|
cancel: lt.deps.CancelHook,
|
||||||
block_cancellation: bool = False,
|
block_cancellation: bool = False,
|
||||||
**kwargs: Mapping[str, int],
|
**kwargs: Mapping[str, int],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Make a relative move. Keyword arguments should be axis names."""
|
"""Make a relative move in the coordinate system used by the sangaboard."""
|
||||||
displacement = [kwargs.get(axis, 0) for axis in self.axis_names]
|
displacement = [kwargs.get(axis, 0) for axis in self.axis_names]
|
||||||
with self.sangaboard() as sb:
|
with self.sangaboard() as sb:
|
||||||
self.moving = True
|
self.moving = True
|
||||||
|
|
@ -98,22 +106,21 @@ class SangaboardThing(BaseStage):
|
||||||
self.moving = False
|
self.moving = False
|
||||||
self.update_position()
|
self.update_position()
|
||||||
|
|
||||||
@lt.thing_action
|
def _hardware_move_absolute(
|
||||||
def move_absolute(
|
|
||||||
self,
|
self,
|
||||||
cancel: lt.deps.CancelHook,
|
cancel: lt.deps.CancelHook,
|
||||||
block_cancellation: bool = False,
|
block_cancellation: bool = False,
|
||||||
**kwargs: Mapping[str, int],
|
**kwargs: Mapping[str, int],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Make an absolute move. Keyword arguments should be axis names."""
|
"""Make a absolute move in the coordinate system used by the sangaboard."""
|
||||||
with self.sangaboard():
|
with self.sangaboard():
|
||||||
self.update_position()
|
self.update_position()
|
||||||
displacement = {
|
displacement = {
|
||||||
axis: int(pos) - self.position[axis]
|
axis: int(pos) - self._hardware_position[axis]
|
||||||
for axis, pos in kwargs.items()
|
for axis, pos in kwargs.items()
|
||||||
if axis in self.axis_names
|
if axis in self.axis_names
|
||||||
}
|
}
|
||||||
self.move_relative(
|
self._hardware_move_relative(
|
||||||
cancel, block_cancellation=block_cancellation, **displacement
|
cancel, block_cancellation=block_cancellation, **displacement
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Test the server without creating a full HTTP server and socket connection.
|
"""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
|
Rather than spinning up a full uvicorn webserver for each test these tests use
|
||||||
the FastAPI ``TestClient`` or to directly communicate with the underlying
|
the FastAPI ``TestClient`` or directly communicate with the underlying
|
||||||
LabThings-FastAPI code. This increases speed of testing significantly.
|
LabThings-FastAPI code. This increases speed of testing significantly.
|
||||||
|
|
||||||
For tests that require a full running server see the ``integration-tests``
|
For tests that require a full running server see the ``integration-tests``
|
||||||
|
|
|
||||||
335
tests/test_stage.py
Normal file
335
tests/test_stage.py
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
"""Test the stage without creating a full HTTP server and socket connection."""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
import tempfile
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
import pytest
|
||||||
|
from httpx import HTTPStatusError
|
||||||
|
from hypothesis import given, settings, HealthCheck, strategies as st
|
||||||
|
|
||||||
|
import labthings_fastapi as lt
|
||||||
|
from labthings_fastapi.exceptions import NotConnectedToServerError
|
||||||
|
|
||||||
|
from openflexure_microscope_server.things.stage import (
|
||||||
|
BaseStage,
|
||||||
|
RedefinedBaseMovementError,
|
||||||
|
)
|
||||||
|
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
||||||
|
|
||||||
|
# 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 DummyStage(step_time=0.000001)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def thing_server(dummy_stage):
|
||||||
|
"""Yield a server with a very basic configuration."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
server = lt.ThingServer(settings_folder=tmpdir)
|
||||||
|
server.add_thing(dummy_stage, "/stage/")
|
||||||
|
yield server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stage_client(thing_server):
|
||||||
|
"""Yield a labthings ThingClient for the stage."""
|
||||||
|
with TestClient(thing_server.app) as test_client:
|
||||||
|
yield lt.ThingClient.from_url("/stage/", client=test_client)
|
||||||
|
|
||||||
|
|
||||||
|
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.thing_action
|
||||||
|
def move_relative(
|
||||||
|
self,
|
||||||
|
cancel: lt.deps.CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(RedefinedBaseMovementError):
|
||||||
|
BadStage1()
|
||||||
|
|
||||||
|
class BadStage2(BaseStage):
|
||||||
|
@lt.thing_action
|
||||||
|
def move_absolute(
|
||||||
|
self,
|
||||||
|
cancel: lt.deps.CancelHook,
|
||||||
|
block_cancellation: bool = False,
|
||||||
|
**kwargs: Mapping[str, int],
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(RedefinedBaseMovementError):
|
||||||
|
BadStage2()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_axis_direction(dummy_stage, direction):
|
||||||
|
"""Set the axis direction directly even if not connected to server."""
|
||||||
|
try:
|
||||||
|
dummy_stage.axis_inverted = direction
|
||||||
|
except NotConnectedToServerError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
_set_axis_direction(dummy_stage, {"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
|
||||||
|
_set_axis_direction(dummy_stage, {"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(stage_client, 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 stage_client.axis_names == ["x", "y", "z"]
|
||||||
|
# position starts at 0, 0, 0
|
||||||
|
assert stage_client.position == {"x": 0, "y": 0, "z": 0}
|
||||||
|
# axis direction starts is -1, 1, 1 for the dummy stage
|
||||||
|
assert stage_client.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(stage_client, dummy_stage):
|
||||||
|
"""Check axes invert as expected when called."""
|
||||||
|
# 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 not modify th initial value
|
||||||
|
assert stage_client.axis_inverted == {"x": True, "y": False, "z": False}
|
||||||
|
stage_client.invert_axis_direction(axis="x")
|
||||||
|
assert stage_client.axis_inverted == {"x": False, "y": False, "z": False}
|
||||||
|
stage_client.invert_axis_direction(axis="x")
|
||||||
|
assert stage_client.axis_inverted == {"x": True, "y": False, "z": False}
|
||||||
|
stage_client.invert_axis_direction(axis="y")
|
||||||
|
assert stage_client.axis_inverted == {"x": True, "y": True, "z": False}
|
||||||
|
stage_client.invert_axis_direction(axis="y")
|
||||||
|
assert stage_client.axis_inverted == {"x": True, "y": False, "z": False}
|
||||||
|
stage_client.invert_axis_direction(axis="z")
|
||||||
|
assert stage_client.axis_inverted == {"x": True, "y": False, "z": True}
|
||||||
|
stage_client.invert_axis_direction(axis="z")
|
||||||
|
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(stage_client, 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.
|
||||||
|
"""
|
||||||
|
_set_axis_direction(dummy_stage, 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(stage_client.position.values())
|
||||||
|
for movement in path:
|
||||||
|
stage_client.move_relative(x=movement[0], y=movement[1], z=movement[2])
|
||||||
|
position = [pos + move for pos, move in zip(position, movement)]
|
||||||
|
stage_pos = stage_client.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(stage_client, 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 = stage_client.axis_names
|
||||||
|
# Create every combination of True/False for x,y,z.
|
||||||
|
inversion_combinations = [
|
||||||
|
dict(zip(axis_names, inverted))
|
||||||
|
for inverted in itertools.product([True, False], repeat=len(axis_names))
|
||||||
|
]
|
||||||
|
print(path)
|
||||||
|
for axis_inverted in inversion_combinations:
|
||||||
|
_test_move_relative(
|
||||||
|
stage_client=stage_client,
|
||||||
|
dummy_stage=dummy_stage,
|
||||||
|
axis_inverted=axis_inverted,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _test_move_absolute(stage_client, 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.
|
||||||
|
"""
|
||||||
|
_set_axis_direction(dummy_stage, 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(stage_client.position.values())
|
||||||
|
for move_to in path:
|
||||||
|
stage_client.move_absolute(x=move_to[0], y=move_to[1], z=move_to[2])
|
||||||
|
position = move_to
|
||||||
|
stage_pos = stage_client.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(stage_client, 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 = stage_client.axis_names
|
||||||
|
# Create every combination of True/False for x,y,z.
|
||||||
|
inversion_combinations = [
|
||||||
|
dict(zip(axis_names, inverted))
|
||||||
|
for inverted in itertools.product([True, False], repeat=len(axis_names))
|
||||||
|
]
|
||||||
|
print(path)
|
||||||
|
for axis_inverted in inversion_combinations:
|
||||||
|
_test_move_absolute(
|
||||||
|
stage_client=stage_client,
|
||||||
|
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 = 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 = 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
|
||||||
|
|
@ -272,15 +272,19 @@ export default {
|
||||||
var x_rel = 0;
|
var x_rel = 0;
|
||||||
var y_rel = 0;
|
var y_rel = 0;
|
||||||
var z_rel = 0;
|
var z_rel = 0;
|
||||||
|
// 37 corresponds to the left key
|
||||||
if (37 in this.arrowKeysDown) {
|
if (37 in this.arrowKeysDown) {
|
||||||
x_rel = x_rel + 1;
|
|
||||||
}
|
|
||||||
if (39 in this.arrowKeysDown) {
|
|
||||||
x_rel = x_rel - 1;
|
x_rel = x_rel - 1;
|
||||||
}
|
}
|
||||||
|
// 39 corresponds to the right key
|
||||||
|
if (39 in this.arrowKeysDown) {
|
||||||
|
x_rel = x_rel + 1;
|
||||||
|
}
|
||||||
|
// 38 corresponds to the up key
|
||||||
if (38 in this.arrowKeysDown) {
|
if (38 in this.arrowKeysDown) {
|
||||||
y_rel = y_rel + 1;
|
y_rel = y_rel + 1;
|
||||||
}
|
}
|
||||||
|
// 40 corresponds to the down key
|
||||||
if (40 in this.arrowKeysDown) {
|
if (40 in this.arrowKeysDown) {
|
||||||
y_rel = y_rel - 1;
|
y_rel = y_rel - 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<li>
|
<li>
|
||||||
<a class="uk-accordion-title" href="#">Configure</a>
|
<a class="uk-accordion-title" href="#">Configure</a>
|
||||||
<div class="uk-accordion-content">
|
<div class="uk-accordion-content">
|
||||||
<b>Step Size</b>
|
<b>Keyboard Step Size</b>
|
||||||
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
|
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
|
||||||
<div>
|
<div>
|
||||||
<label class="uk-form-label" for="form-stacked-text">x</label>
|
<label class="uk-form-label" for="form-stacked-text">x</label>
|
||||||
|
|
@ -57,17 +57,9 @@
|
||||||
name="inputStepZz"
|
name="inputStepZz"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label
|
|
||||||
><input
|
|
||||||
v-model="invert.z"
|
|
||||||
class="uk-checkbox"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
Invert z</label
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<br>
|
||||||
<action-button
|
<action-button
|
||||||
thing="stage"
|
thing="stage"
|
||||||
action="set_zero_position"
|
action="set_zero_position"
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,66 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="stageSettings" class="uk-width-large">
|
<div id="stageSettings" class="uk-width-large" v-observe-visibility="visibilityChanged">
|
||||||
The microscope stage is a <b>{{ stageType }}</b
|
The microscope stage is a <b>{{ stageType }}</b>
|
||||||
>. There are no settings to adjust here: to set the geometry of your stage
|
<div>
|
||||||
(i.e. switch between delta and cartesian), you will need to adjust the
|
<div class="uk-margin">
|
||||||
configuration of your microscope, which cannot be done while it is running.
|
<p>
|
||||||
|
<br>
|
||||||
|
Your z motor is currently {{ this.z_inverted }} inverted.
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
We expect that moving in +z:
|
||||||
|
<ul>
|
||||||
|
<li> Moves your objective up, towards the sample and illumination</li>
|
||||||
|
<li> Turns the exposed z gear anti-clockwise (when viewed from above)</li>
|
||||||
|
</ul>
|
||||||
|
If this is not the case, click the button below to switch.
|
||||||
|
</p>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<action-button
|
||||||
|
class="uk-width-1-2"
|
||||||
|
thing="stage"
|
||||||
|
action="invert_axis_direction"
|
||||||
|
:submit-data="{ axis: 'z' }"
|
||||||
|
submit-label="Invert z"
|
||||||
|
@response=readAxis()
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import ActionButton from "../../labThingsComponents/actionButton.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "StageSettings",
|
name: "StageSettings",
|
||||||
|
|
||||||
components: {},
|
components: {
|
||||||
|
ActionButton,
|
||||||
|
},
|
||||||
|
|
||||||
data: function() {
|
data: function() {
|
||||||
return {};
|
return {
|
||||||
|
z_inverted: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
methods:{
|
||||||
|
visibilityChanged(isVisible) {
|
||||||
|
if (isVisible) {
|
||||||
|
this.readAxis();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async readAxis() {
|
||||||
|
let axes_inverted = await this.readThingProperty(
|
||||||
|
"stage",
|
||||||
|
"axis_inverted"
|
||||||
|
);
|
||||||
|
this.z_inverted = axes_inverted['z'] ? "" : "not ";
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue