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",
"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": {

View file

@ -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",

View file

@ -140,6 +140,8 @@ class SimulatedCamera(BaseCamera):
self._capture_thread: Optional[Thread] = None
self._capture_enabled = False
self.generate_sprites()
# Whether the LED is on
self.led_on = True
repeating: bool = lt.property(default=False)
@ -339,8 +341,16 @@ class SimulatedCamera(BaseCamera):
pl_img = Image.fromarray(np_img.astype("uint8"))
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:
"""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
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
import threading
import time
from contextlib import contextmanager
from copy import copy
from types import TracebackType
@ -174,11 +173,9 @@ class SangaboardThing(BaseStage):
sb.zero_position()
self.update_position()
@lt.action
def flash_led(
def set_led(
self,
number_of_flashes: int = 10,
dt: float = 0.5,
led_on: bool = True,
led_channel: Literal["cc"] = "cc",
) -> None:
"""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
# 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)
if led_on:
on_brightness = 0.32
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})
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())

View file

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