From e9b4d73084f13b2ac1439597ada7dee265c892e7 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 10 Feb 2026 16:59:31 +0000 Subject: [PATCH 01/30] First implementation of interruptable jog moves. This introduces a new move command, "jog", which is a relative move that may be interrupted. The idea is that a client may send repeated requests to invoke "jog" actions, and each action replaces the previous one. This should mean that a continuous stream of "jog" requests results in continuous motion of the stage. Motion may be stopped by cancelling an ongoing `jog` action, or by waiting for the move to complete. My rationale for implementing it this way is that we have a built in fail-safe, because the stage will automatically stop after the current `jog` move completes, unless it gets another request. Using a short move (e.g. 0.5s) means it should never overrun by more than that. --- .../things/stage/sangaboard.py | 187 +++++++++++++++++- 1 file changed, 185 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 7429aea3..43edae8b 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -3,8 +3,11 @@ from __future__ import annotations import threading +import time +from collections.abc import Sequence from contextlib import contextmanager from copy import copy +from queue import Queue from types import TracebackType from typing import Any, Iterator, Literal, Optional, Self @@ -19,6 +22,32 @@ REQUIRED_VERSION = semver.Version.parse("1.0.0") RECOMMENDED_VERSION = semver.Version.parse("1.0.4") +class JogCommand: + """A base class for jog operations.""" + + def __init__(self) -> None: + """Initialise a JogCommand.""" + super().__init__() + self.received = threading.Event() + self.finished = threading.Event() + + +class JogMoveCommand(JogCommand): + """A command to make a jog move.""" + + def __init__(self, displacement: Sequence[int]) -> None: + """Initialise a JogMoveCommand. + + :param displacement: The displacement for the jog move, in hardware coordinates. + """ + super().__init__() + self.displacement = displacement + + +class JogStopCommand(JogCommand): + """A command to stop a jog move.""" + + class SangaboardThing(BaseStage): """A Thing to manage a Sangaboard motor controller. @@ -49,6 +78,11 @@ class SangaboardThing(BaseStage): self.sangaboard_kwargs = copy(kwargs) self.sangaboard_kwargs["port"] = port self._sangaboard_lock = threading.RLock() + + self._jog_lock = threading.Lock() + self._jog_queue: Queue[JogCommand] = Queue[JogCommand](maxsize=1) + self._jog_thread: Optional[threading.Thread] = None + super().__init__(thing_server_interface, **kwargs) def __enter__(self) -> Self: @@ -118,6 +152,46 @@ class SangaboardThing(BaseStage): f"{RECOMMENDED_VERSION}." ) + def _hardware_start_move_relative(self, displacement: Sequence[int]) -> None: + """Start a relative move. + + This starts the stage moving, but does not wait for the move to complete. It + sets ``self.moving`` to `True`: resetting it is the responsibility of the calling code. + """ + with self.sangaboard() as sb: + self.moving = True + sb.move_rel(displacement) + + def _hardware_stop(self) -> None: + """Stop any motion of the stage as soon as possible.""" + with self.sangaboard() as sb: + sb.query("stop") + self.moving = False + + def _move_duration(self, displacement: Sequence[int]) -> float: + """Calculate the expected duration of a move with the given displacement.""" + max_displacement = max(abs(d) for d in displacement) + return max_displacement * 0.001 # This does not yet check the board's speed. + + def _poll_moving(self) -> bool: + """Determine if the stage is still moving. + + This also sets `self.moving` if the status has changed. + + :return: whether the stage is still moving. + """ + with self.sangaboard() as sb: + moving = sb.query("moving?") == "true" + if self.moving != moving: + self.moving = moving + return moving + + def _poll_until_stopped(self) -> None: + """Poll the stage until it reports that it has stopped moving.""" + with self.sangaboard(): + while self._poll_moving(): + lt.cancellable_sleep(0.1) + def _hardware_move_relative( self, block_cancellation: bool = False, @@ -125,20 +199,24 @@ class SangaboardThing(BaseStage): ) -> None: """Make a relative move in the coordinate system used by the sangaboard.""" displacement = [kwargs.get(axis, 0) for axis in self.axis_names] + duration = self._move_duration(displacement) with self.sangaboard() as sb: - self.moving = True try: sb.move_rel(displacement) if block_cancellation: sb.query("notify_on_stop") else: + if duration > 0.2: + lt.cancellable_sleep( + duration - 0.1 + ) # Avoid unnecessary polling while sb.query("moving?") == "true": lt.cancellable_sleep(0.1) except lt.exceptions.InvocationCancelledError as e: # If the move has been cancelled, stop it but don't handle the exception. # We need the exception to propagate in order to stop any calling tasks, # and to mark the invocation as "cancelled" rather than stopped. - sb.query("stop") + self._hardware_stop() raise e finally: self.moving = False @@ -198,3 +276,108 @@ class SangaboardThing(BaseStage): sb.query(f"{led_command} {on_brightness}") else: sb.query(f"{led_command} 0") + + @lt.action + def jog(self, stop: bool = False, **kwargs: int) -> None: + r"""Make a relative move that may be interrupted by a future ``jog``\ . + + This action makes a relative move. If another ``jog`` action is called while + a ``jog`` is already in progress, the first will be stopped and the second + will start immediately. This allows for responsive manual control of the + stage, for example with a joystick. + + :param stop: if this is set to `True` the jog will be terminated. + :param \**kwargs: Keyword arguments should be axis names. + """ + if stop: + self._send_jog_command(JogStopCommand(), wait=True, timeout=1) + else: + self._hardware_jog(**self._apply_axis_direction(kwargs)) + + def _hardware_jog(self, **kwargs: int) -> None: + r"""Make a relative move that may be interrupted by a future ``jog``\ . + + This function uses hardware coordinates, ``jog`` is the public wrapper that + applies any neecessary transform and then calls this function. See the + docs for that function for more explanation. + + See `_jog_loop` for an explanation of the mechanism. + + :param \**kwargs: Keyword arguments should be axis names. + """ + move = [kwargs.get(axis, 0) for axis in self.axis_names] + self._send_jog_command( + JogMoveCommand(move), + timeout=self._move_duration(move) + 1, + ) + + def _send_jog_command( + self, command: JogCommand, timeout: Optional[float] = None + ) -> None: + """Send a jog command to the background jog thread. + + This function will start the background thread if it is not running. + This function acquires `_jog_lock` and uses the `_jog_send` event to signal + the thread to read the next command. As commands interrupt each other, this + function should never block for a long time. + + :param command: the jog command to send. + :param timeout: how long to wait for the command to be completed, or `None` + to skip waiting. + """ + with self._jog_lock: + # Make sure the queue exists. + # Check the background thread is running, and restart it if not. + if self._jog_thread is None or not self._jog_thread.is_alive(): + self._jog_queue = Queue[JogCommand](maxsize=1) + self._jog_thread = threading.Thread(target=self._jog_loop) + self._jog_thread.start() + self._jog_queue.put(command) + command.received.wait(1) + if timeout is not None: + command.finished.wait(timeout) + + def _jog_loop(self) -> None: + r"""Execute jog commands in a background thread. + + This function is intended to be run in a background thread. It will look at + `self._jog_command` when the `self._jog_send` event is set, and signal that + the command is being processed with `self._jog_received`\ . + + If no new command is received before the current command finishes, the thread + will terminate, and `self._jog_received` will be set again. This means that + it should be safe to + """ + duration = 1 # How long to wait initially. This shouldn't ever need to be long. + # On subsequent loop iterations, we'll wait long enough that we expect the last + # move to have finished. + previous_command: Optional[JogCommand] = None + with ( + self.sangaboard() as _sb + ): # prevent others using the sangaboard while jogging. + while True: + try: + command = self._jog_queue.get(timeout=duration) + except TimeoutError: + if self._poll_moving(): + # If there's no new command, keep checking for new commands until + # we stop. + duration = 0.1 + continue + # Once the stage is no longer moving, stop listening for commands. + break + command.received.set() # Acknowledge the command + if previous_command: + previous_command.finished.set() # Notify the last command it's superseded + if isinstance(command, JogMoveCommand): + self._hardware_start_move_relative(command.displacement) + duration = self._move_duration(command.displacement) + elif isinstance(command, JogStopCommand): + self._hardware_stop() + self._poll_until_stopped() + duration = 0.1 # Next iteration, we will probably time out. + else: + raise RuntimeError(f"Unknown jog command: {command}") + if previous_command: + # Notify the last command that it finished, because we stopped moving. + previous_command.finished.set() From 3753341893779e717ab77b9b2d693b3b7885ae6b Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 10 Feb 2026 23:25:44 +0000 Subject: [PATCH 02/30] First working version of jog I've fixed a few issues, used the right exception for queue timeouts, and added a __repr__ to jog commands for nicer debug output. This now works - it's not flawlessly smooth, but it's pretty close. The limiting factor seems to be the repeat rate of the keypresses in javascript. --- .../things/stage/sangaboard.py | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 43edae8b..4ee506dc 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -7,7 +7,7 @@ import time from collections.abc import Sequence from contextlib import contextmanager from copy import copy -from queue import Queue +from queue import Queue, Empty from types import TracebackType from typing import Any, Iterator, Literal, Optional, Self @@ -31,6 +31,10 @@ class JogCommand: self.received = threading.Event() self.finished = threading.Event() + def __repr__(self) -> str: + """Represent the command as a string.""" + return f"<{self.__class__.__name__}>" + class JogMoveCommand(JogCommand): """A command to make a jog move.""" @@ -43,6 +47,10 @@ class JogMoveCommand(JogCommand): super().__init__() self.displacement = displacement + def __repr__(self) -> str: + """Represent the command as a string.""" + return f"<{self.__class__.__name__} {self.displacement}>" + class JogStopCommand(JogCommand): """A command to stop a jog move.""" @@ -290,7 +298,7 @@ class SangaboardThing(BaseStage): :param \**kwargs: Keyword arguments should be axis names. """ if stop: - self._send_jog_command(JogStopCommand(), wait=True, timeout=1) + self._send_jog_command(JogStopCommand(), timeout=1) else: self._hardware_jog(**self._apply_axis_direction(kwargs)) @@ -329,13 +337,16 @@ class SangaboardThing(BaseStage): # Make sure the queue exists. # Check the background thread is running, and restart it if not. if self._jog_thread is None or not self._jog_thread.is_alive(): + self.logger.info("Starting background thread for jog commands") self._jog_queue = Queue[JogCommand](maxsize=1) self._jog_thread = threading.Thread(target=self._jog_loop) self._jog_thread.start() self._jog_queue.put(command) command.received.wait(1) - if timeout is not None: - command.finished.wait(timeout) + # The final wait happens after releasing the lock: this allows jog moves to + # be interrupted. + if timeout is not None: + command.finished.wait(timeout) def _jog_loop(self) -> None: r"""Execute jog commands in a background thread. @@ -357,24 +368,29 @@ class SangaboardThing(BaseStage): ): # prevent others using the sangaboard while jogging. while True: try: + waitstart = time.time() command = self._jog_queue.get(timeout=duration) - except TimeoutError: + self.logger.info(f"Got command {command} after {time.time() - waitstart}") + except Empty: + self.logger.info(f"Timed out waiting for a command, waited {duration}.") if self._poll_moving(): # If there's no new command, keep checking for new commands until # we stop. + self.logger.info(f"Still moving, will wait for the stage to stop.") duration = 0.1 continue # Once the stage is no longer moving, stop listening for commands. + self.logger.info(f"Stage has stopped, terminating.") break command.received.set() # Acknowledge the command if previous_command: previous_command.finished.set() # Notify the last command it's superseded + previous_command = command if isinstance(command, JogMoveCommand): self._hardware_start_move_relative(command.displacement) duration = self._move_duration(command.displacement) elif isinstance(command, JogStopCommand): self._hardware_stop() - self._poll_until_stopped() duration = 0.1 # Next iteration, we will probably time out. else: raise RuntimeError(f"Unknown jog command: {command}") From d09a5a8ee2343e08461cb4e8e6bae8991ed52d0e Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 10 Feb 2026 23:38:20 +0000 Subject: [PATCH 03/30] Use jog action for arrow keys. This now makes the arrow keys work more or less continuously, and stop responsively. It could do with optimisation so the key rep rate is lower, but the initial wait is shorter. That probably entails adding our own timeout. --- webapp/src/App.vue | 8 ++++++++ .../controlComponents/positionControl.vue | 6 ++++-- webapp/vite.config.mjs | 4 +--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/webapp/src/App.vue b/webapp/src/App.vue index 673d4708..5aed7071 100644 --- a/webapp/src/App.vue +++ b/webapp/src/App.vue @@ -143,6 +143,7 @@ export default { ["up", "down", "left", "right"], (event) => { delete this.arrowKeysDown[event.keyCode]; //Remove key from array + this.invokeAction("stage", "jog", { stop: true }); }, "keyup", ); @@ -158,6 +159,13 @@ export default { Mousetrap.bind("pagedown", () => { eventBus.emit("globalMoveStepEvent", { x: 0, y: 0, z: -1 }); }); + Mousetrap.bind( + ["pageup", "pagedown"], + () => { + this.invokeAction("stage", "jog", { stop: true }); + }, + "keyup", + ); this.keyboardManual.push({ shortcut: "pgup / pgdn", description: "Move the microscope focus", diff --git a/webapp/src/components/tabContentComponents/controlComponents/positionControl.vue b/webapp/src/components/tabContentComponents/controlComponents/positionControl.vue index 7c14416e..dc56e226 100644 --- a/webapp/src/components/tabContentComponents/controlComponents/positionControl.vue +++ b/webapp/src/components/tabContentComponents/controlComponents/positionControl.vue @@ -76,6 +76,7 @@ export default { return { setPosition: null, moveLock: false, + jogging: false, }; }, @@ -94,6 +95,7 @@ export default { eventBus.on("globalMoveInImageCoordinatesEvent", this.onMoveImage); // A global signal listener to perform a move in multiples of a step size eventBus.on("globalMoveStepEvent", this.onMoveStep); + // Update the current position in text boxes await this.updatePosition(); }, @@ -122,8 +124,7 @@ export default { const x = x_steps * navigationStepSize.x * (navigationInvert.x ? -1 : 1); const y = y_steps * navigationStepSize.y * (navigationInvert.y ? -1 : 1); const z = z_steps * navigationStepSize.z * (navigationInvert.z ? -1 : 1); - const movePayload = { x, y, z, absolute: false }; - eventBus.emit("globalMoveEvent", movePayload); + this.invokeAction("stage", "jog", { x: x, y: y, z: z }); }, async move(payload) { @@ -131,6 +132,7 @@ export default { // Move the stage, by updating the controls and starting a move task // This is equivalent to clicking the "move" button. if (this.moveLock) return; // Discard move requests if we're already moving + if (this.jogging) return; // Discard move requests if a jog is in progress // NB moveLock is just boolean flag - it's not as safe as a "proper" lock. this.moveLock = true; // This will also be set by the task submitter, but // setting it here avoids multiple moves being requested simultaneously. diff --git a/webapp/vite.config.mjs b/webapp/vite.config.mjs index 2f1adf70..bdc2bc35 100644 --- a/webapp/vite.config.mjs +++ b/webapp/vite.config.mjs @@ -9,9 +9,7 @@ import vue from "@vitejs/plugin-vue"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [ - vue(), - ], + plugins: [vue()], build: { // Output directory for production builds From 04e7fa30bda60a7306b903bd0df0a0ce6ceeff08 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 10 Feb 2026 23:38:29 +0000 Subject: [PATCH 04/30] Format fixes --- .../things/stage/sangaboard.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 4ee506dc..a6313d4f 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -7,7 +7,7 @@ import time from collections.abc import Sequence from contextlib import contextmanager from copy import copy -from queue import Queue, Empty +from queue import Empty, Queue from types import TracebackType from typing import Any, Iterator, Literal, Optional, Self @@ -370,17 +370,20 @@ class SangaboardThing(BaseStage): try: waitstart = time.time() command = self._jog_queue.get(timeout=duration) - self.logger.info(f"Got command {command} after {time.time() - waitstart}") + self.logger.info( + f"Got command {command} after {time.time() - waitstart}" + ) except Empty: - self.logger.info(f"Timed out waiting for a command, waited {duration}.") + self.logger.info( + f"Timed out waiting for a command, waited {duration}." + ) if self._poll_moving(): # If there's no new command, keep checking for new commands until # we stop. - self.logger.info(f"Still moving, will wait for the stage to stop.") duration = 0.1 continue # Once the stage is no longer moving, stop listening for commands. - self.logger.info(f"Stage has stopped, terminating.") + self.logger.info("Stage has stopped, terminating jog thread.") break command.received.set() # Acknowledge the command if previous_command: From 4d445396b2e58370ecf8a8241907a7b739748467 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 11 Feb 2026 00:11:03 +0000 Subject: [PATCH 05/30] Update position property during and after jog --- src/openflexure_microscope_server/things/stage/sangaboard.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index a6313d4f..dfacbce8 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -397,6 +397,8 @@ class SangaboardThing(BaseStage): duration = 0.1 # Next iteration, we will probably time out. else: raise RuntimeError(f"Unknown jog command: {command}") + self._update_position() if previous_command: # Notify the last command that it finished, because we stopped moving. previous_command.finished.set() + self._update_position() From abda60adf6d76531f62e4ae203e721581e47a993 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 11 Feb 2026 00:21:40 +0000 Subject: [PATCH 06/30] Prevent accidental queueing of jog commands --- .../things/stage/sangaboard.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index dfacbce8..0b5d42bc 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -333,7 +333,12 @@ class SangaboardThing(BaseStage): :param timeout: how long to wait for the command to be completed, or `None` to skip waiting. """ - with self._jog_lock: + if not self._jog_lock.acquire(timeout=0.1): + self.logger.warning( + "Could not send a jog message, this indicates a lock error." + ) + return + try: # Make sure the queue exists. # Check the background thread is running, and restart it if not. if self._jog_thread is None or not self._jog_thread.is_alive(): @@ -343,6 +348,8 @@ class SangaboardThing(BaseStage): self._jog_thread.start() self._jog_queue.put(command) command.received.wait(1) + finally: + self._jog_lock.release() # The final wait happens after releasing the lock: this allows jog moves to # be interrupted. if timeout is not None: @@ -397,8 +404,8 @@ class SangaboardThing(BaseStage): duration = 0.1 # Next iteration, we will probably time out. else: raise RuntimeError(f"Unknown jog command: {command}") - self._update_position() + self.update_position() if previous_command: # Notify the last command that it finished, because we stopped moving. previous_command.finished.set() - self._update_position() + self.update_position() From 9ed850cc68a6d916bbabb5db16501e5ceee4154e Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 11 Feb 2026 00:24:40 +0000 Subject: [PATCH 07/30] Switch move buttons to use jog This implements a nicer version of jogging, with a defined interval of 1/3 second. This can be tuned depending on what we think makes sense. It might be good to re-use this for handling keypresses. --- .../things/stage/sangaboard.py | 4 +- .../controlComponents/positionControl.vue | 1 - .../controlComponents/stageControlButtons.vue | 67 ++++++++++++++++--- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 0b5d42bc..138c17fa 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -366,7 +366,9 @@ class SangaboardThing(BaseStage): will terminate, and `self._jog_received` will be set again. This means that it should be safe to """ - duration = 1 # How long to wait initially. This shouldn't ever need to be long. + duration = ( + 1.0 # How long to wait initially. This shouldn't ever need to be long. + ) # On subsequent loop iterations, we'll wait long enough that we expect the last # move to have finished. previous_command: Optional[JogCommand] = None diff --git a/webapp/src/components/tabContentComponents/controlComponents/positionControl.vue b/webapp/src/components/tabContentComponents/controlComponents/positionControl.vue index dc56e226..4ad060ae 100644 --- a/webapp/src/components/tabContentComponents/controlComponents/positionControl.vue +++ b/webapp/src/components/tabContentComponents/controlComponents/positionControl.vue @@ -118,7 +118,6 @@ export default { }, onMoveStep(payload) { - const { x: x_steps, y: y_steps, z: z_steps } = payload; const navigationStepSize = this.$store.state.navigationStepSize; const navigationInvert = this.$store.state.navigationInvert; const x = x_steps * navigationStepSize.x * (navigationInvert.x ? -1 : 1); diff --git a/webapp/src/components/tabContentComponents/controlComponents/stageControlButtons.vue b/webapp/src/components/tabContentComponents/controlComponents/stageControlButtons.vue index 775ce234..f88b9524 100644 --- a/webapp/src/components/tabContentComponents/controlComponents/stageControlButtons.vue +++ b/webapp/src/components/tabContentComponents/controlComponents/stageControlButtons.vue @@ -1,26 +1,52 @@ - + diff --git a/webapp/src/components/tabContentComponents/controlComponents/stageControlButtons.vue b/webapp/src/components/tabContentComponents/controlComponents/stageControlButtons.vue index b8882bac..cb5b7278 100644 --- a/webapp/src/components/tabContentComponents/controlComponents/stageControlButtons.vue +++ b/webapp/src/components/tabContentComponents/controlComponents/stageControlButtons.vue @@ -1,7 +1,15 @@