Merge branch 'fix-movements' into 'v3'

Fix movements axes

Closes #498

See merge request openflexure/openflexure-microscope-server!338
This commit is contained in:
Julian Stirling 2025-08-06 15:50:08 +00:00
commit 73beb2b09d
8 changed files with 562 additions and 57 deletions

View file

@ -11,33 +11,71 @@ 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
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 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")
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
def axis_names(self) -> Sequence[str]:
"""The names of the stage's axes, in order."""
return self._axis_names
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,
@ -47,11 +85,56 @@ class BaseStage(lt.Thing):
)
"""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
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.
"""
# 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
def move_relative(
self,
@ -60,8 +143,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
@ -72,6 +171,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"
)

View file

@ -27,16 +27,23 @@ 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(
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."""
def _hardware_move_relative(
self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
@ -57,8 +64,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 +75,13 @@ 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
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 +89,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 +105,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

View file

@ -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,8 +37,9 @@ class SangaboardThing(BaseStage):
Sangaboard class
"""
self.sangaboard_kwargs = kwargs
self.sangaboard_kwargs = copy(kwargs)
self.sangaboard_kwargs["port"] = port
super().__init__(**kwargs)
def __enter__(self):
"""Connect to the sangaboard when the Thing context manager is opened."""
@ -65,19 +67,25 @@ class SangaboardThing(BaseStage):
with self._sangaboard_lock:
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:
"""Read position from the stage and set the corresponding property."""
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 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."""
"""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
@ -98,22 +106,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
)