From 93d7d75b0dc07ac4548912acd32edd41187c0d28 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 6 May 2026 10:05:34 +0100 Subject: [PATCH 1/6] Create basic gallery class that finds Things that want to show data --- ofm_config_full.json | 1 + ofm_config_simulation.json | 1 + .../things/__init__.py | 7 +++ .../things/camera/__init__.py | 3 +- .../things/gallery.py | 31 ++++++++++++ .../things/smart_scan.py | 3 ++ tests/conftest.py | 20 ++++++++ tests/integration_tests/test_actions.py | 49 ++++++------------- tests/unit_tests/test_gallery.py | 17 +++++++ 9 files changed, 97 insertions(+), 35 deletions(-) create mode 100644 src/openflexure_microscope_server/things/gallery.py create mode 100644 tests/unit_tests/test_gallery.py diff --git a/ofm_config_full.json b/ofm_config_full.json index 27024a2c..16a211fb 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -8,6 +8,7 @@ }, "stage": "openflexure_microscope_server.things.stage.sangaboard:SangaboardThing", "autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing", + "gallery": "openflexure_microscope_server.things.gallery.GalleryThing", "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", diff --git a/ofm_config_simulation.json b/ofm_config_simulation.json index b04988d4..b52ce4d7 100644 --- a/ofm_config_simulation.json +++ b/ofm_config_simulation.json @@ -3,6 +3,7 @@ "camera": "openflexure_microscope_server.things.camera.simulation:SimulatedCamera", "stage": "openflexure_microscope_server.things.stage.dummy:DummyStage", "autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing", + "gallery": "openflexure_microscope_server.things.gallery.GalleryThing", "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", diff --git a/src/openflexure_microscope_server/things/__init__.py b/src/openflexure_microscope_server/things/__init__.py index 75ba3d24..896c2e70 100644 --- a/src/openflexure_microscope_server/things/__init__.py +++ b/src/openflexure_microscope_server/things/__init__.py @@ -15,6 +15,13 @@ class OFMThing(lt.Thing): _data_dir: Optional[str] = None + _show_in_gallery: bool = False + + @property + def show_in_gallery(self) -> bool: + """Whether to show in the Gallery.""" + return self._show_in_gallery + def __enter__(self) -> Self: """Set the data directory when the Thing is entered.""" # Note that the `application_config` was already validated when the diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 6b9bc323..96840889 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -25,6 +25,7 @@ from pydantic import BaseModel, Field import labthings_fastapi as lt from labthings_fastapi.types.numpy import NDArray +from openflexure_microscope_server.things import OFMThing from openflexure_microscope_server.things.background_detect import ( BackgroundDetectAlgorithm, ) @@ -164,7 +165,7 @@ class CameraMemoryBuffer: del self._storage[key] -class BaseCamera(lt.Thing): +class BaseCamera(OFMThing): """The base class for all cameras. All cameras must directly inherit from this class. The connection to the camera hardware should be added to the ``__enter__`` method not diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py new file mode 100644 index 00000000..e8767928 --- /dev/null +++ b/src/openflexure_microscope_server/things/gallery.py @@ -0,0 +1,31 @@ +"""Server-side gallery functionality. + +The types of data that are captured and how they display in the gallery are defined by +the Things that capture the Data. This thing just provides a unified way for the gallery +front end to access the data. +""" + +from typing import Mapping, Optional + +import labthings_fastapi as lt + +from openflexure_microscope_server.things import OFMThing + + +class GalleryThing(lt.Thing): + """A Thing for communicating with the front end gallery.""" + + all_ofm_things: Mapping[str, OFMThing] = lt.thing_slot() + + _gallery_providing_things: Optional[Mapping[str, OFMThing]] = None + + @property + def gallery_providing_things(self) -> Mapping[str, OFMThing]: + """All Things that provide data to the gallery.""" + if self._gallery_providing_things is None: + self._gallery_providing_things = { + name: thing + for name, thing in self.all_ofm_things.items() + if thing.show_in_gallery + } + return self._gallery_providing_things diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index cb7488fd..1e2ae5d5 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -176,6 +176,9 @@ class SmartScanThing(OFMThing): In this case it doesn't need to do anything. """ + # Register with gallery. + _show_in_gallery = True + # Note that the default detector name is set at init. This is over written if # setting is loaded from disk. @lt.setting diff --git a/tests/conftest.py b/tests/conftest.py index 68d6a483..f7fb28c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ """Fixtures for all test suites.""" +import json import logging +import os import re from collections.abc import Iterable from contextlib import contextmanager @@ -10,6 +12,24 @@ import pytest from labthings_fastapi.testing import create_thing_without_server +from .shared_utils.lt_test_utils import LabThingsTestEnv + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(THIS_DIR) +SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json") + + +@pytest.fixture +def simulation_test_env(): + """Yield a server with the configuration from the simulation json.""" + with open(SIM_CONFIG, "r", encoding="utf-8") as f_obj: + config_dict = json.load(f_obj) + with LabThingsTestEnv( + things=config_dict["things"], + application_config=config_dict["application_config"], + ) as env: + yield env + @pytest.fixture def check_side_effect(caplog): diff --git a/tests/integration_tests/test_actions.py b/tests/integration_tests/test_actions.py index 4f2afda0..5872c518 100644 --- a/tests/integration_tests/test_actions.py +++ b/tests/integration_tests/test_actions.py @@ -12,7 +12,6 @@ it has been moved as it artificaially inflated coverage. """ import json -import os import numpy as np import piexif @@ -21,29 +20,11 @@ from PIL import Image import labthings_fastapi as lt -from ..shared_utils.lt_test_utils import LabThingsTestEnv -THIS_DIR = os.path.dirname(os.path.abspath(__file__)) -REPO_ROOT = os.path.dirname(os.path.dirname(THIS_DIR)) -SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json") - - -@pytest.fixture -def test_env(): - """Yield a server with a very basic configuration.""" - with open(SIM_CONFIG, "r", encoding="utf-8") as f_obj: - config_dict = json.load(f_obj) - with LabThingsTestEnv( - things=config_dict["things"], - application_config=config_dict["application_config"], - ) as env: - yield env - - -def test_autofocus(test_env): +def test_autofocus(simulation_test_env): """Test Fast Autofocus can run doesn't raise an exception.""" - stage = test_env.get_thing_client("stage") - autofocus = test_env.get_thing_client("autofocus") + stage = simulation_test_env.get_thing_client("stage") + autofocus = simulation_test_env.get_thing_client("autofocus") assert stage.position["z"] == 0 # Autofocus 5 times and check each ends within 500 steps for i in range(5): @@ -51,17 +32,17 @@ def test_autofocus(test_env): assert abs(stage.position["z"]) < 500, f"Autofocus failed on iteration {i}" -def test_grab_jpeg(test_env): +def test_grab_jpeg(simulation_test_env): """Check that grab_jpeg returns a blob that can be opened.""" - camera = test_env.get_thing_client("camera") + camera = simulation_test_env.get_thing_client("camera") blob = camera.grab_jpeg() image = Image.open(blob.open()) assert image.size == (820, 616) -def test_capture_jpeg_metadata(test_env): +def test_capture_jpeg_metadata(simulation_test_env): """Check that the position is encoded into the image metadata.""" - camera = test_env.get_thing_client("camera") + camera = simulation_test_env.get_thing_client("camera") blob = camera.capture_jpeg() image = Image.open(blob.open()) exif_dict = piexif.load(image.info["exif"]) @@ -72,9 +53,9 @@ def test_capture_jpeg_metadata(test_env): assert image.size == (820, 616) -def test_stage(test_env): +def test_stage(simulation_test_env): """Test moving the stage forwards and backwards.""" - stage = test_env.get_thing_client("stage") + stage = simulation_test_env.get_thing_client("stage") start = stage.position move = {"x": 1, "y": 2, "z": 3} move_back = {"x": -1, "y": -2, "z": -3} @@ -95,20 +76,20 @@ def test_stage(test_env): assert start["z"] == pos["z"] -def test_capture_array(test_env): +def test_capture_array(simulation_test_env): """Capture array from simulation and check the size is as expected.""" - camera = test_env.get_thing_client("camera") + camera = simulation_test_env.get_thing_client("camera") array = np.asarray(camera.capture_array()) assert array.shape == (616, 820, 3) -def test_camera_stage_mapping_calibration(test_env): +def test_camera_stage_mapping_calibration(simulation_test_env): """Check that camera stage mapping runs and returns the expected result.""" - camera = test_env.get_thing_client("camera") + camera = simulation_test_env.get_thing_client("camera") # Remove camera settling time for speed. camera.settling_time = 0 - csm = test_env.get_thing_client("camera_stage_mapping") - stage = test_env.get_thing_client("stage") + csm = simulation_test_env.get_thing_client("camera_stage_mapping") + stage = simulation_test_env.get_thing_client("stage") # Check it starts uncalibrated assert csm.calibration_required diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py new file mode 100644 index 00000000..176be45f --- /dev/null +++ b/tests/unit_tests/test_gallery.py @@ -0,0 +1,17 @@ +"""Tests that captures have the expected metadata.""" + + +def test_gallery_thing_finds_all_providers(simulation_test_env): + """Check the gallery identifies the correct Things that will provide data.""" + gallery = simulation_test_env.get_thing_by_name("gallery") + snake_workflow = simulation_test_env.get_thing_by_name("snake_workflow") + smart_scan = simulation_test_env.get_thing_by_name("smart_scan") + camera = simulation_test_env.get_thing_by_name("camera") + + assert snake_workflow not in gallery.all_ofm_things.values() + assert camera in gallery.all_ofm_things.values() + assert smart_scan in gallery.all_ofm_things.values() + + assert snake_workflow not in gallery.gallery_providing_things.values() + assert camera not in gallery.gallery_providing_things.values() + assert smart_scan in gallery.gallery_providing_things.values() From 45831513451b06b45fbbc80d41945feacd219cc5 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 6 May 2026 14:42:44 +0100 Subject: [PATCH 2/6] Add runtime checkable protocol for checking gallery compatibility. --- .../things/gallery.py | 61 +++++++++- .../things/smart_scan.py | 32 ++++-- tests/unit_tests/test_gallery.py | 105 ++++++++++++++++++ 3 files changed, 181 insertions(+), 17 deletions(-) diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py index e8767928..e83c8f4f 100644 --- a/src/openflexure_microscope_server/things/gallery.py +++ b/src/openflexure_microscope_server/things/gallery.py @@ -5,13 +5,38 @@ the Things that capture the Data. This thing just provides a unified way for the front end to access the data. """ -from typing import Mapping, Optional +from typing import Mapping, Optional, Protocol, Self, runtime_checkable + +from pydantic import BaseModel import labthings_fastapi as lt from openflexure_microscope_server.things import OFMThing +@runtime_checkable +class GalleryCompatibleThing(Protocol): + """Protocol for checking Things using the gallery are complete. + + Note that runtime checkable protocols only check that the methods exist, not the + full signatures. This means that anything attempting to define the methods will + be picked up, but may throw an error when used. + """ + + # Ensure it is a Thing: + _thing_server_interface: lt.ThingServerInterface + + # Ensure it is an OFMThing: + show_in_gallery: bool + + gallery_data_name: str + + gallery_data_schema: type[BaseModel] + + # Ignore D102: No docstrings for the protocol. + def get_gallery_data(self) -> list[BaseModel]: ... # noqa: D102 + + class GalleryThing(lt.Thing): """A Thing for communicating with the front end gallery.""" @@ -23,9 +48,33 @@ class GalleryThing(lt.Thing): def gallery_providing_things(self) -> Mapping[str, OFMThing]: """All Things that provide data to the gallery.""" if self._gallery_providing_things is None: - self._gallery_providing_things = { - name: thing - for name, thing in self.all_ofm_things.items() - if thing.show_in_gallery - } + raise RuntimeError( + "Cannot access gallery_providing_things before server has started." + ) return self._gallery_providing_things + + def __enter__(self) -> Self: + """Check for all gallery providing things on server startup.""" + self._set_gallery_providers() + + def _set_gallery_providers(self) -> None: + """Set the mapping of gallery providing things. + + This is called on ``__enter__`` when the server starts. + """ + gallery_providers = { + name: thing + for name, thing in self.all_ofm_things.items() + if thing.show_in_gallery + } + # cache initial list of keys as it may change in the loop + keys = list(gallery_providers.keys()) + for key in keys: + if not isinstance(gallery_providers[key], GalleryCompatibleThing): + self.logger.error( + f"Data from {key} cannot be shown in gallery as it does not " + "provide all necessary properties/methods for the gallery." + ) + gallery_providers.pop(key) + + self._gallery_providing_things = gallery_providers diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 1e2ae5d5..3a3fdc28 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -179,6 +179,26 @@ class SmartScanThing(OFMThing): # Register with gallery. _show_in_gallery = True + @property + def gallery_data_name(self) -> str: + """Name under which data shows up in gallery.""" + return "Scans" + + @property + def gallery_data_schema(self) -> type[scan_directories.ScanInfo]: + """The schema (BaseModel) for passing data to the gallery.""" + return scan_directories.ScanInfo + + def get_gallery_data(self) -> list[scan_directories.ScanInfo]: + """Return all the information from the scan directories. + + It is preferable to use the method rather than calling + _scan_dir_manager.all_scans_info() directly as it will handle stopping the json + in any ongoing scans being read. + """ + ongoing_name = None if self._ongoing_scan is None else self.ongoing_scan.name + return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name) + # Note that the default detector name is set at init. This is over written if # setting is loaded from disk. @lt.setting @@ -547,16 +567,6 @@ class SmartScanThing(OFMThing): stitch_automatically: bool = lt.setting(default=True) """Whether to run a final stitch at the end of a successful scan.""" - def _get_all_scan_info(self) -> list[scan_directories.ScanInfo]: - """Return all the information from the scan directories. - - It is preferable to use the method rather than calling - _scan_dir_manager.all_scans_info() directly as it will handle stopping the json - in any ongoing scans being read. - """ - ongoing_name = None if self._ongoing_scan is None else self.ongoing_scan.name - return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name) - @lt.property def scans(self) -> ScanListInfo: """All the available scans. @@ -568,7 +578,7 @@ class SmartScanThing(OFMThing): break it. """ return ScanListInfo( - scans=self._get_all_scan_info(), + scans=self.get_gallery_data(), ongoing=None if self._ongoing_scan is None else self.ongoing_scan.name, ) diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py index 176be45f..f2b239d6 100644 --- a/tests/unit_tests/test_gallery.py +++ b/tests/unit_tests/test_gallery.py @@ -1,5 +1,81 @@ """Tests that captures have the expected metadata.""" +import logging + +from pydantic import BaseModel + +from labthings_fastapi.testing import create_thing_without_server + +from openflexure_microscope_server.things import OFMThing +from openflexure_microscope_server.things.gallery import ( + GalleryCompatibleThing, + GalleryThing, +) + +from ..shared_utils.lt_test_utils import LabThingsTestEnv + + +class MockGalleryData(BaseModel): + """Mock gallery data.""" + + mock: str + foobar: int + + +class MinimalGalleryClass: + """A class that provides data to the gallery needs but is NOT a thing. + + This should fail the protocol check as it is not an OFMThing + """ + + gallery_data_name: str = "Mock" + + gallery_data_schema: type[BaseModel] = MockGalleryData + + def get_gallery_data(self) -> list[BaseModel]: + """Return a list of Mock Gallery Data.""" + return [MockGalleryData(mock="mock", foobar="foobar")] + + +class MinimalGallerySource(OFMThing, MinimalGalleryClass): + """Minimal example of a Thing that can show in the gallery. + + Mixes MinimalGalleryClass into OFMThing to be recognised by the protocol. + """ + + show_in_gallery = True + + +class BadGallerySource(OFMThing): + """An OFMThing that almost defines enough to show in the gallery. + + Shouldn't math the protocol as ``gallery_data_name`` is missing. + """ + + show_in_gallery = True + + gallery_data_schema: type[BaseModel] = MockGalleryData + + def get_gallery_data(self) -> list[BaseModel]: + """Return a list of Mock Gallery Data.""" + return [MockGalleryData(mock="mock", foobar="foobar")] + + +def test_gallery_compatible_protocol(): + """Check the gallery compatible protocol detects compatible classes only.""" + # Minimal class has the gallery specific methods but is not an OFMThing. + assert not isinstance(MinimalGalleryClass(), GalleryCompatibleThing) + + # BadGallerySource is an OFM Thing but is missing one of the required properties. + assert not isinstance( + create_thing_without_server(BadGallerySource), GalleryCompatibleThing + ) + + # MinimalGallerySource should match! + assert isinstance( + create_thing_without_server(MinimalGallerySource), GalleryCompatibleThing + ) + def test_gallery_thing_finds_all_providers(simulation_test_env): """Check the gallery identifies the correct Things that will provide data.""" @@ -15,3 +91,32 @@ def test_gallery_thing_finds_all_providers(simulation_test_env): assert snake_workflow not in gallery.gallery_providing_things.values() assert camera not in gallery.gallery_providing_things.values() assert smart_scan in gallery.gallery_providing_things.values() + + assert not isinstance(snake_workflow, GalleryCompatibleThing) + assert isinstance(smart_scan, GalleryCompatibleThing) + + +def test_gallery_logs_for_bad_providers(caplog): + """Test that an error is logged if a gallery source doesn't match the protocol.""" + # Create a config with the minimal and the bad thing + things = { + "minimal_thing": MinimalGallerySource, + "bad_thing": BadGallerySource, + "gallery": GalleryThing, + } + with caplog.at_level(logging.INFO): + # Start the server + with LabThingsTestEnv( + things=things, application_config={"data_folder": "./openflexure/data/"} + ) as env: + gallery = env.get_thing_by_name("gallery") + # Minimal thing is listed as a gallery provider + assert "minimal_thing" in gallery.gallery_providing_things + # Bad thing isn't as the protocol doesn't match + assert "bad_thing" not in gallery.gallery_providing_things + + # There is an error logged about bad_thing + assert len(caplog.records) == 1 + record = caplog.records[0] + assert "Data from bad_thing cannot be shown in gallery" in record.message + assert record.levelname == "ERROR" From a16d47ed61366482e48f78c14a4d04f88d7054e6 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 6 May 2026 16:01:09 +0100 Subject: [PATCH 3/6] ScanList gets scans from GalleryThing, no ongoing scans shown. --- .../scan_directories.py | 21 +++++++++--- .../things/gallery.py | 27 ++++++++++++--- .../things/smart_scan.py | 33 +++---------------- .../scanListComponents/scanCard.vue | 21 +++--------- .../tabContentComponents/scanListContent.vue | 13 +++----- 5 files changed, 52 insertions(+), 63 deletions(-) diff --git a/src/openflexure_microscope_server/scan_directories.py b/src/openflexure_microscope_server/scan_directories.py index 4f2e5215..da852438 100644 --- a/src/openflexure_microscope_server/scan_directories.py +++ b/src/openflexure_microscope_server/scan_directories.py @@ -335,14 +335,25 @@ class ScanDirectoryManager: return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()] @requires_lock - def all_scans_info(self, ongoing: Optional[str] = None) -> list[ScanInfo]: - """Return a lists of ScanInfo objects for each scan.""" + def all_scans_info( + self, *, ongoing: Optional[str] = None, include_ongoing: bool = True + ) -> list[ScanInfo]: + """Return a lists of ScanInfo objects for each scan. + + :param ongoing: The name of the ongoing scan (or None if no scan is ongoing). + :param include_ongoing: True (dfault) to include the scan info of the ongoing + scan in the return. False to exclude it. + + :return: A list of ScanInfo objects for each scan. + """ all_info: list[ScanInfo] = [] for scan_name in self.all_scans: - # If the scan is ongoing send flag to skip reading the json data - skip_json = scan_name == ongoing + scan_is_ongoing = scan_name == ongoing + if scan_is_ongoing and not include_ongoing: + continue scan_dir = ScanDirectory(scan_name, self.base_dir) - info = scan_dir.scan_info(skip_json=skip_json) + # If the scan is ongoing send flag to skip reading the json data + info = scan_dir.scan_info(skip_json=scan_is_ongoing) all_info.append(info) return all_info diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py index e83c8f4f..cfb73ae1 100644 --- a/src/openflexure_microscope_server/things/gallery.py +++ b/src/openflexure_microscope_server/things/gallery.py @@ -5,7 +5,7 @@ the Things that capture the Data. This thing just provides a unified way for the front end to access the data. """ -from typing import Mapping, Optional, Protocol, Self, runtime_checkable +from typing import Any, Mapping, Optional, Protocol, Self, cast, runtime_checkable from pydantic import BaseModel @@ -42,10 +42,10 @@ class GalleryThing(lt.Thing): all_ofm_things: Mapping[str, OFMThing] = lt.thing_slot() - _gallery_providing_things: Optional[Mapping[str, OFMThing]] = None + _gallery_providing_things: Optional[Mapping[str, GalleryCompatibleThing]] = None @property - def gallery_providing_things(self) -> Mapping[str, OFMThing]: + def gallery_providing_things(self) -> Mapping[str, GalleryCompatibleThing]: """All Things that provide data to the gallery.""" if self._gallery_providing_things is None: raise RuntimeError( @@ -56,6 +56,7 @@ class GalleryThing(lt.Thing): def __enter__(self) -> Self: """Check for all gallery providing things on server startup.""" self._set_gallery_providers() + return self def _set_gallery_providers(self) -> None: """Set the mapping of gallery providing things. @@ -77,4 +78,22 @@ class GalleryThing(lt.Thing): ) gallery_providers.pop(key) - self._gallery_providing_things = gallery_providers + # Cast the type of each thing to "GalleryCompatibleThing" as other Things have + # been popped. + self._gallery_providing_things = cast( + Mapping[str, GalleryCompatibleThing], gallery_providers + ) + + @lt.property + def list_data(self) -> list[dict[str, Any]]: + """List the data from all registered things. + + Currently only works with `smart_scan` as the UI cards are not customisable. + + This will change to an action (or another type of endpoint) at a + later date to enable filtering, and returning only a specific page. + """ + data_list = [] + for thing in self.gallery_providing_things.values(): + data_list += [model.model_dump() for model in thing.get_gallery_data()] + return data_list diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 3a3fdc28..5b4c8f3a 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -71,16 +71,6 @@ class ActiveScanData(scan_directories.BaseScanData): self.scan_result = result -class ScanListInfo(BaseModel): - """The information to be sent to the Scan List tab.""" - - scans: list[scan_directories.ScanInfo] - """The list of scans as ScanInfo objects""" - - ongoing: Optional[str] - """The name of the ongoing scan or None""" - - class JPEGBlob(lt.blob.Blob): """A class representing a JPEG image as a LabThings FastAPI Blob.""" @@ -197,7 +187,9 @@ class SmartScanThing(OFMThing): in any ongoing scans being read. """ ongoing_name = None if self._ongoing_scan is None else self.ongoing_scan.name - return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name) + return self._scan_dir_manager.all_scans_info( + ongoing=ongoing_name, include_ongoing=False + ) # Note that the default detector name is set at init. This is over written if # setting is loaded from disk. @@ -567,21 +559,6 @@ class SmartScanThing(OFMThing): stitch_automatically: bool = lt.setting(default=True) """Whether to run a final stitch at the end of a successful scan.""" - @lt.property - def scans(self) -> ScanListInfo: - """All the available scans. - - Each scan has a name (which can be used to access it), along with - its modified and created times (according to the filesystem) and - the number of items in the ``images`` folder. Note that image count - uses a regular expression, and changes to the naming scheme will - break it. - """ - return ScanListInfo( - scans=self.get_gallery_data(), - ongoing=None if self._ongoing_scan is None else self.ongoing_scan.name, - ) - @lt.endpoint( "get", "get_stitch/{scan_name}", @@ -646,7 +623,7 @@ class SmartScanThing(OFMThing): def purge_empty_scans(self) -> None: """Delete all scan folders containing no images at the top level.""" # JSON is ignored as it's created before any images are captured - for scan_info in self._get_all_scan_info(): + for scan_info in self.get_gallery_data(): if scan_info.number_of_images == 0: self._delete_scan(scan_info.name) @@ -761,6 +738,6 @@ class SmartScanThing(OFMThing): """ if self._scan_lock.locked(): raise RuntimeError("Can't stitch previous scans while a scan is ongoing") - for scan in self._get_all_scan_info(): + for scan in self.get_gallery_data(): if scan.dzi is None: self.stitch_scan(scan_name=scan.name) diff --git a/webapp/src/components/tabContentComponents/scanListComponents/scanCard.vue b/webapp/src/components/tabContentComponents/scanListComponents/scanCard.vue index ec56b5e3..4f1999ed 100644 --- a/webapp/src/components/tabContentComponents/scanListComponents/scanCard.vue +++ b/webapp/src/components/tabContentComponents/scanListComponents/scanCard.vue @@ -13,8 +13,7 @@

