Merge branch 'flash-led-sim' into 'v3'
Illumination Thing See merge request openflexure/openflexure-microscope-server!468
This commit is contained in:
commit
704fc5569f
8 changed files with 219 additions and 24 deletions
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"]))
|
||||||
|
|
||||||
|
|
|
||||||
59
src/openflexure_microscope_server/things/illumination.py
Normal file
59
src/openflexure_microscope_server/things/illumination.py
Normal 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)
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
127
tests/unit_tests/test_illumination.py
Normal file
127
tests/unit_tests/test_illumination.py
Normal 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
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue