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/picamera_coverage.zip b/picamera_coverage.zip index f6005301..7c9090de 100644 Binary files a/picamera_coverage.zip and b/picamera_coverage.zip differ 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/__init__.py b/src/openflexure_microscope_server/things/__init__.py index 75ba3d24..b35f1fdf 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_data_in_gallery: bool = False + + @property + def show_data_in_gallery(self) -> bool: + """Whether to show in the Gallery.""" + return self._show_data_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..88a6eaed --- /dev/null +++ b/src/openflexure_microscope_server/things/gallery.py @@ -0,0 +1,99 @@ +"""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 Any, Mapping, Optional, Protocol, Self, cast, 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_data_in_gallery: bool + + gallery_data_name: str + + gallery_data_schema: type[BaseModel] + + # Ignore D102: No docstrings for the protocol. + def get_data_for_gallery(self) -> list[BaseModel]: ... # noqa: D102 + + +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, GalleryCompatibleThing]] = None + + @property + 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( + "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() + return self + + 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_data_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) + + # 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_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 cb7488fd..5b2e54af 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.""" @@ -176,6 +166,31 @@ class SmartScanThing(OFMThing): In this case it doesn't need to do anything. """ + # Register with gallery. + _show_data_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_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 + _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, include_ongoing=False + ) + # Note that the default detector name is set at init. This is over written if # setting is loaded from disk. @lt.setting @@ -544,31 +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.""" - 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. - - 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_all_scan_info(), - ongoing=None if self._ongoing_scan is None else self.ongoing_scan.name, - ) - @lt.endpoint( "get", "get_stitch/{scan_name}", @@ -632,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_all_scan_info(): + # 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) @@ -748,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_all_scan_info(): + # 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/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..10bc246d --- /dev/null +++ b/tests/unit_tests/test_gallery.py @@ -0,0 +1,122 @@ +"""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 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_data_for_gallery(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 data in the gallery. + + Mixes MinimalGalleryClass into OFMThing to be recognised by the protocol. + """ + + show_data_in_gallery = True + + +class BadGallerySource(OFMThing): + """An OFMThing that almost defines enough to show in the gallery. + + Shouldn't match the protocol as ``gallery_data_name`` is missing. + """ + + show_data_in_gallery = True + + gallery_data_schema: type[BaseModel] = MockGalleryData + + def get_data_for_gallery(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.""" + 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() + + 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 Thing 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" 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 @@