{{ scanData.name }}

-

Scan in progress

-
+
  • {{ scanData.number_of_images }} images
  • Created: {{ formatDate(scanData.created) }}
  • -
  • Duration: {{ formatDuration(scanData.duration) }}
  • -
  • Duration: Ongoing
  • +
  • Duration: {{ formatDuration(scanData.duration) }}
  • -
      +
      • Not enough images to stitch
      • @@ -102,10 +100,6 @@ export default { type: String, required: true, }, - ongoing: { - type: Boolean, - required: true, - }, }, emits: ["viewer-requested", "update-requested"], @@ -148,9 +142,7 @@ export default { }, requestViewer() { // Notify parent that thumbnail was clicked - if (!this.ongoing) { - this.$emit("viewer-requested", this.scanData); - } + this.$emit("viewer-requested", this.scanData); }, async deleteScan() { try { @@ -204,9 +196,4 @@ ul { .scan-card-title { text-align: center; } - -.ongoing-msg { - text-align: center; - padding: 2rem 0; -} diff --git a/webapp/src/components/tabContentComponents/scanListContent.vue b/webapp/src/components/tabContentComponents/scanListContent.vue index 2a451617..b8ad5de2 100644 --- a/webapp/src/components/tabContentComponents/scanListContent.vue +++ b/webapp/src/components/tabContentComponents/scanListContent.vue @@ -55,7 +55,6 @@ @@ -97,7 +96,6 @@ export default { data: function () { return { scans: [], - ongoing: null, selectedScan: null, osdViewer: null, currentPage: 1, @@ -108,7 +106,9 @@ export default { computed: { ...mapState(useSettingsStore, ["baseUri", "ready"]), scansUri() { - return this.thingPropertyUrl("smart_scan", "scans"); + // The scans URI is currently used for creating endpoint URIs. + // The actual property does not exist. So allowUndefined=true + return this.thingPropertyUrl("smart_scan", "scans", true); }, scansEmpty() { return this.scans.length == 0; @@ -179,9 +179,7 @@ export default { }, async updateScans() { try { - let scans_information = await this.readThingProperty("smart_scan", "scans"); - let scans = scans_information.scans; - this.ongoing = scans_information.ongoing; + let scans = await this.readThingProperty("gallery", "list_data"); if (!scans | (scans.length == 0)) { this.scans = scans; } @@ -198,9 +196,6 @@ export default { this.scans = []; } }, - isOngoing(name) { - return name === this.ongoing; - }, async deleteAllScans() { try { await this.modalConfirm( From 85299e5a881d264f2038c0d08043b789a96b93f4 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 6 May 2026 16:59:03 +0100 Subject: [PATCH 4/6] update picamera coverage zip --- picamera_coverage.zip | Bin 54038 -> 54038 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index f6005301f27a01c84f0b01e53e3c5e0d89705739..7c9090de9076b9c1bd178612c060e28d019a9d14 100644 GIT binary patch delta 1149 zcmbQXjCtBJW}yIYW)=|!5a4ZJ7V}5PI)0*115;Ju#woM)8BI4E7_bO9$Z=d{;6KMd zl|P7IjqfSnCcZ*GIo{K}Gk6nu4S2rs?B%KCvE%;7eT=)GJCK{3>m1i)u1GFP&g-1B zIb%5`IIeDX6qv}t<|xO)(8xZS(a&IVrWYTpEDJ*;H<-7^i=Wj-hMA!r#N*B{NX<(w z(<`X7lAdhuZN=muHTkcX1FM}R3q#{%$7qAeAH29(EhLy3$|oQ6(&S4lEy+*IPcALi zE2y*-pS;1xibaf>p%x@JS=^VO%|(=jp^P8xfE_2fg$J;&W3Ii%W}A z^$IFYg_#)&Cm;0E;;k$$NzK(Os5BE|W+(=k!JnR(lapFhsaH^GCkSzY_T-h`0!)?y zlQ;ONPM+tLn5 zNC`_$etP=kUSC^QLoQ~9Opp*?aY<%Laz3 zX}OBNt*}DFKA#kV>l+UX6e-f47n2~zv(yP;bLN8

        9SN;PFVuvv40paTae z$DUu?+BDheLYju9nT3ggg@vW1MRJ;jfq9~trLk#Bs)3nhTB3!qMOu=DNs_6NxoPU= z3m57cgDuk%jZ!UA%`6R*EX~uBjf@g4Qw-9K%u-C!EYd8JQw>c_j7-dpjg@KxycwB9 xm{E%eh6@Y~AS?tbH=uaS?m-WgUMWW@_Qe1HYXVthDOfGj^0X>FZ#$ZSxQeX@UdjFk(%7^X~X6y$->ac zKAF+aV6wF*AFBilLnAksSLeykWG6n^z(;TL25*+hwO%}I4q_|}jjWR$y)`GV_hy}J z?ZwAxCCbcD4Hx<6$;o6VGTFe#j@3e#nV}pc!IxNClAo5JTw1JGQ0XEx+1A^I$y9Li zL2pwg8-dCFo;s7)dkZjG@=q@CQJq}i$thKq&!2+uBVFP?g?9&SFa zGdzwwT$>95rg2QXU^V$&j0LN#GfN}q(#Sq} zVx-|@YfoNQNf(wz?#UA)&AH-BOEPmn9+#M`AEOToNLF!2mPXde6C<@?;m9Ip&0Gx^ z_~*&VC^}g`#-2sQlDQltEQlP4!mcchyptzJ8cZ(m7GxBfd?4D4QE+l!q%J5V83iWi z$Ebk~wiLn-w6Jre+xhDI^*=@Fp z`=coGo`L@-|2zIY{7HOI_*U?h@j3Ip-|Q%Gm3OjS-&v+k=E-mSEZ8fJ{z%R^Xf#=; zUs;vAhMmEI@&E7d58iypl&<4tVEE9_%Mjqq+>pr3@Xc7^PayLFhRG%U8vfHFr_DUe z3QN@Y#htT{o@fdiDct~;ga zO?JAFreT(3nUrj7WNd6?W|(G_WR{j{lwy&XWN2 Date: Thu, 7 May 2026 08:04:41 +0000 Subject: [PATCH 5/6] Apply suggestions from code review of branch start-gallery-thing Co-authored-by: Joe Knapper --- tests/unit_tests/test_gallery.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py index f2b239d6..043b03a3 100644 --- a/tests/unit_tests/test_gallery.py +++ b/tests/unit_tests/test_gallery.py @@ -23,7 +23,7 @@ class MockGalleryData(BaseModel): class MinimalGalleryClass: - """A class that provides data to the gallery needs but is NOT a thing. + """A class that provides data to the gallery but is NOT a thing. This should fail the protocol check as it is not an OFMThing """ @@ -38,7 +38,7 @@ class MinimalGalleryClass: class MinimalGallerySource(OFMThing, MinimalGalleryClass): - """Minimal example of a Thing that can show in the gallery. + """Minimal example of a Thing that can show data in the gallery. Mixes MinimalGalleryClass into OFMThing to be recognised by the protocol. """ @@ -49,7 +49,7 @@ class MinimalGallerySource(OFMThing, MinimalGalleryClass): class BadGallerySource(OFMThing): """An OFMThing that almost defines enough to show in the gallery. - Shouldn't math the protocol as ``gallery_data_name`` is missing. + Shouldn't match the protocol as ``gallery_data_name`` is missing. """ show_in_gallery = True @@ -98,7 +98,7 @@ def test_gallery_thing_finds_all_providers(simulation_test_env): def test_gallery_logs_for_bad_providers(caplog): """Test that an error is logged if a gallery source doesn't match the protocol.""" - # Create a config with the minimal and the bad thing + # Create a config with the minimal Thing and the bad Thing things = { "minimal_thing": MinimalGallerySource, "bad_thing": BadGallerySource, From c7bb3e3c11f7e7946826d801f815de80742797c5 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 7 May 2026 09:47:55 +0100 Subject: [PATCH 6/6] Improve some attribute/method names --- src/openflexure_microscope_server/things/__init__.py | 6 +++--- src/openflexure_microscope_server/things/gallery.py | 8 ++++---- .../things/smart_scan.py | 12 +++++++----- tests/unit_tests/test_gallery.py | 8 ++++---- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/openflexure_microscope_server/things/__init__.py b/src/openflexure_microscope_server/things/__init__.py index 896c2e70..b35f1fdf 100644 --- a/src/openflexure_microscope_server/things/__init__.py +++ b/src/openflexure_microscope_server/things/__init__.py @@ -15,12 +15,12 @@ class OFMThing(lt.Thing): _data_dir: Optional[str] = None - _show_in_gallery: bool = False + _show_data_in_gallery: bool = False @property - def show_in_gallery(self) -> bool: + def show_data_in_gallery(self) -> bool: """Whether to show in the Gallery.""" - return self._show_in_gallery + return self._show_data_in_gallery def __enter__(self) -> Self: """Set the data directory when the Thing is entered.""" diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py index cfb73ae1..88a6eaed 100644 --- a/src/openflexure_microscope_server/things/gallery.py +++ b/src/openflexure_microscope_server/things/gallery.py @@ -27,14 +27,14 @@ class GalleryCompatibleThing(Protocol): _thing_server_interface: lt.ThingServerInterface # Ensure it is an OFMThing: - show_in_gallery: bool + show_data_in_gallery: bool gallery_data_name: str gallery_data_schema: type[BaseModel] # Ignore D102: No docstrings for the protocol. - def get_gallery_data(self) -> list[BaseModel]: ... # noqa: D102 + def get_data_for_gallery(self) -> list[BaseModel]: ... # noqa: D102 class GalleryThing(lt.Thing): @@ -66,7 +66,7 @@ class GalleryThing(lt.Thing): gallery_providers = { name: thing for name, thing in self.all_ofm_things.items() - if thing.show_in_gallery + if thing.show_data_in_gallery } # cache initial list of keys as it may change in the loop keys = list(gallery_providers.keys()) @@ -95,5 +95,5 @@ class GalleryThing(lt.Thing): """ data_list = [] for thing in self.gallery_providing_things.values(): - data_list += [model.model_dump() for model in thing.get_gallery_data()] + data_list += [model.model_dump() for model in thing.get_data_for_gallery()] return data_list diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 5b4c8f3a..5b2e54af 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -167,7 +167,7 @@ class SmartScanThing(OFMThing): """ # Register with gallery. - _show_in_gallery = True + _show_data_in_gallery = True @property def gallery_data_name(self) -> str: @@ -179,7 +179,7 @@ class SmartScanThing(OFMThing): """The schema (BaseModel) for passing data to the gallery.""" return scan_directories.ScanInfo - def get_gallery_data(self) -> list[scan_directories.ScanInfo]: + def get_data_for_gallery(self) -> list[scan_directories.ScanInfo]: """Return all the information from the scan directories. It is preferable to use the method rather than calling @@ -622,8 +622,8 @@ class SmartScanThing(OFMThing): @lt.action def purge_empty_scans(self) -> None: """Delete all scan folders containing no images at the top level.""" - # JSON is ignored as it's created before any images are captured - for scan_info in self.get_gallery_data(): + # Use the scan list (the data read by the gallery) to check for empty scans. + for scan_info in self.get_data_for_gallery(): if scan_info.number_of_images == 0: self._delete_scan(scan_info.name) @@ -738,6 +738,8 @@ class SmartScanThing(OFMThing): """ if self._scan_lock.locked(): raise RuntimeError("Can't stitch previous scans while a scan is ongoing") - for scan in self.get_gallery_data(): + # Use the scan list (the data read by the gallery) to find any scans that + # need stitching. + for scan in self.get_data_for_gallery(): if scan.dzi is None: self.stitch_scan(scan_name=scan.name) diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py index 043b03a3..10bc246d 100644 --- a/tests/unit_tests/test_gallery.py +++ b/tests/unit_tests/test_gallery.py @@ -32,7 +32,7 @@ class MinimalGalleryClass: gallery_data_schema: type[BaseModel] = MockGalleryData - def get_gallery_data(self) -> list[BaseModel]: + def get_data_for_gallery(self) -> list[BaseModel]: """Return a list of Mock Gallery Data.""" return [MockGalleryData(mock="mock", foobar="foobar")] @@ -43,7 +43,7 @@ class MinimalGallerySource(OFMThing, MinimalGalleryClass): Mixes MinimalGalleryClass into OFMThing to be recognised by the protocol. """ - show_in_gallery = True + show_data_in_gallery = True class BadGallerySource(OFMThing): @@ -52,11 +52,11 @@ class BadGallerySource(OFMThing): Shouldn't match the protocol as ``gallery_data_name`` is missing. """ - show_in_gallery = True + show_data_in_gallery = True gallery_data_schema: type[BaseModel] = MockGalleryData - def get_gallery_data(self) -> list[BaseModel]: + def get_data_for_gallery(self) -> list[BaseModel]: """Return a list of Mock Gallery Data.""" return [MockGalleryData(mock="mock", foobar="foobar")]