From 591d085359b49ce87ee672947feaead25d8e57a4 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 29 Jul 2025 17:06:38 +0100 Subject: [PATCH 01/13] Realign and document key presses in GUI --- webapp/src/App.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/webapp/src/App.vue b/webapp/src/App.vue index 3f76338e..b82c520e 100644 --- a/webapp/src/App.vue +++ b/webapp/src/App.vue @@ -272,15 +272,19 @@ export default { var x_rel = 0; var y_rel = 0; var z_rel = 0; + // 37 corresponds to the left key if (37 in this.arrowKeysDown) { - x_rel = x_rel + 1; - } - if (39 in this.arrowKeysDown) { 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) { y_rel = y_rel + 1; } + // 40 corresponds to the down key if (40 in this.arrowKeysDown) { y_rel = y_rel - 1; } From 35fbef67e4af980424a1cd0db3cf7431e1ac0760 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 29 Jul 2025 17:20:00 +0100 Subject: [PATCH 02/13] axis direction server setting and action to invert each axis --- .../things/stage/sangaboard.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index e78fa75c..316a7054 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -68,7 +68,10 @@ class SangaboardThing(BaseStage): def update_position(self) -> None: """Read position from the stage and set the corresponding property.""" with self.sangaboard() as sb: - self.position = dict(zip(self.axis_names, sb.position)) + position = [ + a * b for a, b in zip(sb.position, self.axis_direction.values()) + ] + self.position = dict(zip(self.axis_names, position)) @lt.thing_action def move_relative( @@ -78,7 +81,9 @@ class SangaboardThing(BaseStage): **kwargs: Mapping[str, int], ) -> None: """Make a relative move. Keyword arguments should be axis names.""" - displacement = [kwargs.get(axis, 0) for axis in self.axis_names] + displacement = [ + kwargs.get(axis, 0) * self.axis_direction[axis] for axis in self.axis_names + ] with self.sangaboard() as sb: self.moving = True try: @@ -162,3 +167,22 @@ class SangaboardThing(BaseStage): time.sleep(dt) sb.query(f"{led_command} {on_brightness}") time.sleep(dt) + + axis_direction = lt.ThingSetting( + initial_value={"x": 1, "y": 1, "z": -1}, + model=dict, + ) + """The direction that each motor should move. + + Testing reveals that z and either x or y generally need inverting.""" + + @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. + """ + direction = self.axis_direction + direction[axis] *= -1 + self.axis_direction = direction + self.update_position() From ee9362ce48416f9e05b8ae31198fba3df6865b04 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 29 Jul 2025 17:23:43 +0100 Subject: [PATCH 03/13] Invert x by default --- src/openflexure_microscope_server/things/stage/sangaboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 316a7054..118c82af 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -169,7 +169,7 @@ class SangaboardThing(BaseStage): time.sleep(dt) axis_direction = lt.ThingSetting( - initial_value={"x": 1, "y": 1, "z": -1}, + initial_value={"x": -1, "y": 1, "z": -1}, model=dict, ) """The direction that each motor should move. From b373931c1a1aaae450fa657e859d18086a8f80d9 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 30 Jul 2025 11:13:45 +0100 Subject: [PATCH 04/13] Start to refactor into having an internal and external coordinates for stages to handle inversion --- .../things/stage/__init__.py | 34 +++++++++++++++++++ .../things/stage/sangaboard.py | 20 ++--------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 92458ef5..33021b20 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -11,6 +11,7 @@ As the object will be used as a context manager create the hardware connection i from __future__ import annotations from collections.abc import Sequence, Mapping +from typing import Literal import labthings_fastapi as lt @@ -25,12 +26,15 @@ class BaseStage(lt.Thing): """ _axis_names = ("x", "y", "z") + def __init__(self): + self._internal_position = dict.fromkeys(self._axis_names, 0) @lt.thing_property def axis_names(self) -> Sequence[str]: """The names of the stage's axes, in order.""" return self._axis_names + # TODO make this have a getter so it uses axis direction to invert. position = lt.ThingProperty( Mapping[str, int], dict.fromkeys(_axis_names, 0), @@ -47,11 +51,41 @@ class BaseStage(lt.Thing): ) """Whether the stage is in motion.""" + axis_direction = lt.ThingSetting( + initial_value={"x": -1, "y": 1, "z": -1}, + model=Mapping[str, int], + ) + """The direction that each motor should move. + + Testing reveals that z and either x or y generally need inverting.""" + + def apply_axis_direction(self, position: list|Mapping[str|int]) -> list|Mapping[str|int]: + + # TODO check if isinstance works for Mapping + if isinstance(position, (list, tuple)): + # TODO: check list length is axis length + position = [pos*ax_dir for pos, ax_dir in zip(position, self.axis_direction.values())] + return dict(zip(self.axis_names, position)) + elif isinstance(position, Mapping): + # TODO add try for KeyError + return {axis: position[axis] * self.axis_direction[axis] for axis in self.position} + raise TypeError #TODO message + @property def thing_state(self): """Summary metadata describing the current state of the stage.""" 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. + """ + direction = self.axis_direction + direction[axis] *= -1 + self.axis_direction = direction + @lt.thing_action def move_relative( self, diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 118c82af..f435909e 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -38,6 +38,7 @@ class SangaboardThing(BaseStage): """ self.sangaboard_kwargs = kwargs self.sangaboard_kwargs["port"] = port + # TODO super init def __enter__(self): """Connect to the sangaboard when the Thing context manager is opened.""" @@ -168,21 +169,4 @@ class SangaboardThing(BaseStage): sb.query(f"{led_command} {on_brightness}") time.sleep(dt) - axis_direction = lt.ThingSetting( - initial_value={"x": -1, "y": 1, "z": -1}, - model=dict, - ) - """The direction that each motor should move. - - Testing reveals that z and either x or y generally need inverting.""" - - @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. - """ - direction = self.axis_direction - direction[axis] *= -1 - self.axis_direction = direction - self.update_position() + From 7e5a455b7559cab8f2f811c103c064f799235bcc Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 1 Aug 2025 13:05:58 +0100 Subject: [PATCH 05/13] Complete refactor so that child stages only work in hardware reference frame and BaseStage handles conversion --- .../things/stage/__init__.py | 135 ++++++++++++++---- .../things/stage/dummy.py | 31 ++-- .../things/stage/sangaboard.py | 33 ++--- 3 files changed, 138 insertions(+), 61 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 33021b20..c6bcb9a2 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -16,32 +16,66 @@ from typing import Literal 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): """A base stage class for OpenFlexure translation stages. This can't be used directly but should reduce boilerplate code when - implementing new stages. A minimal working stage must implement - ``move_relative`` and ``move_absolute`` actions, which update the - ``position`` property on completion, and provide ``set_zero_position``. + implementing new stages. + + Note that the coordinate system used for the microscope may need to have different + axis direction as those used for + + 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") + def __init__(self): - self._internal_position = dict.fromkeys(self._axis_names, 0) + """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 def axis_names(self) -> Sequence[str]: """The names of the stage's axes, in order.""" return self._axis_names - # TODO make this have a getter so it uses axis direction to invert. - position = lt.ThingProperty( - Mapping[str, int], - dict.fromkeys(_axis_names, 0), - readonly=True, - observable=True, - ) - """Current position of the stage.""" + @lt.thing_property + def position(self) -> Mapping[str, int]: + """Current position of the stage.""" + return self._apply_axis_direction(self._hardware_position) moving = lt.ThingProperty( bool, @@ -52,24 +86,33 @@ class BaseStage(lt.Thing): """Whether the stage is in motion.""" axis_direction = lt.ThingSetting( - initial_value={"x": -1, "y": 1, "z": -1}, + initial_value={"x": 1, "y": 1, "z": 1}, model=Mapping[str, int], ) - """The direction that each motor should move. + """Used to convert coordinates between the program frame and the hardware frame.""" - Testing reveals that z and either x or y generally need inverting.""" - - def apply_axis_direction(self, position: list|Mapping[str|int]) -> list|Mapping[str|int]: - - # TODO check if isinstance works for Mapping - if isinstance(position, (list, tuple)): - # TODO: check list length is axis length - position = [pos*ax_dir for pos, ax_dir in zip(position, self.axis_direction.values())] + def _apply_axis_direction( + self, position: Sequence[int] | Mapping[str | int] + ) -> list[int] | Mapping[str | int]: + if isinstance(position, Sequence): + position = [ + pos * ax_dir + for pos, ax_dir in zip(position, self.axis_direction.values()) + ] return dict(zip(self.axis_names, position)) - elif isinstance(position, Mapping): - # TODO add try for KeyError - return {axis: position[axis] * self.axis_direction[axis] for axis in self.position} - raise TypeError #TODO message + if isinstance(position, Mapping): + try: + return { + axis: position[axis] * self.axis_direction[axis] + for axis 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 def thing_state(self): @@ -82,8 +125,12 @@ class BaseStage(lt.Thing): :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_direction - direction[axis] *= -1 + try: + direction[axis] *= -1 + except KeyError as e: + raise KeyError(f"The axis {axis} is not defined.") from e self.axis_direction = direction @lt.thing_action @@ -94,8 +141,24 @@ class BaseStage(lt.Thing): **kwargs: Mapping[str, int], ): """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( - "StageThings must define their own move_relative method" + "StageThings must define their own _hardware_move_relative method" ) @lt.thing_action @@ -106,6 +169,22 @@ class BaseStage(lt.Thing): **kwargs: Mapping[str, int], ): """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( "StageThings must define their own move_absolute method" ) diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 87109e4f..a4af7731 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -27,16 +27,16 @@ class DummyStage(BaseStage): """ super().__init__(**kwargs) self.step_time = step_time + self.instantaneous_position = self._hardware_position def __enter__(self): """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): """Nothing to do when the Thing context manager is closed.""" - @lt.thing_action - def move_relative( + def _hardware_move_relative( self, cancel: lt.deps.CancelHook, block_cancellation: bool = False, @@ -44,6 +44,7 @@ class DummyStage(BaseStage): ): """Make a relative move. Keyword arguments should be axis names.""" displacement = [kwargs.get(k, 0) for k in self.axis_names] + print(f"d {displacement}") self.moving = True try: fraction_complete = 0.0 @@ -57,8 +58,8 @@ class DummyStage(BaseStage): cancel.sleep(dt) fraction_complete = (time.time() - start_time) / (dt * max_displacement) self.instantaneous_position = { - k: self.position[k] + int(fraction_complete * v) - for k, v in zip(self.axis_names, displacement) + ax: self._hardware_position[ax] + int(fraction_complete * disp) + for ax, disp in zip(self.axis_names, displacement) } fraction_complete = 1.0 except lt.exceptions.InvocationCancelledError as e: @@ -68,14 +69,14 @@ class DummyStage(BaseStage): raise e finally: self.moving = False - self.position = { - k: self.position[k] + int(fraction_complete * v) - for k, v in zip(self.axis_names, displacement) + self._hardware_position = { + ax: self._hardware_position[ax] + int(fraction_complete * disp) + for ax, disp in zip(self.axis_names, displacement) } - self.instantaneous_position = self.position + print(self._hardware_position) + self.instantaneous_position = self._hardware_position - @lt.thing_action - def move_absolute( + def _hardware_move_absolute( self, cancel: lt.deps.CancelHook, block_cancellation: bool = False, @@ -83,11 +84,11 @@ class DummyStage(BaseStage): ): """Make an absolute move. Keyword arguments should be axis names.""" displacement = { - axis: int(pos) - self.position[axis] + axis: int(pos) - self._hardware_position[axis] for axis, pos in kwargs.items() if axis in self.axis_names } - self.move_relative( + self._hardware_move_relative( cancel, block_cancellation=block_cancellation, **displacement ) @@ -99,5 +100,5 @@ class DummyStage(BaseStage): It is intended for use after manually or automatically recentring the stage. """ - self.position = dict.fromkeys(self.axis_names, 0) - self.instantaneous_position = self.position + self._hardware_position = dict.fromkeys(self.axis_names, 0) + self.instantaneous_position = self._hardware_position diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index f435909e..a72150e9 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -38,7 +38,7 @@ class SangaboardThing(BaseStage): """ self.sangaboard_kwargs = kwargs self.sangaboard_kwargs["port"] = port - # TODO super init + super().__init__(**kwargs) def __enter__(self): """Connect to the sangaboard when the Thing context manager is opened.""" @@ -66,25 +66,25 @@ class SangaboardThing(BaseStage): with self._sangaboard_lock: yield self._sangaboard + axis_direction = lt.ThingSetting( + initial_value={"x": -1, "y": 1, "z": -1}, + model=Mapping[str, int], + ) + """Used to convert coordinates between the program frame and the hardware frame.""" + def update_position(self) -> None: """Read position from the stage and set the corresponding property.""" with self.sangaboard() as sb: - position = [ - a * b for a, b in zip(sb.position, self.axis_direction.values()) - ] - self.position = dict(zip(self.axis_names, position)) + self._hardware_position = dict(zip(self.axis_names, sb.position)) - @lt.thing_action - def move_relative( + def _hardware_move_relative( self, cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ) -> None: - """Make a relative move. Keyword arguments should be axis names.""" - displacement = [ - kwargs.get(axis, 0) * self.axis_direction[axis] for axis in self.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] with self.sangaboard() as sb: self.moving = True try: @@ -104,22 +104,21 @@ class SangaboardThing(BaseStage): self.moving = False self.update_position() - @lt.thing_action - def move_absolute( + def _hardware_move_absolute( self, cancel: lt.deps.CancelHook, block_cancellation: bool = False, **kwargs: Mapping[str, int], ) -> 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(): self.update_position() displacement = { - axis: int(pos) - self.position[axis] + axis: int(pos) - self._hardware_position[axis] for axis, pos in kwargs.items() if axis in self.axis_names } - self.move_relative( + self._hardware_move_relative( cancel, block_cancellation=block_cancellation, **displacement ) @@ -168,5 +167,3 @@ class SangaboardThing(BaseStage): time.sleep(dt) sb.query(f"{led_command} {on_brightness}") time.sleep(dt) - - From 58e3b5dcbd4ffe817ad5014d8b2a6844aa3f2ef7 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 1 Aug 2025 16:01:59 +0100 Subject: [PATCH 06/13] Start adding stage test and apply fixes to base class --- .../things/stage/__init__.py | 8 +- .../things/stage/dummy.py | 7 + .../things/stage/sangaboard.py | 1 + tests/test_dummy_server.py | 2 +- tests/test_stage.py | 170 ++++++++++++++++++ 5 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 tests/test_stage.py diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index c6bcb9a2..e03532a7 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -88,18 +88,18 @@ class BaseStage(lt.Thing): axis_direction = lt.ThingSetting( initial_value={"x": 1, "y": 1, "z": 1}, model=Mapping[str, int], + readonly=True, ) """Used to convert coordinates between the program frame and the hardware frame.""" def _apply_axis_direction( - self, position: Sequence[int] | Mapping[str | int] + self, position: list[int] | tuple[int] | Mapping[str | int] ) -> list[int] | Mapping[str | int]: - if isinstance(position, Sequence): - position = [ + if isinstance(position, (list, tuple)): + return [ pos * ax_dir for pos, ax_dir in zip(position, self.axis_direction.values()) ] - return dict(zip(self.axis_names, position)) if isinstance(position, Mapping): try: return { diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index a4af7731..92d75e84 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -36,6 +36,13 @@ class DummyStage(BaseStage): def __exit__(self, _exc_type, _exc_value, _traceback): """Nothing to do when the Thing context manager is closed.""" + axis_direction = lt.ThingSetting( + initial_value={"x": -1, "y": 1, "z": 1}, + model=Mapping[str, int], + readonly=True, + ) + """Used to convert coordinates between the program frame and the hardware frame.""" + def _hardware_move_relative( self, cancel: lt.deps.CancelHook, diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index a72150e9..3aecc6c9 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -69,6 +69,7 @@ class SangaboardThing(BaseStage): axis_direction = lt.ThingSetting( initial_value={"x": -1, "y": 1, "z": -1}, model=Mapping[str, int], + readonly=True, ) """Used to convert coordinates between the program frame and the hardware frame.""" diff --git a/tests/test_dummy_server.py b/tests/test_dummy_server.py index f63b44e3..571a7159 100644 --- a/tests/test_dummy_server.py +++ b/tests/test_dummy_server.py @@ -1,7 +1,7 @@ """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 to directly communicate with the underlying +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`` diff --git a/tests/test_stage.py b/tests/test_stage.py new file mode 100644 index 00000000..6e457324 --- /dev/null +++ b/tests/test_stage.py @@ -0,0 +1,170 @@ +"""Test the stage without creating a full HTTP server and socket connection.""" + +from collections.abc import Mapping +import tempfile + +from fastapi.testclient import TestClient +import pytest +from httpx import HTTPStatusError + +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 + + +@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_direction = 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": 1, "y": 1, "z": 1}) + + # 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": -1, "y": 1, "z": -1}) + + # A list of (input position, output position) to try + position_pairs = [ + ([1, 2, 3], [-1, 2, -3]), # list + ((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": 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_direction == {"x": -1, "y": 1, "z": 1} + # And check the thing state + assert dummy_stage.thing_state == {"position": {"x": 0, "y": 0, "z": 0}} + + +def test_direction_inversion(stage_client): + """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_direction = {"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_direction == {"x": -1, "y": 1, "z": 1} + stage_client.invert_axis_direction(axis="x") + assert stage_client.axis_direction == {"x": 1, "y": 1, "z": 1} + stage_client.invert_axis_direction(axis="x") + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} + stage_client.invert_axis_direction(axis="y") + assert stage_client.axis_direction == {"x": -1, "y": -1, "z": 1} + stage_client.invert_axis_direction(axis="y") + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} + stage_client.invert_axis_direction(axis="z") + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": -1} + stage_client.invert_axis_direction(axis="z") + assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} From dacf219ea119dd53592993d42ae0a48f1dbdb837 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 1 Aug 2025 17:48:04 +0100 Subject: [PATCH 07/13] Change from axis direction set by ints to set by bools. Add a lot more tests. --- .../things/stage/__init__.py | 20 +- .../things/stage/dummy.py | 8 +- .../things/stage/sangaboard.py | 9 +- tests/test_stage.py | 189 ++++++++++++++++-- 4 files changed, 194 insertions(+), 32 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index e03532a7..d8556951 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -85,9 +85,9 @@ class BaseStage(lt.Thing): ) """Whether the stage is in motion.""" - axis_direction = lt.ThingSetting( - initial_value={"x": 1, "y": 1, "z": 1}, - model=Mapping[str, int], + 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.""" @@ -97,14 +97,14 @@ class BaseStage(lt.Thing): ) -> list[int] | Mapping[str | int]: if isinstance(position, (list, tuple)): return [ - pos * ax_dir - for pos, ax_dir in zip(position, self.axis_direction.values()) + -pos if inverted else pos + for pos, inverted in zip(position, self.axis_inverted.values()) ] if isinstance(position, Mapping): try: return { - axis: position[axis] * self.axis_direction[axis] - for axis in position + ax: -position[ax] if self.axis_inverted[ax] else position[ax] + for ax in position } except KeyError as e: raise KeyError( @@ -126,12 +126,12 @@ class BaseStage(lt.Thing): :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_direction + direction = self.axis_inverted try: - direction[axis] *= -1 + direction[axis] = not direction[axis] except KeyError as e: raise KeyError(f"The axis {axis} is not defined.") from e - self.axis_direction = direction + self.axis_inverted = direction @lt.thing_action def move_relative( diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 92d75e84..8c01c186 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -36,9 +36,9 @@ class DummyStage(BaseStage): def __exit__(self, _exc_type, _exc_value, _traceback): """Nothing to do when the Thing context manager is closed.""" - axis_direction = lt.ThingSetting( - initial_value={"x": -1, "y": 1, "z": 1}, - model=Mapping[str, int], + axis_inverted = lt.ThingSetting( + 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.""" @@ -51,7 +51,6 @@ class DummyStage(BaseStage): ): """Make a relative move. Keyword arguments should be axis names.""" displacement = [kwargs.get(k, 0) for k in self.axis_names] - print(f"d {displacement}") self.moving = True try: fraction_complete = 0.0 @@ -80,7 +79,6 @@ class DummyStage(BaseStage): ax: self._hardware_position[ax] + int(fraction_complete * disp) for ax, disp in zip(self.axis_names, displacement) } - print(self._hardware_position) self.instantaneous_position = self._hardware_position def _hardware_move_absolute( diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 3aecc6c9..4f3d661d 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import threading import time +from copy import copy from typing import Iterator, Literal from contextlib import contextmanager from collections.abc import Mapping @@ -36,7 +37,7 @@ class SangaboardThing(BaseStage): Sangaboard class """ - self.sangaboard_kwargs = kwargs + self.sangaboard_kwargs = copy(kwargs) self.sangaboard_kwargs["port"] = port super().__init__(**kwargs) @@ -66,9 +67,9 @@ class SangaboardThing(BaseStage): with self._sangaboard_lock: yield self._sangaboard - axis_direction = lt.ThingSetting( - initial_value={"x": -1, "y": 1, "z": -1}, - model=Mapping[str, int], + 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.""" diff --git a/tests/test_stage.py b/tests/test_stage.py index 6e457324..d47f4192 100644 --- a/tests/test_stage.py +++ b/tests/test_stage.py @@ -2,10 +2,12 @@ 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 @@ -16,6 +18,15 @@ from openflexure_microscope_server.things.stage import ( ) 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(): @@ -79,7 +90,7 @@ def test_override_base_movement(): def _set_axis_direction(dummy_stage, direction): """Set the axis direction directly even if not connected to server.""" try: - dummy_stage.axis_direction = direction + dummy_stage.axis_inverted = direction except NotConnectedToServerError: pass @@ -87,7 +98,7 @@ def _set_axis_direction(dummy_stage, direction): 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": 1, "y": 1, "z": 1}) + _set_axis_direction(dummy_stage, {"x": False, "y": False, "z": False}) # A list of positions to try positions = [ @@ -107,7 +118,7 @@ def test_apply_axis_direction_all_pos(dummy_stage): 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": -1, "y": 1, "z": -1}) + _set_axis_direction(dummy_stage, {"x": True, "y": False, "z": True}) # A list of (input position, output position) to try position_pairs = [ @@ -142,29 +153,181 @@ def test_default_values(stage_client, dummy_stage): # 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_direction == {"x": -1, "y": 1, "z": 1} + 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): +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_direction = {"x": 2, "y": 1, "z": 1} + 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_direction == {"x": -1, "y": 1, "z": 1} + assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} stage_client.invert_axis_direction(axis="x") - assert stage_client.axis_direction == {"x": 1, "y": 1, "z": 1} + assert stage_client.axis_inverted == {"x": False, "y": False, "z": False} stage_client.invert_axis_direction(axis="x") - assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} + assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} stage_client.invert_axis_direction(axis="y") - assert stage_client.axis_direction == {"x": -1, "y": -1, "z": 1} + assert stage_client.axis_inverted == {"x": True, "y": True, "z": False} stage_client.invert_axis_direction(axis="y") - assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} + assert stage_client.axis_inverted == {"x": True, "y": False, "z": False} stage_client.invert_axis_direction(axis="z") - assert stage_client.axis_direction == {"x": -1, "y": 1, "z": -1} + assert stage_client.axis_inverted == {"x": True, "y": False, "z": True} stage_client.invert_axis_direction(axis="z") - assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1} + 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 From 4eca6c3f54f05b56e74d4785577b8e74220d720b Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 1 Aug 2025 20:48:15 +0100 Subject: [PATCH 08/13] Fix docstring formatting --- .../things/stage/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index d8556951..443e750d 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -49,9 +49,9 @@ class BaseStage(lt.Thing): """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. + ``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) @@ -155,7 +155,7 @@ class BaseStage(lt.Thing): ): """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`. + Make sure to use and update ``self._hardware_position`` not ``self.position``. """ raise NotImplementedError( "StageThings must define their own _hardware_move_relative method" @@ -183,7 +183,7 @@ class BaseStage(lt.Thing): ): """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`. + Make sure to use and update ``self._hardware_position`` not ``self.position``. """ raise NotImplementedError( "StageThings must define their own move_absolute method" From f3c8add3989894bf21cdfc14a0dc0b49fb9dcafc Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 4 Aug 2025 16:59:55 +0100 Subject: [PATCH 09/13] Make axis inversion robust agains UI sending positions as strings --- .../things/stage/__init__.py | 10 ++++++---- tests/test_stage.py | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 443e750d..310900ed 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -93,17 +93,19 @@ class BaseStage(lt.Thing): """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]: + self, position: list[int] | tuple[int] | Mapping[str, int] + ) -> list[int] | Mapping[str, int]: if isinstance(position, (list, tuple)): return [ - -pos if inverted else pos + -int(pos) if inverted else int(pos) for pos, inverted in zip(position, self.axis_inverted.values()) ] if isinstance(position, Mapping): try: return { - ax: -position[ax] if self.axis_inverted[ax] else position[ax] + ax: -int(position[ax]) + if self.axis_inverted[ax] + else int(position[ax]) for ax in position } except KeyError as e: diff --git a/tests/test_stage.py b/tests/test_stage.py index d47f4192..be4e662f 100644 --- a/tests/test_stage.py +++ b/tests/test_stage.py @@ -123,9 +123,11 @@ def test_apply_axis_direction_mixed(dummy_stage): # 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 ] From c7dd269c01e308644b0f930d520805aa298226c8 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Tue, 5 Aug 2025 13:09:59 +0000 Subject: [PATCH 10/13] Apply suggestions from code review of branch fix-movements Co-authored-by: Julian Stirling --- src/openflexure_microscope_server/things/stage/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 310900ed..85d2495a 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -36,7 +36,7 @@ class BaseStage(lt.Thing): implementing new stages. Note that the coordinate system used for the microscope may need to have different - axis direction as those used for + 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`` From 5edf15ca7a7f9a7705c8d17e6a99cdc27b68a7a0 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 6 Aug 2025 12:14:49 +0100 Subject: [PATCH 11/13] Action button to invert z axis in stage coordinates --- .../settingsComponents/stageSettings.vue | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/webapp/src/components/tabContentComponents/settingsComponents/stageSettings.vue b/webapp/src/components/tabContentComponents/settingsComponents/stageSettings.vue index 483f7478..09ae23a3 100644 --- a/webapp/src/components/tabContentComponents/settingsComponents/stageSettings.vue +++ b/webapp/src/components/tabContentComponents/settingsComponents/stageSettings.vue @@ -1,20 +1,66 @@