From 44300eafd6ebc8a4a3e57bf978ebb0e0caff1cc6 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 11 May 2026 18:28:44 +0100 Subject: [PATCH 01/14] Measure framerate in non-blocking way --- .../things/camera/picamera.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index b76d4ed5..d58d5797 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -17,10 +17,12 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf from __future__ import annotations import copy +import csv import json import logging import os import tempfile +import threading import time from contextlib import contextmanager from threading import RLock @@ -548,6 +550,81 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: cam.capture_metadata() + def _framerate_worker( + self, + duration: float, + output_dir: str, + sample_interval: float = 0.1, + ) -> None: + """Worker thread to measure framerate and save capture metadata.""" + try: + os.makedirs(output_dir, exist_ok=True) + timestamp = time.strftime("%Y%m%d_%H%M%S") + log_path = os.path.join( + output_dir, + f"framerate_log_{timestamp}.csv", + ) + + start_time = time.time() + last_sample_time = start_time + last_sample_frames = 0 + frames = 0 + + with open(log_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["timestamp", "frame_count", "instant_fps"]) + + with self._streaming_picamera() as cam: + while time.time() - start_time < duration: + cam.capture_metadata() + frames += 1 + + now = time.time() + + if now - last_sample_time >= sample_interval: + interval = now - last_sample_time + instant_fps = (frames - last_sample_frames) / interval + + writer.writerow([now, frames, instant_fps]) + f.flush() + + LOGGER.info(f"[Framerate test] FPS ~ {instant_fps:.2f}") + + last_sample_time = now + last_sample_frames = frames + + total_time = time.time() - start_time + avg_fps = frames / total_time + + LOGGER.info( + f"[Framerate test complete] " + f"Frames={frames}, Avg FPS={avg_fps:.2f}" + ) + + except Exception: + LOGGER.exception("Framerate worker crashed") + + @lt.action + def record_framerate_async( + self, + duration: float = 5.0, + output_dir: Optional[str] = "framerate", + ) -> str: + """Start a background framerate recording session.""" + if output_dir is None: + output_dir = tempfile.mkdtemp(prefix="framerate_test_") + + thread = threading.Thread( + target=self._framerate_worker, + args=(duration, output_dir), + daemon=True, + ) + thread.start() + + LOGGER.info(f"Started framerate test for {duration}s -> {output_dir}") + + return output_dir + @contextmanager def _switch_to_still_capture_mode(self) -> Iterator[Picamera2]: """Get the picamera lock, pause stream and switch into still capture config. From 261b4272c21f4cca4390b0277838533939ba3a8f Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 11 May 2026 18:36:35 +0100 Subject: [PATCH 02/14] Picamera test for framerate --- .../picamera2/test_acquisition.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/hardware_specific_tests/picamera2/test_acquisition.py b/tests/hardware_specific_tests/picamera2/test_acquisition.py index 1d8ba1a9..6c39e03f 100644 --- a/tests/hardware_specific_tests/picamera2/test_acquisition.py +++ b/tests/hardware_specific_tests/picamera2/test_acquisition.py @@ -1,5 +1,8 @@ """Test data collection from the Raspberry Picamera.""" +import csv +import time + import numpy as np from PIL import Image @@ -29,3 +32,45 @@ def test_jpeg_and_array(picamera_client): # Verify image sizes are the same assert mjpeg_frame.size == jpeg_capture.size assert array_main.shape[1::-1] == jpeg_capture.size + + +def test_record_framerate_async(picamera_client, tmp_path): + """Check that asynchronous framerate recording creates a valid CSV log.""" + output_dir = tmp_path / "framerate" + + returned_dir = picamera_client.record_framerate_async( + duration=1.0, + output_dir=str(output_dir), + ) + + assert returned_dir == str(output_dir) + + # Wait slightly longer than recording duration for thread completion + time.sleep(1.5) + + log_files = list(output_dir.glob("framerate_log_*.csv")) + + # Ensure exactly one log file was created + assert len(log_files) == 1 + + log_file = log_files[0] + + # Ensure file is not empty + assert log_file.stat().st_size > 0 + + with open(log_file, newline="") as f: + rows = list(csv.reader(f)) + + # Header exists + assert rows[0] == ["timestamp", "frame_count", "instant_fps"] + + # At least one FPS sample row exists + assert len(rows) > 1 + + # Validate sample row structure + sample_row = rows[1] + assert len(sample_row) == 3 + + # Ensure FPS is positive + fps = float(sample_row[2]) + assert fps > 0 From e9414cfb1d7db4eece3bb5e959cf8904c7b09178 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 11 May 2026 19:05:53 +0100 Subject: [PATCH 03/14] Update picamera tests --- tests/unit_tests/test_cameras.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit_tests/test_cameras.py b/tests/unit_tests/test_cameras.py index 0fcec5ca..52d16f31 100644 --- a/tests/unit_tests/test_cameras.py +++ b/tests/unit_tests/test_cameras.py @@ -93,6 +93,7 @@ def test_thing_description_equivalence(mock_picam_thing): "set_static_green_equalisation", "set_ce_enable_to_off", "stop_streaming", + "record_framerate_async", "reset_ccm", } picamera_extra_props = { From 800aea21dcda870d022ab9aed6dbf7fb01f8886b Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 12 May 2026 12:03:31 +0100 Subject: [PATCH 04/14] Move to async monitoring --- .../things/camera/picamera.py | 150 +++++++++++++----- 1 file changed, 114 insertions(+), 36 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index d58d5797..f2735758 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -22,7 +22,6 @@ import json import logging import os import tempfile -import threading import time from contextlib import contextmanager from threading import RLock @@ -169,6 +168,7 @@ class StreamingPiCamera2(BaseCamera): self._sensor_info = SUPPORTED_CAMS_SENSOR_INFO[camera_board] self._picamera_lock = RLock() self._picamera = None + self._framerate_monitor_running = False # Load the tuning file for the specified sensor mode. self.default_tuning = tf_utils.load_default_tuning( @@ -550,15 +550,35 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: cam.capture_metadata() - def _framerate_worker( + async def _monitor_framerate( self, duration: float, output_dir: str, sample_interval: float = 0.1, ) -> None: - """Worker thread to measure framerate and save capture metadata.""" + """Monitor the delivered MJPEG frame rate asynchronously. + + This method listens to frames arriving on self.mjpeg_stream and records + timing information without directly interacting with the underlying + ``Picamera2`` instance. + + Writes a CSV log file with timestamp, frame count, and size of the most + recent MJPEG frame in bytes + + Monitoring stops automatically after ``duration`` seconds, or earlier if + ``self._framerate_monitor_running`` is set to ``False``. + + :param duration: Total monitoring duration in seconds. + :param output_dir: Directory in which the CSV log file will be written. + :param sample_interval: Time interval in seconds between FPS samples. + Default = 0.1s. + + :raises Exception: Any unexpected exception is logged before the monitor + exits. + """ try: os.makedirs(output_dir, exist_ok=True) + timestamp = time.strftime("%Y%m%d_%H%M%S") log_path = os.path.join( output_dir, @@ -570,58 +590,116 @@ class StreamingPiCamera2(BaseCamera): last_sample_frames = 0 frames = 0 - with open(log_path, "w", newline="") as f: - writer = csv.writer(f) - writer.writerow(["timestamp", "frame_count", "instant_fps"]) + self._framerate_monitor_running = True - with self._streaming_picamera() as cam: - while time.time() - start_time < duration: - cam.capture_metadata() - frames += 1 + LOGGER.info( + "Starting framerate monitor for %.2fs -> %s", + duration, + log_path, + ) - now = time.time() + with open(log_path, "w", newline="") as csv_file: + writer = csv.writer(csv_file) - if now - last_sample_time >= sample_interval: - interval = now - last_sample_time - instant_fps = (frames - last_sample_frames) / interval + writer.writerow( + [ + "timestamp", + "frame_count", + "frame_size_bytes", + ] + ) - writer.writerow([now, frames, instant_fps]) - f.flush() + async for frame in self.mjpeg_stream.frame_async_generator(): + if not self._framerate_monitor_running: + LOGGER.info("Framerate monitor stopped externally") + break - LOGGER.info(f"[Framerate test] FPS ~ {instant_fps:.2f}") + now = time.time() - last_sample_time = now - last_sample_frames = frames + frames += 1 + frame_size = len(frame) - total_time = time.time() - start_time - avg_fps = frames / total_time + if now - last_sample_time >= sample_interval: + interval = now - last_sample_time - LOGGER.info( - f"[Framerate test complete] " - f"Frames={frames}, Avg FPS={avg_fps:.2f}" - ) + instant_fps = (frames - last_sample_frames) / interval + + writer.writerow( + [ + now, + frames, + frame_size, + ] + ) + + csv_file.flush() + + LOGGER.info( + "[Framerate test] FPS=%.2f, size=%d B", + instant_fps, + frame_size, + ) + + last_sample_time = now + last_sample_frames = frames + + if now - start_time >= duration: + LOGGER.info("Framerate monitor duration reached") + break + + total_time = time.time() - start_time + avg_fps = frames / total_time if total_time > 0 else 0 + + writer.writerow([]) + writer.writerow(["TOTAL_DURATION", total_time]) + writer.writerow(["TOTAL_FRAMES", frames]) + writer.writerow(["AVG_FPS", avg_fps]) + + LOGGER.info( + "[Framerate test complete] Frames=%d, Avg FPS=%.2f", + frames, + avg_fps, + ) except Exception: - LOGGER.exception("Framerate worker crashed") + LOGGER.exception("Framerate monitor crashed") + + finally: + self._framerate_monitor_running = False @lt.action def record_framerate_async( self, duration: float = 5.0, - output_dir: Optional[str] = "framerate", + output_dir: str = "framerate", ) -> str: - """Start a background framerate recording session.""" - if output_dir is None: - output_dir = tempfile.mkdtemp(prefix="framerate_test_") + """Start asynchronous MJPEG framerate monitoring. - thread = threading.Thread( - target=self._framerate_worker, - args=(duration, output_dir), - daemon=True, + This action schedules ``_monitor_framerate`` on the Thing server event loop + and immediately returns. Monitoring occurs in the background and does not + block the calling thread. + + The monitor observes frames delivered through ``self.mjpeg_stream`` rather + than directly querying the camera hardware. This makes it suitable for + measuring effective stream throughput under real operating conditions. + + :param duration: Monitoring duration in seconds. Default = 5.0s. + :param output_dir: Directory where CSV log files will be written. + Default = ``"framerate"``. + + :returns: The output directory containing generated log files. + """ + self._thing_server_interface.start_async_task_soon( + self._monitor_framerate, + duration, + output_dir, ) - thread.start() - LOGGER.info(f"Started framerate test for {duration}s -> {output_dir}") + LOGGER.info( + "Started async framerate monitor for %.2fs -> %s", + duration, + output_dir, + ) return output_dir From 5a8e8f745a76047aebc5adfa5a45aae1201e3ebc Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 12 May 2026 12:29:25 +0100 Subject: [PATCH 05/14] Update tests --- .../picamera2/test_acquisition.py | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/tests/hardware_specific_tests/picamera2/test_acquisition.py b/tests/hardware_specific_tests/picamera2/test_acquisition.py index 6c39e03f..b017cf83 100644 --- a/tests/hardware_specific_tests/picamera2/test_acquisition.py +++ b/tests/hardware_specific_tests/picamera2/test_acquisition.py @@ -35,7 +35,7 @@ def test_jpeg_and_array(picamera_client): def test_record_framerate_async(picamera_client, tmp_path): - """Check that asynchronous framerate recording creates a valid CSV log.""" + """Check that asynchronous framerate monitoring creates a valid CSV log.""" output_dir = tmp_path / "framerate" returned_dir = picamera_client.record_framerate_async( @@ -45,7 +45,7 @@ def test_record_framerate_async(picamera_client, tmp_path): assert returned_dir == str(output_dir) - # Wait slightly longer than recording duration for thread completion + # Wait slightly longer than recording duration for async task completion time.sleep(1.5) log_files = list(output_dir.glob("framerate_log_*.csv")) @@ -62,15 +62,39 @@ def test_record_framerate_async(picamera_client, tmp_path): rows = list(csv.reader(f)) # Header exists - assert rows[0] == ["timestamp", "frame_count", "instant_fps"] + assert rows[0] == [ + "timestamp", + "frame_count", + "frame_size_bytes", + ] - # At least one FPS sample row exists - assert len(rows) > 1 + sample_rows = [ + row + for row in rows[1:] + if row and row[0] not in {"TOTAL_FRAMES", "TOTAL_DURATION", "AVG_FPS"} + ] + + assert len(sample_rows) > 0 # Validate sample row structure - sample_row = rows[1] + sample_row = sample_rows[0] + assert len(sample_row) == 3 - # Ensure FPS is positive - fps = float(sample_row[2]) - assert fps > 0 + timestamp = float(sample_row[0]) + frame_count = int(sample_row[1]) + frame_size = int(sample_row[2]) + + # Basic sanity checks + assert timestamp > 0 + assert frame_count > 0 + assert frame_size > 0 + + assert rows[-3][0] == "TOTAL_DURATION" + assert float(rows[-3][1]) > 0 + + assert rows[-2][0] == "TOTAL_FRAMES" + assert int(rows[-2][1]) > 0 + + assert rows[-1][0] == "AVG_FPS" + assert float(rows[-1][1]) > 0 From ab25705edc7692c6a1321e2a5495ea8fd7aae7a0 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 12 May 2026 14:00:29 +0100 Subject: [PATCH 06/14] Move framerate to basecamera, using data_dir --- .../things/camera/__init__.py | 154 ++++++++++++++++++ .../things/camera/picamera.py | 154 ------------------ .../picamera2/test_acquisition.py | 2 +- tests/unit_tests/test_cameras.py | 1 - 4 files changed, 155 insertions(+), 156 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 5842a2c0..bc9d504d 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -8,6 +8,7 @@ See repository root for licensing information. from __future__ import annotations +import csv import io import json import os @@ -198,6 +199,8 @@ class BaseCamera(OFMThing): def __enter__(self) -> Self: """Open hardware connection when the Thing context manager is opened.""" + super().__enter__() + self._background_detector_name = coerce_thing_selector( thing_mapping=self._all_background_detectors, selected=self._background_detector_name, @@ -264,6 +267,157 @@ class BaseCamera(OFMThing): self.mjpeg_stream._streaming = False self.lores_mjpeg_stream._streaming = False + async def _monitor_framerate( + self, + duration: float, + output_dir: str, + sample_interval: float = 0.1, + ) -> None: + """Monitor the delivered MJPEG frame rate asynchronously. + + This method listens to frames arriving on self.mjpeg_stream and records + timing information without directly interacting with the underlying + ``Picamera2`` instance. + + Writes a CSV log file with timestamp, frame count, and size of the most + recent MJPEG frame in bytes + + Monitoring stops automatically after ``duration`` seconds, or earlier if + ``self._framerate_monitor_running`` is set to ``False``. + + :param duration: Total monitoring duration in seconds. + :param output_dir: Directory in which the CSV log file will be written. + :param sample_interval: Time interval in seconds between FPS samples. + Default = 0.1s. + + :raises Exception: Any unexpected exception is logged before the monitor + exits. + """ + try: + os.makedirs(output_dir, exist_ok=True) + + timestamp = time.strftime("%Y%m%d_%H%M%S") + log_path = os.path.join( + output_dir, + f"framerate_{timestamp}.csv", + ) + + start_time = time.time() + last_sample_time = start_time + last_sample_frames = 0 + frames = 0 + + self._framerate_monitor_running = True + + # Store rows in memory and write once at the end + sample_rows = [] + + self.logger.info( + "Starting framerate monitor for %.2fs -> %s", + duration, + log_path, + ) + + async for frame in self.mjpeg_stream.frame_async_generator(): + if not self._framerate_monitor_running: + self.logger.info("Framerate monitor stopped externally") + break + + now = time.time() + + frames += 1 + frame_size = len(frame) + + if now - last_sample_time >= sample_interval: + interval = now - last_sample_time + + instant_fps = (frames - last_sample_frames) / interval + + sample_rows.append( + [ + now, + frames, + frame_size, + ] + ) + + self.logger.info( + "[Framerate test] FPS=%.2f, size=%d B", + instant_fps, + frame_size, + ) + + last_sample_time = now + last_sample_frames = frames + + if now - start_time >= duration: + self.logger.info("Framerate monitor duration reached") + break + + total_time = time.time() - start_time + avg_fps = frames / total_time if total_time > 0 else 0 + + # Write CSV once at completion + with open(log_path, "w", newline="") as csv_file: + writer = csv.writer(csv_file) + + writer.writerow( + [ + "timestamp", + "frame_count", + "frame_size_bytes", + ] + ) + + writer.writerows(sample_rows) + + writer.writerow([]) + writer.writerow(["TOTAL_DURATION", total_time]) + writer.writerow(["TOTAL_FRAMES", frames]) + writer.writerow(["AVG_FPS", avg_fps]) + + self.logger.info( + "[Framerate test complete] Frames=%d, Avg FPS=%.2f", + frames, + avg_fps, + ) + + except Exception: + self.logger.exception("Framerate monitor crashed") + + finally: + self._framerate_monitor_running = False + + @lt.action + def record_framerate_async( + self, + duration: float = 5.0, + ) -> str: + """Start asynchronous MJPEG framerate monitoring. + + The monitor observes frames delivered through ``self.mjpeg_stream`` rather + than directly querying the camera hardware. This makes it suitable for + measuring effective stream throughput under real operating conditions. + + :param duration: Monitoring duration in seconds. Default = 5.0s. + + :returns: The output directory containing generated log files. + """ + output_dir = os.path.join(self.data_dir, "characterisation", "framerate") + self._thing_server_interface.start_async_task_soon( + self._monitor_framerate, + duration, + output_dir, + ) + + self.logger.info( + "Started async framerate monitor for %.2fs -> %s", + duration, + output_dir, + ) + + return output_dir + @lt.property def stream_active(self) -> bool: """Whether the MJPEG stream is active.""" diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index f2735758..93f536fa 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -17,7 +17,6 @@ https://datasheets.raspberrypi.com/camera/raspberry-pi-camera-guide.pdf from __future__ import annotations import copy -import csv import json import logging import os @@ -550,159 +549,6 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: cam.capture_metadata() - async def _monitor_framerate( - self, - duration: float, - output_dir: str, - sample_interval: float = 0.1, - ) -> None: - """Monitor the delivered MJPEG frame rate asynchronously. - - This method listens to frames arriving on self.mjpeg_stream and records - timing information without directly interacting with the underlying - ``Picamera2`` instance. - - Writes a CSV log file with timestamp, frame count, and size of the most - recent MJPEG frame in bytes - - Monitoring stops automatically after ``duration`` seconds, or earlier if - ``self._framerate_monitor_running`` is set to ``False``. - - :param duration: Total monitoring duration in seconds. - :param output_dir: Directory in which the CSV log file will be written. - :param sample_interval: Time interval in seconds between FPS samples. - Default = 0.1s. - - :raises Exception: Any unexpected exception is logged before the monitor - exits. - """ - try: - os.makedirs(output_dir, exist_ok=True) - - timestamp = time.strftime("%Y%m%d_%H%M%S") - log_path = os.path.join( - output_dir, - f"framerate_log_{timestamp}.csv", - ) - - start_time = time.time() - last_sample_time = start_time - last_sample_frames = 0 - frames = 0 - - self._framerate_monitor_running = True - - LOGGER.info( - "Starting framerate monitor for %.2fs -> %s", - duration, - log_path, - ) - - with open(log_path, "w", newline="") as csv_file: - writer = csv.writer(csv_file) - - writer.writerow( - [ - "timestamp", - "frame_count", - "frame_size_bytes", - ] - ) - - async for frame in self.mjpeg_stream.frame_async_generator(): - if not self._framerate_monitor_running: - LOGGER.info("Framerate monitor stopped externally") - break - - now = time.time() - - frames += 1 - frame_size = len(frame) - - if now - last_sample_time >= sample_interval: - interval = now - last_sample_time - - instant_fps = (frames - last_sample_frames) / interval - - writer.writerow( - [ - now, - frames, - frame_size, - ] - ) - - csv_file.flush() - - LOGGER.info( - "[Framerate test] FPS=%.2f, size=%d B", - instant_fps, - frame_size, - ) - - last_sample_time = now - last_sample_frames = frames - - if now - start_time >= duration: - LOGGER.info("Framerate monitor duration reached") - break - - total_time = time.time() - start_time - avg_fps = frames / total_time if total_time > 0 else 0 - - writer.writerow([]) - writer.writerow(["TOTAL_DURATION", total_time]) - writer.writerow(["TOTAL_FRAMES", frames]) - writer.writerow(["AVG_FPS", avg_fps]) - - LOGGER.info( - "[Framerate test complete] Frames=%d, Avg FPS=%.2f", - frames, - avg_fps, - ) - - except Exception: - LOGGER.exception("Framerate monitor crashed") - - finally: - self._framerate_monitor_running = False - - @lt.action - def record_framerate_async( - self, - duration: float = 5.0, - output_dir: str = "framerate", - ) -> str: - """Start asynchronous MJPEG framerate monitoring. - - This action schedules ``_monitor_framerate`` on the Thing server event loop - and immediately returns. Monitoring occurs in the background and does not - block the calling thread. - - The monitor observes frames delivered through ``self.mjpeg_stream`` rather - than directly querying the camera hardware. This makes it suitable for - measuring effective stream throughput under real operating conditions. - - :param duration: Monitoring duration in seconds. Default = 5.0s. - :param output_dir: Directory where CSV log files will be written. - Default = ``"framerate"``. - - :returns: The output directory containing generated log files. - """ - self._thing_server_interface.start_async_task_soon( - self._monitor_framerate, - duration, - output_dir, - ) - - LOGGER.info( - "Started async framerate monitor for %.2fs -> %s", - duration, - output_dir, - ) - - return output_dir - @contextmanager def _switch_to_still_capture_mode(self) -> Iterator[Picamera2]: """Get the picamera lock, pause stream and switch into still capture config. diff --git a/tests/hardware_specific_tests/picamera2/test_acquisition.py b/tests/hardware_specific_tests/picamera2/test_acquisition.py index b017cf83..84259515 100644 --- a/tests/hardware_specific_tests/picamera2/test_acquisition.py +++ b/tests/hardware_specific_tests/picamera2/test_acquisition.py @@ -48,7 +48,7 @@ def test_record_framerate_async(picamera_client, tmp_path): # Wait slightly longer than recording duration for async task completion time.sleep(1.5) - log_files = list(output_dir.glob("framerate_log_*.csv")) + log_files = list(output_dir.glob("framerate_*.csv")) # Ensure exactly one log file was created assert len(log_files) == 1 diff --git a/tests/unit_tests/test_cameras.py b/tests/unit_tests/test_cameras.py index 52d16f31..0fcec5ca 100644 --- a/tests/unit_tests/test_cameras.py +++ b/tests/unit_tests/test_cameras.py @@ -93,7 +93,6 @@ def test_thing_description_equivalence(mock_picam_thing): "set_static_green_equalisation", "set_ce_enable_to_off", "stop_streaming", - "record_framerate_async", "reset_ccm", } picamera_extra_props = { From 5962c80fdccbe901de137e1d19f5380754d609b6 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 12 May 2026 19:13:35 +0100 Subject: [PATCH 07/14] Update testenvs with minimal app config Worked out by Beth Probert --- tests/unit_tests/test_base_camera.py | 5 ++++- tests/unit_tests/test_metadata.py | 4 +++- tests/unit_tests/test_simulated_camera.py | 6 +++++- tests/unit_tests/test_stage.py | 4 +++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/test_base_camera.py b/tests/unit_tests/test_base_camera.py index aba5b4ea..c8b64274 100644 --- a/tests/unit_tests/test_base_camera.py +++ b/tests/unit_tests/test_base_camera.py @@ -5,6 +5,8 @@ test_simulated_camera.py and for testing the consistency of camera APIs see test_cameras.py. """ +import tempfile + import numpy as np import pytest from PIL import Image @@ -19,7 +21,8 @@ from ..shared_utils.lt_test_utils import LabThingsTestEnv def test_env() -> LabThingsTestEnv: """Yield a test environment with the Simulated Camera and Dummy Stage.""" thing_conf = {"camera": SimulatedCamera, "stage": DummyStage} - with LabThingsTestEnv(things=thing_conf) as env: + app_config = {"data_folder": tempfile.gettempdir()} + with LabThingsTestEnv(things=thing_conf, application_config=app_config) as env: yield env diff --git a/tests/unit_tests/test_metadata.py b/tests/unit_tests/test_metadata.py index 7c5f87b6..4b158e5a 100644 --- a/tests/unit_tests/test_metadata.py +++ b/tests/unit_tests/test_metadata.py @@ -1,6 +1,7 @@ """Tests that captures have the expected metadata.""" import json +import tempfile from datetime import datetime from pathlib import Path @@ -99,7 +100,8 @@ def test_env() -> LabThingsTestEnv: "stage": DummyStage, "bg_channel_deviations_luv": ChannelDeviationLUV, } - with LabThingsTestEnv(things=thing_conf) as env: + app_config = {"data_folder": tempfile.gettempdir()} + with LabThingsTestEnv(things=thing_conf, application_config=app_config) as env: yield env diff --git a/tests/unit_tests/test_simulated_camera.py b/tests/unit_tests/test_simulated_camera.py index dd499fc9..8b76106c 100644 --- a/tests/unit_tests/test_simulated_camera.py +++ b/tests/unit_tests/test_simulated_camera.py @@ -1,6 +1,7 @@ """Test the functionality specific to the simulated camera.""" import logging +import tempfile import time import numpy as np @@ -27,7 +28,10 @@ def test_env() -> LabThingsTestEnv: "stage": DummyStage, "bg_channel_deviations_luv": ChannelDeviationLUV, } - with LabThingsTestEnv(things=thing_conf) as env: + + app_config = {"data_folder": tempfile.gettempdir()} + + with LabThingsTestEnv(things=thing_conf, application_config=app_config) as env: yield env diff --git a/tests/unit_tests/test_stage.py b/tests/unit_tests/test_stage.py index f922ef65..a699b65f 100644 --- a/tests/unit_tests/test_stage.py +++ b/tests/unit_tests/test_stage.py @@ -2,6 +2,7 @@ import itertools import logging +import tempfile import threading import time from dataclasses import dataclass @@ -176,7 +177,8 @@ def test_direction_inversion(dummy_stage): def test_direction_errors_local_and_http(): """Check for expected errors both locally and over http.""" thing_conf = {"camera": SimulatedCamera, "stage": DummyStage} - with LabThingsTestEnv(things=thing_conf) as test_env: + app_config = {"data_folder": tempfile.gettempdir()} + with LabThingsTestEnv(things=thing_conf, application_config=app_config) as test_env: dummy_stage = test_env.get_thing_by_type(DummyStage) stage_client = test_env.get_thing_client("stage") From 3bbea44eaf5dbd549ee4d8281ccc4370da00c709 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 13 May 2026 14:24:00 +0100 Subject: [PATCH 08/14] Move saving data out of async, save as json --- .../things/camera/__init__.py | 127 +++++++----------- 1 file changed, 45 insertions(+), 82 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index bc9d504d..0fd7332a 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -8,7 +8,7 @@ See repository root for licensing information. from __future__ import annotations -import csv +import asyncio import io import json import os @@ -268,126 +268,89 @@ class BaseCamera(OFMThing): self.lores_mjpeg_stream._streaming = False async def _monitor_framerate( - self, - duration: float, - output_dir: str, - sample_interval: float = 0.1, + self, duration: float, output_dir: str, sample_interval: float = 0.1 ) -> None: - """Monitor the delivered MJPEG frame rate asynchronously. - - This method listens to frames arriving on self.mjpeg_stream and records - timing information without directly interacting with the underlying - ``Picamera2`` instance. - - Writes a CSV log file with timestamp, frame count, and size of the most - recent MJPEG frame in bytes - - Monitoring stops automatically after ``duration`` seconds, or earlier if - ``self._framerate_monitor_running`` is set to ``False``. - - :param duration: Total monitoring duration in seconds. - :param output_dir: Directory in which the CSV log file will be written. - :param sample_interval: Time interval in seconds between FPS samples. - Default = 0.1s. - - :raises Exception: Any unexpected exception is logged before the monitor - exits. - """ try: os.makedirs(output_dir, exist_ok=True) timestamp = time.strftime("%Y%m%d_%H%M%S") - log_path = os.path.join( - output_dir, - f"framerate_{timestamp}.csv", - ) + log_path = os.path.join(output_dir, f"framerate_{timestamp}.json") start_time = time.time() last_sample_time = start_time last_sample_frames = 0 + frames = 0 + samples = [] self._framerate_monitor_running = True - # Store rows in memory and write once at the end - sample_rows = [] - - self.logger.info( - "Starting framerate monitor for %.2fs -> %s", - duration, - log_path, - ) + self.logger.info("Framerate monitor started -> %s", log_path) async for frame in self.mjpeg_stream.frame_async_generator(): if not self._framerate_monitor_running: - self.logger.info("Framerate monitor stopped externally") break now = time.time() - frames += 1 - frame_size = len(frame) + # take data every sample_interval seconds if now - last_sample_time >= sample_interval: interval = now - last_sample_time + fps = (frames - last_sample_frames) / interval - instant_fps = (frames - last_sample_frames) / interval - - sample_rows.append( - [ - now, - frames, - frame_size, - ] - ) - - self.logger.info( - "[Framerate test] FPS=%.2f, size=%d B", - instant_fps, - frame_size, - ) + samples.append((now, frames, len(frame), fps)) last_sample_time = now last_sample_frames = frames if now - start_time >= duration: - self.logger.info("Framerate monitor duration reached") break - total_time = time.time() - start_time - avg_fps = frames / total_time if total_time > 0 else 0 - - # Write CSV once at completion - with open(log_path, "w", newline="") as csv_file: - writer = csv.writer(csv_file) - - writer.writerow( - [ - "timestamp", - "frame_count", - "frame_size_bytes", - ] - ) - - writer.writerows(sample_rows) - - writer.writerow([]) - writer.writerow(["TOTAL_DURATION", total_time]) - writer.writerow(["TOTAL_FRAMES", frames]) - writer.writerow(["AVG_FPS", avg_fps]) - - self.logger.info( - "[Framerate test complete] Frames=%d, Avg FPS=%.2f", - frames, - avg_fps, + # write to disk outside of async + await asyncio.to_thread( + self._write_framerate_json, log_path, samples, start_time, frames ) + self.logger.info("Framerate monitor complete") + except Exception: self.logger.exception("Framerate monitor crashed") finally: self._framerate_monitor_running = False + def _write_framerate_json( + self, + log_path: str, + samples: list[tuple[float, int, int, float]], + start_time: float, + frames: int, + ) -> None: + """Write framerate monitoring results to a JSON file.""" + total_time = time.time() - start_time + avg_fps = frames / total_time if total_time > 0 else 0 + + data = { + "summary": { + "total_duration": total_time, + "total_frames": frames, + "avg_fps": avg_fps, + }, + "samples": [ + { + "timestamp": s[0], + "frame_count": s[1], + "frame_size_bytes": s[2], + "instant_fps": s[3], + } + for s in samples + ], + } + + with open(log_path, "w") as f: + json.dump(data, f, indent=2) + @lt.action def record_framerate_async( self, From 817c53d1df96e953c58da1492674c054001ab48b Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 13 May 2026 16:09:34 +0100 Subject: [PATCH 09/14] Data dir in cam_test_utils --- .../picamera2/cam_test_utils.py | 10 ++- .../picamera2/test_acquisition.py | 73 ++++++++----------- 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/tests/hardware_specific_tests/picamera2/cam_test_utils.py b/tests/hardware_specific_tests/picamera2/cam_test_utils.py index 144bc54b..8a96362b 100644 --- a/tests/hardware_specific_tests/picamera2/cam_test_utils.py +++ b/tests/hardware_specific_tests/picamera2/cam_test_utils.py @@ -1,5 +1,6 @@ """Utilities to help with testing the camera.""" +import tempfile from contextlib import contextmanager from typing import Optional @@ -23,7 +24,14 @@ def camera_test_env(settings_folder: Optional[str] = None): "camera": StreamingPiCamera2, "bg_channel_deviations_luv": ChannelDeviationLUV, } - with LabThingsTestEnv(things=thing_conf, settings_folder=settings_folder) as env: + app_config = { + "data_folder": tempfile.mkdtemp(), + } + with LabThingsTestEnv( + things=thing_conf, + settings_folder=settings_folder, + application_config=app_config, + ) as env: yield env diff --git a/tests/hardware_specific_tests/picamera2/test_acquisition.py b/tests/hardware_specific_tests/picamera2/test_acquisition.py index 84259515..3609bdd7 100644 --- a/tests/hardware_specific_tests/picamera2/test_acquisition.py +++ b/tests/hardware_specific_tests/picamera2/test_acquisition.py @@ -1,7 +1,8 @@ """Test data collection from the Raspberry Picamera.""" -import csv +import json import time +from pathlib import Path import numpy as np from PIL import Image @@ -34,21 +35,14 @@ def test_jpeg_and_array(picamera_client): assert array_main.shape[1::-1] == jpeg_capture.size -def test_record_framerate_async(picamera_client, tmp_path): - """Check that asynchronous framerate monitoring creates a valid CSV log.""" - output_dir = tmp_path / "framerate" - - returned_dir = picamera_client.record_framerate_async( - duration=1.0, - output_dir=str(output_dir), - ) - - assert returned_dir == str(output_dir) +def test_record_framerate_async(picamera_client): + """Check that asynchronous framerate monitoring creates a valid JSON log.""" + returned_dir = Path(picamera_client.record_framerate_async(duration=1.0)) # Wait slightly longer than recording duration for async task completion time.sleep(1.5) - log_files = list(output_dir.glob("framerate_*.csv")) + log_files = list(returned_dir.glob("framerate_*.json")) # Ensure exactly one log file was created assert len(log_files) == 1 @@ -58,43 +52,36 @@ def test_record_framerate_async(picamera_client, tmp_path): # Ensure file is not empty assert log_file.stat().st_size > 0 - with open(log_file, newline="") as f: - rows = list(csv.reader(f)) + # Load JSON + with open(log_file, "r") as f: + data = json.load(f) - # Header exists - assert rows[0] == [ - "timestamp", - "frame_count", - "frame_size_bytes", - ] + assert "summary" in data + assert "samples" in data - sample_rows = [ - row - for row in rows[1:] - if row and row[0] not in {"TOTAL_FRAMES", "TOTAL_DURATION", "AVG_FPS"} - ] + summary = data["summary"] + samples = data["samples"] - assert len(sample_rows) > 0 + assert "total_duration" in summary + assert "total_frames" in summary + assert "avg_fps" in summary - # Validate sample row structure - sample_row = sample_rows[0] + assert summary["total_duration"] > 0 + assert summary["total_frames"] > 0 + assert summary["avg_fps"] > 0 - assert len(sample_row) == 3 + assert len(samples) > 0 - timestamp = float(sample_row[0]) - frame_count = int(sample_row[1]) - frame_size = int(sample_row[2]) + sample = samples[0] - # Basic sanity checks - assert timestamp > 0 - assert frame_count > 0 - assert frame_size > 0 + # Each sample row equivalent + assert "timestamp" in sample + assert "frame_count" in sample + assert "frame_size_bytes" in sample + assert "instant_fps" in sample - assert rows[-3][0] == "TOTAL_DURATION" - assert float(rows[-3][1]) > 0 + assert sample["timestamp"] > 0 + assert sample["frame_count"] > 0 + assert sample["frame_size_bytes"] > 0 - assert rows[-2][0] == "TOTAL_FRAMES" - assert int(rows[-2][1]) > 0 - - assert rows[-1][0] == "AVG_FPS" - assert float(rows[-1][1]) > 0 + assert samples[-1]["frame_count"] > 0 From 1ea308a42d40844469d9a74d04be5933fa01dfdd Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 13 May 2026 17:50:24 +0100 Subject: [PATCH 10/14] Move analysis and saving out of async --- .../things/camera/__init__.py | 168 ++++++++---------- 1 file changed, 78 insertions(+), 90 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 0fd7332a..fe24bdc2 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -268,67 +268,86 @@ class BaseCamera(OFMThing): self.lores_mjpeg_stream._streaming = False async def _monitor_framerate( - self, duration: float, output_dir: str, sample_interval: float = 0.1 - ) -> None: + self, + duration: float, + sample_interval: float = 0.1, + ) -> tuple[float, int, list[dict[str, float | int]]]: + """Asynchronously monitor the timing on incoming frames.""" + start_time = time.time() + last_sample_time = start_time + last_sample_frames = 0 + + frames = 0 + samples = [] + + async for frame in self.mjpeg_stream.frame_async_generator(): + if not self._framerate_monitor_running: + break + + now = time.time() + frames += 1 + + if now - last_sample_time >= sample_interval: + interval = now - last_sample_time + fps = (frames - last_sample_frames) / interval + + samples.append( + { + "timestamp": now, + "frame_count": frames, + "frame_size_bytes": len(frame), + "instant_fps": fps, + } + ) + + last_sample_time = now + last_sample_frames = frames + + if now - start_time >= duration: + break + + total_time = time.time() - start_time + + return total_time, frames, samples + + @lt.action + def record_framerate( + self, + duration: float = 5.0, + ) -> str: + """Record MJPEG stream framerate statistics.""" + output_dir = os.path.join( + self.data_dir, + "characterisation", + "framerate", + ) + os.makedirs(output_dir, exist_ok=True) + + timestamp = time.strftime("%Y%m%d_%H%M%S") + log_path = os.path.join( + output_dir, + f"framerate_{timestamp}.json", + ) + + self.logger.info( + "Framerate monitor started -> %s", + log_path, + ) + + self._framerate_monitor_running = True + + # This runs as an async task, which we wait to complete try: - os.makedirs(output_dir, exist_ok=True) - - timestamp = time.strftime("%Y%m%d_%H%M%S") - log_path = os.path.join(output_dir, f"framerate_{timestamp}.json") - - start_time = time.time() - last_sample_time = start_time - last_sample_frames = 0 - - frames = 0 - samples = [] - - self._framerate_monitor_running = True - - self.logger.info("Framerate monitor started -> %s", log_path) - - async for frame in self.mjpeg_stream.frame_async_generator(): - if not self._framerate_monitor_running: - break - - now = time.time() - frames += 1 - - # take data every sample_interval seconds - if now - last_sample_time >= sample_interval: - interval = now - last_sample_time - fps = (frames - last_sample_frames) / interval - - samples.append((now, frames, len(frame), fps)) - - last_sample_time = now - last_sample_frames = frames - - if now - start_time >= duration: - break - - # write to disk outside of async - await asyncio.to_thread( - self._write_framerate_json, log_path, samples, start_time, frames + total_time, frames, samples = ( + self._thing_server_interface.call_async_task( + self._monitor_framerate, + duration, + ) ) - self.logger.info("Framerate monitor complete") - - except Exception: - self.logger.exception("Framerate monitor crashed") - finally: self._framerate_monitor_running = False - def _write_framerate_json( - self, - log_path: str, - samples: list[tuple[float, int, int, float]], - start_time: float, - frames: int, - ) -> None: - """Write framerate monitoring results to a JSON file.""" - total_time = time.time() - start_time avg_fps = frames / total_time if total_time > 0 else 0 data = { @@ -337,49 +356,18 @@ class BaseCamera(OFMThing): "total_frames": frames, "avg_fps": avg_fps, }, - "samples": [ - { - "timestamp": s[0], - "frame_count": s[1], - "frame_size_bytes": s[2], - "instant_fps": s[3], - } - for s in samples - ], + "samples": samples, } with open(log_path, "w") as f: json.dump(data, f, indent=2) - @lt.action - def record_framerate_async( - self, - duration: float = 5.0, - ) -> str: - """Start asynchronous MJPEG framerate monitoring. - - The monitor observes frames delivered through ``self.mjpeg_stream`` rather - than directly querying the camera hardware. This makes it suitable for - measuring effective stream throughput under real operating conditions. - - :param duration: Monitoring duration in seconds. Default = 5.0s. - - :returns: The output directory containing generated log files. - """ - output_dir = os.path.join(self.data_dir, "characterisation", "framerate") - self._thing_server_interface.start_async_task_soon( - self._monitor_framerate, - duration, - output_dir, - ) - self.logger.info( - "Started async framerate monitor for %.2fs -> %s", - duration, - output_dir, + "Framerate monitor complete -> %s", + log_path, ) - return output_dir + return log_path @lt.property def stream_active(self) -> bool: From 5be8b0ecab182bf2d636399815a84840314e38b3 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 13 May 2026 18:07:29 +0100 Subject: [PATCH 11/14] Update tests, including picamera --- picamera_coverage.zip | Bin 54038 -> 54038 bytes .../things/camera/__init__.py | 16 +++++++++------ .../picamera2/test_acquisition.py | 19 +++++------------- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index a29f518f023a9183533f48a1cac72e3307fe8dd0..16fdc31272ab91c56235a78e16b3236122b9f616 100644 GIT binary patch delta 507 zcmbQXjCtBJW}yIYW)=|!5V$#EZOr`q^ZPamsWgaOXW;+I|Av1je;dCH-w8ffK2F~2 zn;ixA@=g}(>q$+waJtPDBmDN|#ea3Y|Lp$0-*P>HH;04a{a!sQiTDlC%m?xt-!m|1 zJgEOKH%Hbk_waf~28Mqd85y!N7z}b44%D%=*k>_F9B43YkiNt4M_+>B0LTRl2@DJj z%m*0q7}(-}b5CaP52?>!Wo6`S3}9vY_1$c@(8wgo+&D4KB+)Fz z!qD8rBF)mw#4^<+$!PM03(gvr#%ZR;W@(9uX(k4#hAF0ImT76p#wKZrmZ^!x24-fd zh8C74iAiRYFI=#h+~X%Yx#!|xJEJ5ML*qn~6eELFQv-{Xlw_k6^E6`v^HgJV3uB{H hQ$zFAB=b}Q1EtykZ$>5&X4D9s+XI*g}IFDI=_9%sNc_4KmT9(t?UhKW-JZg+t2?wMO@a8f66X#(_0J(s{fPtZb zF@eFHp?UpZ*2zEmLSmv=Ss6JSO<0+JeK*^zV?0~3ON5ajpMinFfrXEuxr%XPTqB#o z0|o^K2_CZzj5io!Y;_rKh&9wkI4}t_FmI4#U{G$5elee6?(<`3MVK@mH1IYUvSnwQ z8aPO+Oy1U?#>2(L!pJGc)Rbz&^HImHDQcO})ObrrKEG<*b&65l) zjS^E*3=PsOj180142;ZDOihiGO($QtU^BVLPjqt6#lv>VrpA`3mT6{|$!5t0$%%%> wDaNTOX354DW@bhvsm5tWhDN652C2qMwE^CYOd`yvku|yZk_E!WXD@jI0E}^$UH||9 diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index fe24bdc2..b43b8dd9 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -8,7 +8,6 @@ See repository root for licensing information. from __future__ import annotations -import asyncio import io import json import os @@ -338,11 +337,9 @@ class BaseCamera(OFMThing): # This runs as an async task, which we wait to complete try: - total_time, frames, samples = ( - self._thing_server_interface.call_async_task( - self._monitor_framerate, - duration, - ) + total_time, frames, samples = self._thing_server_interface.call_async_task( + self._monitor_framerate, + duration, ) finally: @@ -359,6 +356,13 @@ class BaseCamera(OFMThing): "samples": samples, } + self.logger.info( + ("Framerate monitor results: duration=%.2fs, frames=%d, avg_fps=%.2f"), + total_time, + frames, + avg_fps, + ) + with open(log_path, "w") as f: json.dump(data, f, indent=2) diff --git a/tests/hardware_specific_tests/picamera2/test_acquisition.py b/tests/hardware_specific_tests/picamera2/test_acquisition.py index 3609bdd7..7ac49e3a 100644 --- a/tests/hardware_specific_tests/picamera2/test_acquisition.py +++ b/tests/hardware_specific_tests/picamera2/test_acquisition.py @@ -1,7 +1,6 @@ """Test data collection from the Raspberry Picamera.""" import json -import time from pathlib import Path import numpy as np @@ -35,19 +34,12 @@ def test_jpeg_and_array(picamera_client): assert array_main.shape[1::-1] == jpeg_capture.size -def test_record_framerate_async(picamera_client): - """Check that asynchronous framerate monitoring creates a valid JSON log.""" - returned_dir = Path(picamera_client.record_framerate_async(duration=1.0)) +def test_record_framerate(picamera_client): + """Check that framerate monitoring creates a valid JSON log with good data.""" + log_file = Path(picamera_client.record_framerate(duration=1.0)) - # Wait slightly longer than recording duration for async task completion - time.sleep(1.5) - - log_files = list(returned_dir.glob("framerate_*.json")) - - # Ensure exactly one log file was created - assert len(log_files) == 1 - - log_file = log_files[0] + assert log_file.exists() + assert log_file.is_file() # Ensure file is not empty assert log_file.stat().st_size > 0 @@ -74,7 +66,6 @@ def test_record_framerate_async(picamera_client): sample = samples[0] - # Each sample row equivalent assert "timestamp" in sample assert "frame_count" in sample assert "frame_size_bytes" in sample From cbfbd84fa90e0f2c9d7fd32dfac3daa9fdff143f Mon Sep 17 00:00:00 2001 From: jaknapper Date: Thu, 14 May 2026 10:52:33 +0100 Subject: [PATCH 12/14] Changes from review --- .../things/camera/__init__.py | 10 +++++----- .../things/camera/picamera.py | 1 - tests/shared_utils/lt_test_utils.py | 6 +++++- tests/unit_tests/test_base_camera.py | 5 +---- tests/unit_tests/test_metadata.py | 4 +--- tests/unit_tests/test_simulated_camera.py | 5 +---- tests/unit_tests/test_stage.py | 4 +--- 7 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index b43b8dd9..049f2a29 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -323,14 +323,14 @@ class BaseCamera(OFMThing): os.makedirs(output_dir, exist_ok=True) timestamp = time.strftime("%Y%m%d_%H%M%S") - log_path = os.path.join( + datafile_path = os.path.join( output_dir, f"framerate_{timestamp}.json", ) self.logger.info( "Framerate monitor started -> %s", - log_path, + datafile_path, ) self._framerate_monitor_running = True @@ -363,15 +363,15 @@ class BaseCamera(OFMThing): avg_fps, ) - with open(log_path, "w") as f: + with open(datafile_path, "w") as f: json.dump(data, f, indent=2) self.logger.info( "Framerate monitor complete -> %s", - log_path, + datafile_path, ) - return log_path + return datafile_path @lt.property def stream_active(self) -> bool: diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 93f536fa..b76d4ed5 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -167,7 +167,6 @@ class StreamingPiCamera2(BaseCamera): self._sensor_info = SUPPORTED_CAMS_SENSOR_INFO[camera_board] self._picamera_lock = RLock() self._picamera = None - self._framerate_monitor_running = False # Load the tuning file for the specified sensor mode. self.default_tuning = tf_utils.load_default_tuning( diff --git a/tests/shared_utils/lt_test_utils.py b/tests/shared_utils/lt_test_utils.py index 19b61723..6e272c1d 100644 --- a/tests/shared_utils/lt_test_utils.py +++ b/tests/shared_utils/lt_test_utils.py @@ -48,7 +48,11 @@ class LabThingsTestEnv: self._test_client: Optional[TestClient] self._things_config = things self._settings_folder = settings_folder - self._application_config = application_config + # Labthings requires an application config for the data_folder + if application_config: + self._application_config = application_config + else: + self._application_config = {"data_folder": tempfile.TemporaryDirectory()} self._tmp_dir_obj: Optional[tempfile.TemporaryDirectory] = None def __enter__(self) -> Self: diff --git a/tests/unit_tests/test_base_camera.py b/tests/unit_tests/test_base_camera.py index c8b64274..aba5b4ea 100644 --- a/tests/unit_tests/test_base_camera.py +++ b/tests/unit_tests/test_base_camera.py @@ -5,8 +5,6 @@ test_simulated_camera.py and for testing the consistency of camera APIs see test_cameras.py. """ -import tempfile - import numpy as np import pytest from PIL import Image @@ -21,8 +19,7 @@ from ..shared_utils.lt_test_utils import LabThingsTestEnv def test_env() -> LabThingsTestEnv: """Yield a test environment with the Simulated Camera and Dummy Stage.""" thing_conf = {"camera": SimulatedCamera, "stage": DummyStage} - app_config = {"data_folder": tempfile.gettempdir()} - with LabThingsTestEnv(things=thing_conf, application_config=app_config) as env: + with LabThingsTestEnv(things=thing_conf) as env: yield env diff --git a/tests/unit_tests/test_metadata.py b/tests/unit_tests/test_metadata.py index 4b158e5a..7c5f87b6 100644 --- a/tests/unit_tests/test_metadata.py +++ b/tests/unit_tests/test_metadata.py @@ -1,7 +1,6 @@ """Tests that captures have the expected metadata.""" import json -import tempfile from datetime import datetime from pathlib import Path @@ -100,8 +99,7 @@ def test_env() -> LabThingsTestEnv: "stage": DummyStage, "bg_channel_deviations_luv": ChannelDeviationLUV, } - app_config = {"data_folder": tempfile.gettempdir()} - with LabThingsTestEnv(things=thing_conf, application_config=app_config) as env: + with LabThingsTestEnv(things=thing_conf) as env: yield env diff --git a/tests/unit_tests/test_simulated_camera.py b/tests/unit_tests/test_simulated_camera.py index 8b76106c..99f486be 100644 --- a/tests/unit_tests/test_simulated_camera.py +++ b/tests/unit_tests/test_simulated_camera.py @@ -1,7 +1,6 @@ """Test the functionality specific to the simulated camera.""" import logging -import tempfile import time import numpy as np @@ -29,9 +28,7 @@ def test_env() -> LabThingsTestEnv: "bg_channel_deviations_luv": ChannelDeviationLUV, } - app_config = {"data_folder": tempfile.gettempdir()} - - with LabThingsTestEnv(things=thing_conf, application_config=app_config) as env: + with LabThingsTestEnv(things=thing_conf) as env: yield env diff --git a/tests/unit_tests/test_stage.py b/tests/unit_tests/test_stage.py index a699b65f..f922ef65 100644 --- a/tests/unit_tests/test_stage.py +++ b/tests/unit_tests/test_stage.py @@ -2,7 +2,6 @@ import itertools import logging -import tempfile import threading import time from dataclasses import dataclass @@ -177,8 +176,7 @@ def test_direction_inversion(dummy_stage): def test_direction_errors_local_and_http(): """Check for expected errors both locally and over http.""" thing_conf = {"camera": SimulatedCamera, "stage": DummyStage} - app_config = {"data_folder": tempfile.gettempdir()} - with LabThingsTestEnv(things=thing_conf, application_config=app_config) as test_env: + with LabThingsTestEnv(things=thing_conf) as test_env: dummy_stage = test_env.get_thing_by_type(DummyStage) stage_client = test_env.get_thing_client("stage") From 9d68718570b9d803be16f1528281e5bc14bc5ba3 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Thu, 14 May 2026 11:00:31 +0100 Subject: [PATCH 13/14] Test picamera --- 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 16fdc31272ab91c56235a78e16b3236122b9f616..e1c41f1e8f939f3f0e9fddc228da6e458fe388ce 100644 GIT binary patch delta 327 zcmbQXjCtBJW}yIYW)=|!5HJW|7vm*)^vOn{#=cZznT5HG>pH)E$*AAYRzLq=`K{~? zY-TJC-{Zt(d-QLtWqh#xKsf`0$cO#){O9=QZ+m#1fq~)w4F(2N69x%0h6H=&L-M8! zJP8M+4)EqN{1fM4NC4TvV8Fo8z?i^b&d|L6FYD$XeNK)XAbVGAlA zmX@jJ=1B&YMu{mYh6ZUC#)iph21aHnrl!WprjsvRu$kQBCpx+3;$b^uqf{fKR10I1 z#6$~&q}0?DgVf{{i)1syG|Mz&vs7c#WFylwGm{jh+5m4xCJ|=Tz?s~8$$|s!;ZTR7cjiV=SM^5VZb-hXy~-*35| zz?;Lt@P4nJl|=l8Xyya?jqe#4G#=FdmzyJNmwR|UBLl;~jf@Og84LzF31M>lfJO;M--`tyj^f@_lfb6y2xyx;`(}gsRBopH# zi Date: Thu, 14 May 2026 11:24:05 +0100 Subject: [PATCH 14/14] Clean up temporary data dir in LabThingsTestEnv after test --- tests/shared_utils/lt_test_utils.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/shared_utils/lt_test_utils.py b/tests/shared_utils/lt_test_utils.py index 6e272c1d..5ceabe4c 100644 --- a/tests/shared_utils/lt_test_utils.py +++ b/tests/shared_utils/lt_test_utils.py @@ -42,24 +42,28 @@ class LabThingsTestEnv: as a context manager: :param things: The thing configuration dictionary used to initialise the server. - :param settings_folder: The settings folder to use. + :param settings_folder: The settings folder to use. If None a tempdir is created + for this. + :param application_config: The application configuration dictionary. If None is + supplied then "data_folder" is set to a temporary directory. """ self._server: Optional[lt.ThingServer] self._test_client: Optional[TestClient] self._things_config = things self._settings_folder = settings_folder - # Labthings requires an application config for the data_folder - if application_config: - self._application_config = application_config - else: - self._application_config = {"data_folder": tempfile.TemporaryDirectory()} - self._tmp_dir_obj: Optional[tempfile.TemporaryDirectory] = None + self._application_config = application_config + self._settings_tmp_dir: Optional[tempfile.TemporaryDirectory] = None + self._data_tmp_dir: Optional[tempfile.TemporaryDirectory] = None def __enter__(self) -> Self: """Create server and run it in the TestClient.""" + # Any OFMThings require an application config for the data_folder + if self._application_config is None: + self._data_tmp_dir = tempfile.TemporaryDirectory() + self._application_config = {"data_folder": self._data_tmp_dir} if self._settings_folder is None: - self._tmp_dir_obj = tempfile.TemporaryDirectory() - self._settings_folder = self._tmp_dir_obj.name + self._settings_tmp_dir = tempfile.TemporaryDirectory() + self._settings_folder = self._settings_tmp_dir.name self._server = lt.ThingServer( things=self._things_config, settings_folder=self._settings_folder, @@ -77,8 +81,10 @@ class LabThingsTestEnv: ) -> None: """Close the TestClient and cleanup.""" self._test_client.__exit__(exc_type, exc_value, traceback) - if self._tmp_dir_obj is not None: - self._tmp_dir_obj.cleanup() + if self._settings_tmp_dir is not None: + self._settings_tmp_dir.cleanup() + if self._data_tmp_dir is not None: + self._data_tmp_dir.cleanup() @property def server(self) -> lt.ThingServer: