Merge branch 'flash-led-sim' into 'v3'

Illumination Thing

See merge request openflexure/openflexure-microscope-server!468
This commit is contained in:
Joe Knapper 2026-02-12 16:37:53 +00:00
commit 704fc5569f
8 changed files with 219 additions and 24 deletions

View file

@ -10,6 +10,7 @@
"autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing", "autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing",
"camera_stage_mapping": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper", "camera_stage_mapping": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper",
"system": "openflexure_microscope_server.things.system:OpenFlexureSystem", "system": "openflexure_microscope_server.things.system:OpenFlexureSystem",
"illumination": "openflexure_microscope_server.things.illumination:SangaIllumination",
"smart_scan": { "smart_scan": {
"class": "openflexure_microscope_server.things.smart_scan:SmartScanThing", "class": "openflexure_microscope_server.things.smart_scan:SmartScanThing",
"kwargs": { "kwargs": {

View file

@ -4,6 +4,7 @@
"stage": "openflexure_microscope_server.things.stage.dummy:DummyStage", "stage": "openflexure_microscope_server.things.stage.dummy:DummyStage",
"autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing", "autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing",
"camera_stage_mapping": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper", "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", "system": "openflexure_microscope_server.things.system:OpenFlexureSystem",
"smart_scan": { "smart_scan": {
"class": "openflexure_microscope_server.things.smart_scan:SmartScanThing", "class": "openflexure_microscope_server.things.smart_scan:SmartScanThing",

View file

@ -140,6 +140,8 @@ class SimulatedCamera(BaseCamera):
self._capture_thread: Optional[Thread] = None self._capture_thread: Optional[Thread] = None
self._capture_enabled = False self._capture_enabled = False
self.generate_sprites() self.generate_sprites()
# Whether the LED is on
self.led_on = True
repeating: bool = lt.property(default=False) repeating: bool = lt.property(default=False)
@ -339,8 +341,16 @@ class SimulatedCamera(BaseCamera):
pl_img = Image.fromarray(np_img.astype("uint8")) pl_img = Image.fromarray(np_img.astype("uint8"))
return pl_img.resize((self.shape[1], self.shape[0]), Image.Resampling.BILINEAR) return pl_img.resize((self.shape[1], self.shape[0]), Image.Resampling.BILINEAR)
def set_led(self, led_on: bool = True) -> None:
"""Set the simulated LED to on or off."""
self.led_on = led_on
def generate_frame(self) -> Image.Image: def generate_frame(self) -> Image.Image:
"""Generate a frame with blobs based on the stage coordinates.""" """Generate a frame with blobs based on the stage coordinates."""
# Simulate LED turning off by setting all channels to 0
if not self.led_on:
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 pos = self._stage.instantaneous_position
return self.generate_image((pos["y"], pos["x"], pos["z"])) return self.generate_image((pos["y"], pos["x"], pos["z"]))

View file

@ -0,0 +1,59 @@
"""A module to handle the illumination in LabThings."""
import time
from typing import Literal
import labthings_fastapi as lt
from .camera.simulation import SimulatedCamera
from .stage.sangaboard import SangaboardThing
class Illumination(lt.Thing):
"""Base class for an illumination controller."""
@lt.action
def set_led(self, led_on: bool = True) -> None:
"""Set the LED to on or off."""
raise NotImplementedError(
"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."""
_stage: SangaboardThing = lt.thing_slot()
@lt.action
def set_led(
self,
led_on: bool = True,
led_channel: Literal["cc"] = "cc",
) -> None:
"""Set the LED to on or off."""
self._stage.set_led(led_on, led_channel)
class SimulatorIllumination(Illumination):
"""Illumination control in the simulator."""
_cam: SimulatedCamera = lt.thing_slot()
@lt.action
def set_led(self, led_on: bool = True) -> None:
"""Set the LED to on or off."""
self._cam.set_led(led_on)

View file

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import threading import threading
import time
from contextlib import contextmanager from contextlib import contextmanager
from copy import copy from copy import copy
from types import TracebackType from types import TracebackType
@ -174,11 +173,9 @@ class SangaboardThing(BaseStage):
sb.zero_position() sb.zero_position()
self.update_position() self.update_position()
@lt.action def set_led(
def flash_led(
self, self,
number_of_flashes: int = 10, led_on: bool = True,
dt: float = 0.5,
led_channel: Literal["cc"] = "cc", led_channel: Literal["cc"] = "cc",
) -> None: ) -> None:
"""Flash the LED to identify the board. """Flash the LED to identify the board.
@ -196,14 +193,8 @@ class SangaboardThing(BaseStage):
# Reading and setting LED brightness suffers from repeated reads and writes # 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 # decreasing the value. Rather than use the value the code warns that the value
# cannot be used. # cannot be used.
intended_brightness = float(return_value[7:]) if led_on:
on_brightness = 0.32 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}") sb.query(f"{led_command} {on_brightness}")
time.sleep(dt) else:
sb.query(f"{led_command} 0")

View file

@ -0,0 +1,127 @@
"""Tests for the Illumination thing."""
from unittest.mock import call, patch
import pytest
from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things.illumination import (
Illumination,
SangaIllumination,
SimulatorIllumination,
)
# Test the BaseClass before testing specific implementation
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="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
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 led_call, expected in zip(calls, expected_sequence, strict=True):
args, kwargs = led_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 led_call, expected in zip(calls, expected_sequence, strict=True):
args, kwargs = led_call
assert args[0] == expected

View file

@ -284,9 +284,6 @@ def test_thing_description_equivalence(dummy_stage, mocker):
mocker.patch.dict("sys.modules", {"sangaboard": mock_sangaboard}) mocker.patch.dict("sys.modules", {"sangaboard": mock_sangaboard})
from openflexure_microscope_server.things.stage.sangaboard import SangaboardThing 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_td = create_thing_without_server(BaseStage).thing_description()
base_actions = set(base_td.actions.keys()) base_actions = set(base_td.actions.keys())
base_properties = set(base_td.properties.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() sanga_td = create_thing_without_server(SangaboardThing).thing_description()
# Remove known extra actions # Remove known extra actions
sanga_actions = list(sanga_td.actions.keys()) 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_actions = set(sanga_actions)
sanga_properties = set(sanga_td.properties.keys()) sanga_properties = set(sanga_td.properties.keys())

View file

@ -13,9 +13,9 @@
{{ $store.state.origin }} {{ $store.state.origin }}
</div> </div>
<action-button <action-button
v-if="stageType" v-if="illuminationType"
thing="stage" thing="illumination"
action="flash_led" action="flash"
submit-label="Flash Illumination" submit-label="Flash Illumination"
:can-terminate="false" :can-terminate="false"
:submit-data="{ dt: 0.25 }" :submit-data="{ dt: 0.25 }"
@ -46,6 +46,13 @@
{{ stageType }} {{ stageType }}
</div> </div>
</div> </div>
<div v-if="illuminationType">
<b>Illumination:</b>
<br />
<div>
{{ illuminationType }}
</div>
</div>
<hr /> <hr />
</div> </div>
@ -77,6 +84,11 @@ export default {
stageType() { stageType() {
return this.thingAvailable("stage") ? this.thingDescription("stage").title : undefined; return this.thingAvailable("stage") ? this.thingDescription("stage").title : undefined;
}, },
illuminationType() {
return this.thingAvailable("illumination")
? this.thingDescription("illumination").title
: undefined;
},
}, },
async mounted() { async mounted() {