From 1de4366df714df683539ce7779fd98f3b6f6fffa Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Tue, 10 Feb 2026 15:52:06 +0000 Subject: [PATCH 01/15] Flash LED endpoint in simulator for parity --- .../things/camera/simulation.py | 6 +++++- .../things/stage/dummy.py | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 1f956661..521fc7cc 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -342,7 +342,11 @@ class SimulatedCamera(BaseCamera): def generate_frame(self) -> Image.Image: """Generate a frame with blobs based on the stage coordinates.""" pos = self._stage.instantaneous_position - return self.generate_image((pos["y"], pos["x"], pos["z"])) + frame = self.generate_image((pos["y"], pos["x"], pos["z"])) + # Simulate LED turning off by setting all channels to 0 + if not self._stage.led_on: + frame = np.full((self.shape[0], self.shape[1], 3), 0, dtype=np.uint8) + return frame def __enter__(self) -> Self: """Start the capture thread when the Thing context manager is opened.""" diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 7130fcb7..82515d17 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -4,7 +4,7 @@ from __future__ import annotations import time from types import TracebackType -from typing import Any, Optional, Self +from typing import Any, Literal, Optional, Self import labthings_fastapi as lt @@ -18,6 +18,8 @@ class DummyStage(BaseStage): hardware attached. """ + led_on: bool = lt.property(default=True) + def __init__( self, thing_server_interface: lt.ThingServerInterface, @@ -115,3 +117,18 @@ class DummyStage(BaseStage): """ self._hardware_position = dict.fromkeys(self.axis_names, 0) self.instantaneous_position = self._hardware_position + + # led_channel is unused in the simulation, but must exist to match the real API + @lt.action + def flash_led( + self, + number_of_flashes: int = 10, + dt: float = 0.5, + led_channel: Literal["cc"] = "cc", # noqa: ARG002 + ) -> None: + """Flash the LED to identify the board (simulated).""" + for _ in range(number_of_flashes): + self.led_on = False + time.sleep(dt) + self.led_on = True + time.sleep(dt) From 8435f6ae981fd82684e20d6764d5ddf077e0a164 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Tue, 10 Feb 2026 18:06:21 +0000 Subject: [PATCH 02/15] Move illumination to new IlluminationThing --- ofm_config_full.json | 1 + .../things/illumination.py | 54 +++++++++++++++++++ .../things/stage/sangaboard.py | 37 +------------ 3 files changed, 56 insertions(+), 36 deletions(-) create mode 100644 src/openflexure_microscope_server/things/illumination.py diff --git a/ofm_config_full.json b/ofm_config_full.json index 75534b10..885b65a2 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -10,6 +10,7 @@ "autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing", "camera_stage_mapping": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper", "system": "openflexure_microscope_server.things.system:OpenFlexureSystem", + "illumination": "openflexure_microscope_server.things.illumination:SangaIllumination", "smart_scan": { "class": "openflexure_microscope_server.things.smart_scan:SmartScanThing", "kwargs": { diff --git a/src/openflexure_microscope_server/things/illumination.py b/src/openflexure_microscope_server/things/illumination.py new file mode 100644 index 00000000..39d752e8 --- /dev/null +++ b/src/openflexure_microscope_server/things/illumination.py @@ -0,0 +1,54 @@ +import time +from typing import Literal + +import labthings_fastapi as lt + + +class Illumination(lt.Thing): + """Abstract illumination controller.""" + + brightness: float = lt.property( + default=0.0, + description="Illumination brightness (0-1)", + ) + + @lt.action + def set_brightness(self, brightness: float) -> None: + self.brightness = brightness + + @lt.action + def flash( + self, + number_of_flashes: int = 10, + dt: float = 0.5, + ) -> None: + """Flash the illumination source.""" + for _ in range(number_of_flashes): + self.set_brightness(1.0) + time.sleep(dt) + self.set_brightness(0.0) + time.sleep(dt) + + +class SangaIllumination(Illumination): + """Illumination driven by a Sangaboard.""" + + sangaboard = lt.thing_slot(description="Sangaboard providing LED control") + + @lt.action + def set_brightness(self, brightness: float) -> None: + self.sangaboard.set_led_brightness(brightness) + self.brightness = brightness + + @lt.action + def flash( + self, + number_of_flashes: int = 10, + dt: float = 0.5, + led_channel: Literal["cc"] = "cc", + ) -> None: + for _ in range(number_of_flashes): + self.sangaboard.set_led(True, led_channel) + time.sleep(dt) + self.sangaboard.set_led(False, led_channel) + time.sleep(dt) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index db64ccc3..800a0e31 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -3,11 +3,10 @@ from __future__ import annotations import threading -import time from contextlib import contextmanager from copy import copy from types import TracebackType -from typing import Any, Iterator, Literal, Optional, Self +from typing import Any, Iterator, Optional, Self import semver @@ -173,37 +172,3 @@ class SangaboardThing(BaseStage): with self.sangaboard() as sb: sb.zero_position() self.update_position() - - @lt.action - def flash_led( - self, - number_of_flashes: int = 10, - dt: float = 0.5, - led_channel: Literal["cc"] = "cc", - ) -> None: - """Flash the LED to identify the board. - - This is intended to be useful in situations where there are multiple - Sangaboards in use, and it is necessary to identify which one is - being addressed. - """ - led_command = f"led_{led_channel}" - with self.sangaboard() as sb: - return_value = sb.query(f"{led_command}?") - if not return_value.startswith("CC LED:"): - raise IOError("The sangaboard does not support LED control") - - # Reading and setting LED brightness suffers from repeated reads and writes - # decreasing the value. Rather than use the value the code warns that the value - # cannot be used. - intended_brightness = float(return_value[7:]) - on_brightness = 0.32 - self.logger.warning( - "Brightness control is not yet implemented. Desired brightness: " - f"{intended_brightness}. Set brightness: {on_brightness}" - ) - for _i in range(number_of_flashes): - sb.query(f"{led_command} 0") - time.sleep(dt) - sb.query(f"{led_command} {on_brightness}") - time.sleep(dt) From 5682b4d28d35aa8cf32819c227e496f7290359ec Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 10 Feb 2026 18:42:59 +0000 Subject: [PATCH 03/15] Illumination Things for sangaboard and simulator --- .../things/camera/simulation.py | 13 +++-- .../things/illumination.py | 49 ++++++++++--------- .../things/stage/sangaboard.py | 30 +++++++++++- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 521fc7cc..c22b32e6 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -140,6 +140,7 @@ class SimulatedCamera(BaseCamera): self._capture_thread: Optional[Thread] = None self._capture_enabled = False self.generate_sprites() + self.mult = 1 repeating: bool = lt.property(default=False) @@ -339,14 +340,20 @@ class SimulatedCamera(BaseCamera): pl_img = Image.fromarray(np_img.astype("uint8")) return pl_img.resize((self.shape[1], self.shape[0]), Image.Resampling.BILINEAR) + @lt.action + def set_led(self, led_on: bool = True, led_channel=None): + if led_on: + self.mult = 1 + else: + self.mult = 0 + + def generate_frame(self) -> Image.Image: """Generate a frame with blobs based on the stage coordinates.""" pos = self._stage.instantaneous_position frame = self.generate_image((pos["y"], pos["x"], pos["z"])) # Simulate LED turning off by setting all channels to 0 - if not self._stage.led_on: - frame = np.full((self.shape[0], self.shape[1], 3), 0, dtype=np.uint8) - return frame + return frame * self.mult def __enter__(self) -> Self: """Start the capture thread when the Thing context manager is opened.""" diff --git a/src/openflexure_microscope_server/things/illumination.py b/src/openflexure_microscope_server/things/illumination.py index 39d752e8..7cfddf50 100644 --- a/src/openflexure_microscope_server/things/illumination.py +++ b/src/openflexure_microscope_server/things/illumination.py @@ -3,19 +3,12 @@ from typing import Literal import labthings_fastapi as lt +from .stage import BaseStage +from .camera import BaseCam class Illumination(lt.Thing): """Abstract illumination controller.""" - brightness: float = lt.property( - default=0.0, - description="Illumination brightness (0-1)", - ) - - @lt.action - def set_brightness(self, brightness: float) -> None: - self.brightness = brightness - @lt.action def flash( self, @@ -23,22 +16,14 @@ class Illumination(lt.Thing): dt: float = 0.5, ) -> None: """Flash the illumination source.""" - for _ in range(number_of_flashes): - self.set_brightness(1.0) - time.sleep(dt) - self.set_brightness(0.0) - time.sleep(dt) - + raise NotImplementedError( + 'Flashing the LED can only be done from the simulator or sangaboard' + ) class SangaIllumination(Illumination): """Illumination driven by a Sangaboard.""" - sangaboard = lt.thing_slot(description="Sangaboard providing LED control") - - @lt.action - def set_brightness(self, brightness: float) -> None: - self.sangaboard.set_led_brightness(brightness) - self.brightness = brightness + _stage: BaseStage = lt.thing_slot() @lt.action def flash( @@ -48,7 +33,25 @@ class SangaIllumination(Illumination): led_channel: Literal["cc"] = "cc", ) -> None: for _ in range(number_of_flashes): - self.sangaboard.set_led(True, led_channel) + self._stage.set_led(False, led_channel) time.sleep(dt) - self.sangaboard.set_led(False, led_channel) + self._stage.set_led(True, led_channel) + time.sleep(dt) + +class SimulatorIllumination(Illumination): + """Illumination control in the simulator.""" + + _cam: BaseCam = lt.thing_slot() + + @lt.action + def flash( + self, + number_of_flashes: int = 10, + dt: float = 0.5, + led_channel: Literal["cc"] = "cc", + ) -> None: + for _ in range(number_of_flashes): + self._cam.set_led(False, led_channel) + time.sleep(dt) + self._cam.set_led(True, led_channel) time.sleep(dt) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 800a0e31..fb313337 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -6,7 +6,7 @@ import threading from contextlib import contextmanager from copy import copy from types import TracebackType -from typing import Any, Iterator, Optional, Self +from typing import Any, Iterator, Literal, Optional, Self import semver @@ -172,3 +172,31 @@ class SangaboardThing(BaseStage): with self.sangaboard() as sb: sb.zero_position() self.update_position() + + @lt.action + def set_led( + self, + led_on: bool = True, + led_channel: Literal["cc"] = "cc", + ) -> None: + """Flash the LED to identify the board. + + This is intended to be useful in situations where there are multiple + Sangaboards in use, and it is necessary to identify which one is + being addressed. + """ + led_command = f"led_{led_channel}" + with self.sangaboard() as sb: + return_value = sb.query(f"{led_command}?") + if not return_value.startswith("CC LED:"): + raise IOError("The sangaboard does not support LED control") + + # Reading and setting LED brightness suffers from repeated reads and writes + # decreasing the value. Rather than use the value the code warns that the value + # cannot be used. + if led_on: + on_brightness = 0.32 + sb.query(f"{led_command} {on_brightness}") + else: + sb.query(f"{led_command} 0") + From 4d788a28179e0856170b1ffdc2d2886e935a79a1 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Tue, 10 Feb 2026 22:13:09 +0000 Subject: [PATCH 04/15] Remove flash from stage baseclass, add to sim config --- ofm_config_simulation.json | 1 + .../things/camera/simulation.py | 8 ++++--- .../things/illumination.py | 21 ++++++++++++++++--- .../things/stage/dummy.py | 19 +---------------- .../things/stage/sangaboard.py | 3 +-- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/ofm_config_simulation.json b/ofm_config_simulation.json index b061dbd8..21b97694 100644 --- a/ofm_config_simulation.json +++ b/ofm_config_simulation.json @@ -4,6 +4,7 @@ "stage": "openflexure_microscope_server.things.stage.dummy:DummyStage", "autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing", "camera_stage_mapping": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper", + "illumination": "openflexure_microscope_server.things.illumination.SimulatorIllumination", "system": "openflexure_microscope_server.things.system:OpenFlexureSystem", "smart_scan": { "class": "openflexure_microscope_server.things.smart_scan:SmartScanThing", diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index c22b32e6..730a6539 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -341,19 +341,21 @@ class SimulatedCamera(BaseCamera): return pl_img.resize((self.shape[1], self.shape[0]), Image.Resampling.BILINEAR) @lt.action - def set_led(self, led_on: bool = True, led_channel=None): + def set_led(self, led_on: bool = True, led_channel: str = None) -> None: # noqa: ARG002 + """Set the simulated LED to on or off.""" if led_on: self.mult = 1 else: self.mult = 0 - def generate_frame(self) -> Image.Image: """Generate a frame with blobs based on the stage coordinates.""" pos = self._stage.instantaneous_position frame = self.generate_image((pos["y"], pos["x"], pos["z"])) # Simulate LED turning off by setting all channels to 0 - return frame * self.mult + if self.mult == 0: + return Image.new(frame.mode, frame.size, 0) + return frame def __enter__(self) -> Self: """Start the capture thread when the Thing context manager is opened.""" diff --git a/src/openflexure_microscope_server/things/illumination.py b/src/openflexure_microscope_server/things/illumination.py index 7cfddf50..d1d5e193 100644 --- a/src/openflexure_microscope_server/things/illumination.py +++ b/src/openflexure_microscope_server/things/illumination.py @@ -1,10 +1,13 @@ +"""A module to handle the illumination in LabThings.""" + import time from typing import Literal import labthings_fastapi as lt +from .camera import BaseCamera from .stage import BaseStage -from .camera import BaseCam + class Illumination(lt.Thing): """Abstract illumination controller.""" @@ -17,9 +20,10 @@ class Illumination(lt.Thing): ) -> None: """Flash the illumination source.""" raise NotImplementedError( - 'Flashing the LED can only be done from the simulator or sangaboard' + "Flashing the LED can only be done from the simulator or sangaboard" ) + class SangaIllumination(Illumination): """Illumination driven by a Sangaboard.""" @@ -32,16 +36,22 @@ class SangaIllumination(Illumination): dt: float = 0.5, led_channel: Literal["cc"] = "cc", ) -> None: + """Flash an LED a given number of times. + + Flashes the LED on channel led_channel number_of_flashes times, + for a duration of dt each. + """ for _ in range(number_of_flashes): self._stage.set_led(False, led_channel) time.sleep(dt) self._stage.set_led(True, led_channel) time.sleep(dt) + class SimulatorIllumination(Illumination): """Illumination control in the simulator.""" - _cam: BaseCam = lt.thing_slot() + _cam: BaseCamera = lt.thing_slot() @lt.action def flash( @@ -50,6 +60,11 @@ class SimulatorIllumination(Illumination): dt: float = 0.5, led_channel: Literal["cc"] = "cc", ) -> None: + """Flash an LED a given number of times. + + Flashes the LED on channel led_channel number_of_flashes times, + for a duration of dt each. + """ for _ in range(number_of_flashes): self._cam.set_led(False, led_channel) time.sleep(dt) diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 82515d17..7130fcb7 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -4,7 +4,7 @@ from __future__ import annotations import time from types import TracebackType -from typing import Any, Literal, Optional, Self +from typing import Any, Optional, Self import labthings_fastapi as lt @@ -18,8 +18,6 @@ class DummyStage(BaseStage): hardware attached. """ - led_on: bool = lt.property(default=True) - def __init__( self, thing_server_interface: lt.ThingServerInterface, @@ -117,18 +115,3 @@ class DummyStage(BaseStage): """ self._hardware_position = dict.fromkeys(self.axis_names, 0) self.instantaneous_position = self._hardware_position - - # led_channel is unused in the simulation, but must exist to match the real API - @lt.action - def flash_led( - self, - number_of_flashes: int = 10, - dt: float = 0.5, - led_channel: Literal["cc"] = "cc", # noqa: ARG002 - ) -> None: - """Flash the LED to identify the board (simulated).""" - for _ in range(number_of_flashes): - self.led_on = False - time.sleep(dt) - self.led_on = True - time.sleep(dt) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index fb313337..9a9b7fe5 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -197,6 +197,5 @@ class SangaboardThing(BaseStage): if led_on: on_brightness = 0.32 sb.query(f"{led_command} {on_brightness}") - else: + else: sb.query(f"{led_command} 0") - From f05cb396c8ba7f9296c28db2057b51d51ae01018 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Tue, 10 Feb 2026 22:20:22 +0000 Subject: [PATCH 05/15] Update FlashLED to new endpoint in webapp --- .../tabContentComponents/aboutComponents/statusPane.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue b/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue index 6b4e8c6f..ce603f83 100644 --- a/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue +++ b/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue @@ -14,8 +14,8 @@ Date: Wed, 11 Feb 2026 10:19:19 +0000 Subject: [PATCH 06/15] Apply suggestions from code review of branch flash-led-sim Co-authored-by: Julian Stirling --- .../things/camera/simulation.py | 2 +- .../things/illumination.py | 8 ++++---- .../things/stage/sangaboard.py | 1 - .../aboutComponents/statusPane.vue | 12 +++++++++++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 730a6539..916a6067 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -340,7 +340,7 @@ class SimulatedCamera(BaseCamera): pl_img = Image.fromarray(np_img.astype("uint8")) return pl_img.resize((self.shape[1], self.shape[0]), Image.Resampling.BILINEAR) - @lt.action + def set_led(self, led_on: bool = True) -> None: def set_led(self, led_on: bool = True, led_channel: str = None) -> None: # noqa: ARG002 """Set the simulated LED to on or off.""" if led_on: diff --git a/src/openflexure_microscope_server/things/illumination.py b/src/openflexure_microscope_server/things/illumination.py index d1d5e193..d9d0d2c6 100644 --- a/src/openflexure_microscope_server/things/illumination.py +++ b/src/openflexure_microscope_server/things/illumination.py @@ -5,8 +5,8 @@ from typing import Literal import labthings_fastapi as lt -from .camera import BaseCamera -from .stage import BaseStage +from .camera import SimulatedCamera +from .stage import SangaboardThing class Illumination(lt.Thing): @@ -27,7 +27,7 @@ class Illumination(lt.Thing): class SangaIllumination(Illumination): """Illumination driven by a Sangaboard.""" - _stage: BaseStage = lt.thing_slot() + _stage: SangaboardThing = lt.thing_slot() @lt.action def flash( @@ -51,7 +51,7 @@ class SangaIllumination(Illumination): class SimulatorIllumination(Illumination): """Illumination control in the simulator.""" - _cam: BaseCamera = lt.thing_slot() + _cam: SimulatedCamera = lt.thing_slot() @lt.action def flash( diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 9a9b7fe5..7429aea3 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -173,7 +173,6 @@ class SangaboardThing(BaseStage): sb.zero_position() self.update_position() - @lt.action def set_led( self, led_on: bool = True, diff --git a/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue b/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue index ce603f83..a2cd0b5d 100644 --- a/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue +++ b/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue @@ -13,7 +13,7 @@ {{ $store.state.origin }} +
+ Illumination: +
+
+ {{ illuminationType }} +
+

@@ -77,6 +84,9 @@ export default { stageType() { return this.thingAvailable("stage") ? this.thingDescription("stage").title : undefined; }, + illuminationType() { + return this.thingAvailable("illumination") ? this.thingDescription("illumination").title : undefined; + }, }, async mounted() { From 16458db85bc7961a7063749dd44ce4ff4ca7c7f0 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 11 Feb 2026 10:26:04 +0000 Subject: [PATCH 07/15] Fix simulated imports --- .../things/camera/simulation.py | 1 - .../things/illumination.py | 11 +++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 916a6067..a8a00862 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -341,7 +341,6 @@ class SimulatedCamera(BaseCamera): return pl_img.resize((self.shape[1], self.shape[0]), Image.Resampling.BILINEAR) def set_led(self, led_on: bool = True) -> None: - def set_led(self, led_on: bool = True, led_channel: str = None) -> None: # noqa: ARG002 """Set the simulated LED to on or off.""" if led_on: self.mult = 1 diff --git a/src/openflexure_microscope_server/things/illumination.py b/src/openflexure_microscope_server/things/illumination.py index d9d0d2c6..fdbb2431 100644 --- a/src/openflexure_microscope_server/things/illumination.py +++ b/src/openflexure_microscope_server/things/illumination.py @@ -5,8 +5,8 @@ from typing import Literal import labthings_fastapi as lt -from .camera import SimulatedCamera -from .stage import SangaboardThing +from .camera.simulation import SimulatedCamera +from .stage.sangaboard import SangaboardThing class Illumination(lt.Thing): @@ -58,15 +58,14 @@ class SimulatorIllumination(Illumination): self, number_of_flashes: int = 10, dt: float = 0.5, - led_channel: Literal["cc"] = "cc", ) -> None: """Flash an LED a given number of times. - Flashes the LED on channel led_channel number_of_flashes times, + Flashes the simulated LED number_of_flashes times, for a duration of dt each. """ for _ in range(number_of_flashes): - self._cam.set_led(False, led_channel) + self._cam.set_led(False) time.sleep(dt) - self._cam.set_led(True, led_channel) + self._cam.set_led(True) time.sleep(dt) From d4dc113a602925bd47ea492c71a2effdf413333c Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 11 Feb 2026 12:12:43 +0000 Subject: [PATCH 08/15] Linter fix in statusPane --- .../tabContentComponents/aboutComponents/statusPane.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue b/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue index a2cd0b5d..209df9ef 100644 --- a/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue +++ b/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue @@ -85,7 +85,9 @@ export default { return this.thingAvailable("stage") ? this.thingDescription("stage").title : undefined; }, illuminationType() { - return this.thingAvailable("illumination") ? this.thingDescription("illumination").title : undefined; + return this.thingAvailable("illumination") + ? this.thingDescription("illumination").title + : undefined; }, }, From 21f42d4698c341ce7ada7b93296570236fbb523f Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 11 Feb 2026 12:58:46 +0000 Subject: [PATCH 09/15] Remove flash-led from SangaTest --- tests/unit_tests/test_stage.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/unit_tests/test_stage.py b/tests/unit_tests/test_stage.py index aeedc0dc..25c19fa1 100644 --- a/tests/unit_tests/test_stage.py +++ b/tests/unit_tests/test_stage.py @@ -284,9 +284,6 @@ def test_thing_description_equivalence(dummy_stage, mocker): mocker.patch.dict("sys.modules", {"sangaboard": mock_sangaboard}) from openflexure_microscope_server.things.stage.sangaboard import SangaboardThing - # Flash LED isn't a standard stage action, most stages do not control illumination. - extra_sanga_actions = ["flash_led"] - base_td = create_thing_without_server(BaseStage).thing_description() base_actions = set(base_td.actions.keys()) base_properties = set(base_td.properties.keys()) @@ -298,9 +295,6 @@ def test_thing_description_equivalence(dummy_stage, mocker): sanga_td = create_thing_without_server(SangaboardThing).thing_description() # Remove known extra actions sanga_actions = list(sanga_td.actions.keys()) - for action in extra_sanga_actions: - index = sanga_actions.index(action) - sanga_actions.pop(index) sanga_actions = set(sanga_actions) sanga_properties = set(sanga_td.properties.keys()) From 7da3dbbf8fddc47925523c9857e2753223e5a9a4 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 11 Feb 2026 16:11:39 +0000 Subject: [PATCH 10/15] Test illumination --- tests/unit_tests/test_illumination.py | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/unit_tests/test_illumination.py diff --git a/tests/unit_tests/test_illumination.py b/tests/unit_tests/test_illumination.py new file mode 100644 index 00000000..f8edbd8a --- /dev/null +++ b/tests/unit_tests/test_illumination.py @@ -0,0 +1,58 @@ +"""Tests for the Illumination thing.""" + +import pytest + +from labthings_fastapi.testing import create_thing_without_server + +from openflexure_microscope_server.things.illumination import ( + SangaIllumination, + SimulatorIllumination, +) + + +@pytest.fixture +def sanga_mock_stage(): + """Return a SangaIllumination with a mocked stage.""" + return create_thing_without_server(SangaIllumination, mock_all_slots=True) + + +@pytest.fixture +def sim_mock_cam(): + """Return a SimulatorIllumination with a mocked camera.""" + return create_thing_without_server(SimulatorIllumination, mock_all_slots=True) + + +def test_sanga_flash_calls_led(sanga_mock_stage): + """Test that flashing calls set_led with correct parameters.""" + number_of_flashes = 3 + dt = 0.01 # tiny sleep for test speed + + sanga_mock_stage.flash(number_of_flashes=number_of_flashes, dt=dt) + + # Each flash calls set_led False then True + assert sanga_mock_stage._stage.set_led.call_count == 2 * number_of_flashes + + # Check that False and True are alternating when sent to _stage.set_led + calls = sanga_mock_stage._stage.set_led.call_args_list + expected_sequence = [False, True] * number_of_flashes + for call, expected in zip(calls, expected_sequence, strict=True): + args, kwargs = call + assert args[0] == expected + + +def test_sim_flash_calls_led(sim_mock_cam): + """Test that simulator flash calls set_led correctly.""" + number_of_flashes = 2 + dt = 0.01 + + sim_mock_cam.flash(number_of_flashes=number_of_flashes, dt=dt) + + # Each flash calls set_led False then True + assert sim_mock_cam._cam.set_led.call_count == 2 * number_of_flashes + + # Check that False and True are alternating when sent to _cam.set_led + calls = sim_mock_cam._cam.set_led.call_args_list + expected_sequence = [False, True] * number_of_flashes + for call, expected in zip(calls, expected_sequence, strict=True): + args, kwargs = call + assert args[0] == expected From 3423d4ed0e85418a27d00feab9457f5a5ae472c3 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 11 Feb 2026 16:13:32 +0000 Subject: [PATCH 11/15] Test calling base Illumination --- tests/unit_tests/test_illumination.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit_tests/test_illumination.py b/tests/unit_tests/test_illumination.py index f8edbd8a..1c675c0a 100644 --- a/tests/unit_tests/test_illumination.py +++ b/tests/unit_tests/test_illumination.py @@ -5,11 +5,19 @@ import pytest from labthings_fastapi.testing import create_thing_without_server from openflexure_microscope_server.things.illumination import ( + Illumination, SangaIllumination, SimulatorIllumination, ) +def test_base_illumination_flash_not_implemented(): + """Check that calling flash on the base Illumination raises NotImplementedError.""" + ill = create_thing_without_server(Illumination) + with pytest.raises(NotImplementedError, match="Flashing the LED can only be done"): + ill.flash(number_of_flashes=1, dt=0.01) + + @pytest.fixture def sanga_mock_stage(): """Return a SangaIllumination with a mocked stage.""" From 18477a663d1ba97292a3f87afc05fe75d79a8b53 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 12 Feb 2026 13:40:20 +0000 Subject: [PATCH 12/15] Define flash in illumination base class --- .../things/illumination.py | 60 ++++++++----------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/src/openflexure_microscope_server/things/illumination.py b/src/openflexure_microscope_server/things/illumination.py index fdbb2431..2e444138 100644 --- a/src/openflexure_microscope_server/things/illumination.py +++ b/src/openflexure_microscope_server/things/illumination.py @@ -10,19 +10,28 @@ from .stage.sangaboard import SangaboardThing class Illumination(lt.Thing): - """Abstract illumination controller.""" + """Base class for an illumination controller.""" @lt.action - def flash( - self, - number_of_flashes: int = 10, - dt: float = 0.5, - ) -> None: - """Flash the illumination source.""" + def set_led(self, led_on: bool = True) -> None: + """Set the LED to on or off.""" raise NotImplementedError( - "Flashing the LED can only be done from the simulator or sangaboard" + "Turning the LED on and off should be implemented by child classes." ) + @lt.action + def flash(self, number_of_flashes: int = 10, dt: float = 0.5) -> None: + """Flash the illumination source. + + :param number_of_flashes: Number of times to flash the LED. + :param dt: Flash duration in seconds. + """ + for _ in range(number_of_flashes): + self.set_led(False) + time.sleep(dt) + self.set_led(True) + time.sleep(dt) + class SangaIllumination(Illumination): """Illumination driven by a Sangaboard.""" @@ -30,22 +39,13 @@ class SangaIllumination(Illumination): _stage: SangaboardThing = lt.thing_slot() @lt.action - def flash( + def set_led( self, - number_of_flashes: int = 10, - dt: float = 0.5, + led_on: bool = True, led_channel: Literal["cc"] = "cc", ) -> None: - """Flash an LED a given number of times. - - Flashes the LED on channel led_channel number_of_flashes times, - for a duration of dt each. - """ - for _ in range(number_of_flashes): - self._stage.set_led(False, led_channel) - time.sleep(dt) - self._stage.set_led(True, led_channel) - time.sleep(dt) + """Set the LED to on or off.""" + self._stage.set_led(led_on, led_channel) class SimulatorIllumination(Illumination): @@ -54,18 +54,6 @@ class SimulatorIllumination(Illumination): _cam: SimulatedCamera = lt.thing_slot() @lt.action - def flash( - self, - number_of_flashes: int = 10, - dt: float = 0.5, - ) -> None: - """Flash an LED a given number of times. - - Flashes the simulated LED number_of_flashes times, - for a duration of dt each. - """ - for _ in range(number_of_flashes): - self._cam.set_led(False) - time.sleep(dt) - self._cam.set_led(True) - time.sleep(dt) + def set_led(self, led_on: bool = True) -> None: + """Set the LED to on or off.""" + self._cam.set_led(led_on) From fdf33ce4a88d0e51529a973c8541417c6234fdff Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 12 Feb 2026 13:48:12 +0000 Subject: [PATCH 13/15] Apply suggestions from code review of branch flash-led-sim Co-authored-by: Julian Stirling --- .../things/camera/simulation.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index a8a00862..36dc600a 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -140,7 +140,8 @@ class SimulatedCamera(BaseCamera): self._capture_thread: Optional[Thread] = None self._capture_enabled = False self.generate_sprites() - self.mult = 1 + # Whether the LED is on + self.led_on = True repeating: bool = lt.property(default=False) @@ -342,17 +343,14 @@ class SimulatedCamera(BaseCamera): def set_led(self, led_on: bool = True) -> None: """Set the simulated LED to on or off.""" - if led_on: - self.mult = 1 - else: - self.mult = 0 + self.led_on = led_on def generate_frame(self) -> Image.Image: """Generate a frame with blobs based on the stage coordinates.""" pos = self._stage.instantaneous_position frame = self.generate_image((pos["y"], pos["x"], pos["z"])) # Simulate LED turning off by setting all channels to 0 - if self.mult == 0: + if not self.led_on: return Image.new(frame.mode, frame.size, 0) return frame From bb5cce3d4e3f6b04ab25ad22be4c1d93897ee75a Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 12 Feb 2026 13:54:12 +0000 Subject: [PATCH 14/15] Only generate frame if LED is on --- .../things/camera/simulation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 36dc600a..a374f764 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -347,12 +347,12 @@ class SimulatedCamera(BaseCamera): def generate_frame(self) -> Image.Image: """Generate a frame with blobs based on the stage coordinates.""" - pos = self._stage.instantaneous_position - frame = self.generate_image((pos["y"], pos["x"], pos["z"])) # Simulate LED turning off by setting all channels to 0 if not self.led_on: - return Image.new(frame.mode, frame.size, 0) - return frame + return Image.new(mode="RGB", size=(self.shape[1], self.shape[0]), color=0) + # Otherwise, generate a frame from current position + pos = self._stage.instantaneous_position + return self.generate_image((pos["y"], pos["x"], pos["z"])) def __enter__(self) -> Self: """Start the capture thread when the Thing context manager is opened.""" From e0a8eb08c8cbc6e38c6af45537cad5c27a937227 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 12 Feb 2026 15:33:58 +0000 Subject: [PATCH 15/15] Test illumination baseclass --- tests/unit_tests/test_illumination.py | 77 ++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/tests/unit_tests/test_illumination.py b/tests/unit_tests/test_illumination.py index 1c675c0a..a7e02ee1 100644 --- a/tests/unit_tests/test_illumination.py +++ b/tests/unit_tests/test_illumination.py @@ -1,5 +1,7 @@ """Tests for the Illumination thing.""" +from unittest.mock import call, patch + import pytest from labthings_fastapi.testing import create_thing_without_server @@ -10,12 +12,71 @@ from openflexure_microscope_server.things.illumination import ( SimulatorIllumination, ) +# Test the BaseClass before testing specific implementation -def test_base_illumination_flash_not_implemented(): - """Check that calling flash on the base Illumination raises NotImplementedError.""" + +def test_base_illumination_set_led_not_implemented(): + """Check that calling set_led on the base Illumination raises NotImplementedError.""" ill = create_thing_without_server(Illumination) - with pytest.raises(NotImplementedError, match="Flashing the LED can only be done"): - ill.flash(number_of_flashes=1, dt=0.01) + with pytest.raises( + NotImplementedError, + match="Turning the LED on and off should be implemented by child classes.", + ): + ill.set_led(led_on=True) + + +def test_flash_calls_set_led_and_raises(): + """Check that calling flash from Baseclass calls flash with right arguments.""" + ill = create_thing_without_server(Illumination) + + number_of_flashes = 3 # smaller number for test speed + dt = 0 # avoid sleeping + + with pytest.raises(NotImplementedError): + ill.flash(number_of_flashes=number_of_flashes, dt=dt) + + # Patch set_led to just record calls without raising + with patch.object(ill, "set_led") as mock_set_led: + # Call flash normally + ill.flash(number_of_flashes=number_of_flashes, dt=dt) + + # Each flash calls set_led twice: False, True + expected_sequence = [call(False), call(True)] * number_of_flashes + assert mock_set_led.call_args_list == expected_sequence + + # Also check total number of calls + assert mock_set_led.call_count == number_of_flashes * 2 + + +def test_negative_flash_calls(): + """Check that calling flash with negative number doesn't call set_led.""" + ill = create_thing_without_server(Illumination) + + number_of_flashes = -33 # smaller number for test speed + dt = 0 # avoid sleeping + + # Patch set_led to just record calls without raising + with patch.object(ill, "set_led") as mock_set_led: + # Call flash normally + ill.flash(number_of_flashes=number_of_flashes, dt=dt) + + # Also check total number of calls + assert mock_set_led.call_count == 0 + + +def test_negative_flash_dt(): + """Check that calling flash with negative dt raises ValueError.""" + ill = create_thing_without_server(Illumination) + + number_of_flashes = 3 # smaller number for test speed + dt = -5 # set a negative delay + + # Patch set_led to just record calls without raising + with ( + patch.object(ill, "set_led"), + pytest.raises(ValueError, match="sleep length must be non-negative"), + ): + ill.flash(number_of_flashes=number_of_flashes, dt=dt) @pytest.fixture @@ -43,8 +104,8 @@ def test_sanga_flash_calls_led(sanga_mock_stage): # Check that False and True are alternating when sent to _stage.set_led calls = sanga_mock_stage._stage.set_led.call_args_list expected_sequence = [False, True] * number_of_flashes - for call, expected in zip(calls, expected_sequence, strict=True): - args, kwargs = call + for led_call, expected in zip(calls, expected_sequence, strict=True): + args, kwargs = led_call assert args[0] == expected @@ -61,6 +122,6 @@ def test_sim_flash_calls_led(sim_mock_cam): # Check that False and True are alternating when sent to _cam.set_led calls = sim_mock_cam._cam.set_led.call_args_list expected_sequence = [False, True] * number_of_flashes - for call, expected in zip(calls, expected_sequence, strict=True): - args, kwargs = call + for led_call, expected in zip(calls, expected_sequence, strict=True): + args, kwargs = led_call assert args[0] == expected