diff --git a/ofm_config_full.json b/ofm_config_full.json index 25ddc96a..c43eb8ad 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/ofm_config_simulation.json b/ofm_config_simulation.json index 57070a66..962dffae 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 1f956661..a374f764 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -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"])) diff --git a/src/openflexure_microscope_server/things/illumination.py b/src/openflexure_microscope_server/things/illumination.py new file mode 100644 index 00000000..2e444138 --- /dev/null +++ b/src/openflexure_microscope_server/things/illumination.py @@ -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) diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index db64ccc3..7429aea3 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -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") diff --git a/tests/unit_tests/test_illumination.py b/tests/unit_tests/test_illumination.py new file mode 100644 index 00000000..a7e02ee1 --- /dev/null +++ b/tests/unit_tests/test_illumination.py @@ -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 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()) diff --git a/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue b/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue index 6b4e8c6f..209df9ef 100644 --- a/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue +++ b/webapp/src/components/tabContentComponents/aboutComponents/statusPane.vue @@ -13,9 +13,9 @@ {{ $store.state.origin }} +
+ Illumination: +
+
+ {{ illuminationType }} +
+

@@ -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() {