diff --git a/software/control/_def.py b/software/control/_def.py index 28a8ab2ba..522159bae 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -859,6 +859,12 @@ class SOFTWARE_POS_LIMIT: ENABLE_WELLPLATE_MULTIPOINT = True ENABLE_RECORDING = False +# Throttled live preview during Record + Z-Stack acquisitions: frames/s shown in +# the live display during the recording phase. When preview is enabled, z-stack +# planes display unthrottled (like multipoint). 0 disables the preview for both +# phases. +RECORD_ZSTACK_DISPLAY_FPS = 3 + RESUME_LIVE_AFTER_ACQUISITION = True # When enabled, each multipoint acquisition will write a second log file scoped to that acquisition at: diff --git a/software/control/acquisition_yaml_loader.py b/software/control/acquisition_yaml_loader.py index 225520d8b..4bf9aaabf 100644 --- a/software/control/acquisition_yaml_loader.py +++ b/software/control/acquisition_yaml_loader.py @@ -61,6 +61,83 @@ class AcquisitionYAMLData: skip_saving: bool = False +@dataclass +class RecordZStackYAMLData: + """Parsed record/z-stack acquisition YAML data structure.""" + + widget_type: str # "record_zstack" + xy_mode: str = "Select Wells" + + objective_name: Optional[str] = None + camera_binning: Optional[Tuple[int, int]] = None + + nt: int = 1 + delta_t_s: float = 0.0 + + laser_af: bool = False + + recording_enabled: bool = False + recording_channel: Optional[Dict] = None + fps: float = 10.0 + effective_fps: Optional[float] = None # informational: the rate the saved run was made at + duration_s: float = 1.0 + recording_bottom_z_offset_um: float = 0.0 + recording_nz: int = 1 + recording_dz_um: float = 1.0 + + zstack_enabled: bool = False + zstack_channels: List[Dict] = field(default_factory=list) + z_min_um: float = -3.0 + z_max_um: float = 3.0 + z_step_um: float = 1.0 + + scan_size_mm: Optional[float] = None + overlap_percent: float = 10.0 + wellplate_regions: Optional[List[Dict]] = None + + +def _parse_camera_binning(obj: dict) -> Optional[Tuple[int, int]]: + binning = obj.get("camera_binning") + if binning and isinstance(binning, list) and len(binning) == 2: + return tuple(binning) + return None + + +def _parse_record_zstack_yaml_data(data: dict, acq: dict) -> RecordZStackYAMLData: + obj = data.get("objective", {}) + time_series = data.get("time_series", {}) + autofocus = data.get("autofocus", {}) + recording = data.get("recording", {}) + z_stack = data.get("z_stack", {}) + wellplate_scan = data.get("wellplate_scan", {}) + + return RecordZStackYAMLData( + widget_type="record_zstack", + xy_mode=acq.get("xy_mode", "Select Wells"), + objective_name=obj.get("name"), + camera_binning=_parse_camera_binning(obj), + nt=time_series.get("nt", 1), + delta_t_s=time_series.get("delta_t_s", 0.0), + laser_af=autofocus.get("laser_af", False), + recording_enabled=recording.get("enabled", False), + recording_channel=recording.get("channel"), + fps=recording.get("fps", 10.0), + effective_fps=recording.get("effective_fps"), + duration_s=recording.get("duration_s", 1.0), + recording_bottom_z_offset_um=recording.get("bottom_z_offset_um", 0.0), + recording_nz=recording.get("nz", 1), + recording_dz_um=recording.get("dz_um", 1.0), + zstack_enabled=z_stack.get("enabled", False), + zstack_channels=z_stack.get("channels", []), + z_min_um=z_stack.get("z_min_um", -3.0), + z_max_um=z_stack.get("z_max_um", 3.0), + z_step_um=z_stack.get("z_step_um", 1.0), + scan_size_mm=wellplate_scan.get("scan_size_mm"), + overlap_percent=wellplate_scan.get("overlap_percent", 10.0), + wellplate_regions=wellplate_scan.get("regions"), + ) + + def parse_acquisition_yaml(file_path: str) -> AcquisitionYAMLData: """Parse a saved acquisition.yaml - or a saved acquisition folder containing one. @@ -83,7 +160,8 @@ def parse_acquisition_yaml(file_path: str) -> AcquisitionYAMLData: parsed = parse_acquisition_dict(data, source=file_path) csv_path = os.path.join(folder or os.path.dirname(file_path), "coordinates.csv") - regions = parsed.wellplate_regions if parsed.widget_type == "wellplate" else parsed.flexible_positions + # record_zstack YAMLs carry wellplate-style regions and no flexible_positions. + regions = parsed.flexible_positions if parsed.widget_type == "flexible" else parsed.wellplate_regions if regions and any(not r.get("fovs") for r in regions) and os.path.isfile(csv_path): try: by_name = {r["name"]: r["fovs"] for r in read_coordinates_csv(csv_path)} @@ -114,17 +192,16 @@ def parse_acquisition_dict(data: dict, source: str = "") -> AcquisitionYAM flexible_scan = data.get("flexible_scan", {}) # Validate widget_type - VALID_WIDGET_TYPES = ("wellplate", "flexible") + VALID_WIDGET_TYPES = ("wellplate", "flexible", "record_zstack") widget_type = acq.get("widget_type", "wellplate") if widget_type not in VALID_WIDGET_TYPES: raise ValueError(f"Invalid widget_type '{widget_type}'. Must be one of: {VALID_WIDGET_TYPES}") + if widget_type == "record_zstack": + return _parse_record_zstack_yaml_data(data, acq) + # Parse camera binning - binning = obj.get("camera_binning") - if binning and isinstance(binning, list) and len(binning) == 2: - camera_binning = tuple(binning) - else: - camera_binning = None + camera_binning = _parse_camera_binning(obj) # Determine overlap_percent from the appropriate section if wellplate_scan: diff --git a/software/control/camera_toupcam.py b/software/control/camera_toupcam.py index ed6a9393b..8b1c085ce 100644 --- a/software/control/camera_toupcam.py +++ b/software/control/camera_toupcam.py @@ -20,6 +20,7 @@ import threading import control.toupcam as toupcam +import control.toupcam_exceptions from control.toupcam_exceptions import hresult_checker log = squid.logging.get_logger(__name__) @@ -61,7 +62,33 @@ def get_sn_by_model(camera_model: ToupcamCameraModel): return None # return None if no device with the specified model_name is connected +def clamp_precise_framerate_tenths(fps: float, min_tenths: int, max_tenths: int) -> int: + """Clamp fps (in frames per second) to the allowed range in tenths. + + Args: + fps: Desired frame rate in frames per second + min_tenths: Minimum allowed value in tenths (0.1 fps units) + max_tenths: Maximum allowed value in tenths (0.1 fps units) + + Returns: + Clamped value in tenths of fps + """ + tenths = int(round(fps * 10.0)) + return max(min_tenths, min(max_tenths, tenths)) + + class ToupcamCamera(AbstractCamera): + # Last successfully read (min, max) PRECISE_FRAMERATE range in tenths of fps; see + # _refresh_precise_framerate_range. Class-level default so it exists before __init__ runs. + _precise_framerate_range_tenths: Optional[Tuple[int, int]] = None + # (width, height, bytes/pixel) the cached range was read for; the range depends on + # resolution and bit depth, so a mode change invalidates it (see _update_internal_settings). + _precise_framerate_mode_key: Optional[Tuple[int, int, int]] = None + # time.time() of the last PRECISE_FRAMERATE write made while the stream was running. + # Such a write corrupts the frame integrating at that moment when a hardware ROI is + # set; ContinuousFrameSource discards frames delivered within a period of it. + frame_rate_hint_live_write_ts: Optional[float] = None + TOUPCAM_OPTION_RAW_RAW_VAL = 1 TOUPCAM_OPTION_RAW_RGB_VAL = 0 PIXEL_SIZE_UM = 3.76 @@ -331,6 +358,18 @@ def _start_raw_camera_stream(self): self._raw_camera_stream_started = False self._log.exception("failed to start camera, hr=0x{:x}".format(ex.hr)) raise ex + # The PRECISE_FRAMERATE range is only readable while the stream runs; grab + # it now so set_frame_rate() calls made while stopped can clamp against + # the current mode's range (see _refresh_precise_framerate_range). Only in + # CONTINUOUS mode: the option paces free-run only, and a trigger-mode + # stream (the z-stack phase) must not overwrite the cached free-run range. + # Outside the try above so nothing here can flip _raw_camera_stream_started + # while the SDK stream is actually running. + try: + if self.get_acquisition_mode() == CameraAcquisitionMode.CONTINUOUS: + self._refresh_precise_framerate_range() + except Exception: + self._log.debug("precise-framerate range refresh skipped after stream start", exc_info=True) def _on_frame_callback(self): """ @@ -456,6 +495,14 @@ def _update_internal_settings(self, send_exposure=True): with self._raw_frame_callback_lock: self._internal_read_buffer = bytes(buffer_size) + # A resolution / bit-depth change moves the PRECISE_FRAMERATE range (28 fps at + # 2x2 16-bit vs 63 fps at 3x3 on the ITR3CMOS26000KMA): drop the cached range so a + # stopped-stream set_frame_rate() cannot clamp the new mode to the old maximum. + mode_key = (int(width), int(height), int(pixel_size)) + if mode_key != self._precise_framerate_mode_key: + self._precise_framerate_mode_key = mode_key + self._precise_framerate_range_tenths = None + image_exposure_time_ms = self.get_exposure_time() camera_exposure_time_ms = self._calculate_camera_exposure_time(image_exposure_time_ms) self._strobe_info = ToupcamCamera._calculate_strobe_info( @@ -588,6 +635,134 @@ def get_exposure_limits(self) -> Tuple[float, float]: (min_exposure, max_exposure, default_exposure) = self._camera.get_ExpTimeRange() return min_exposure / 1000.0, max_exposure / 1000.0 # us -> ms + def _continuous_max_framerate(self, exposure_time_ms: Optional[float] = None) -> float: + """Highest frame rate the sensor can sustain in CONTINUOUS (free-run) mode, in fps, at the + given exposure (None: the current one). + + In continuous mode the exposure pipelines with sensor readout, so the frame period + is max(readout, exposure). This deliberately does NOT use get_total_frame_time() + (== readout + trigger_delay + exposure), which is the *sequential* software/hardware- + trigger period and under-reports the free-run rate by ~2x. strobe_time_us is the + pure, exposure-independent readout period. The triggered-mode timing getters + (get_strobe_time/get_total_frame_time/_calculate_strobe_info) are left untouched. + """ + exposure = self.get_exposure_time() if exposure_time_ms is None else float(exposure_time_ms) + readout_ms = self._strobe_info.strobe_time_us / 1000.0 + return 1000.0 / max(readout_ms, exposure) + + def _refresh_precise_framerate_range(self) -> Optional[Tuple[int, int]]: + """Return (min, max) PRECISE_FRAMERATE in tenths of fps, refreshing the cache when possible. + + The range query is only answered while the pull-mode stream is running; while the + camera is stopped it fails with E_UNEXPECTED (verified on an ITR3CMOS26000KMA), so a + failed read falls back to the last successful one. Returns None when the option has + never been readable (model without PRECISE_FRAMERATE support). + """ + try: + max_tenths = self._camera.get_Option(toupcam.TOUPCAM_OPTION_MAX_PRECISE_FRAMERATE) + min_tenths = self._camera.get_Option(toupcam.TOUPCAM_OPTION_MIN_PRECISE_FRAMERATE) + mode_key = self._current_mode_key() + except Exception as ex: + # HRESULTException while stopped is the normal case; anything else + # (ROI / pixel-format lookups) must not escape either — a stale or + # missing cache only costs the hardware pacing, never the stream. + self._log.debug(f"precise-framerate range read failed (stream stopped?): {ex}") + return self._precise_framerate_range_tenths + self._precise_framerate_range_tenths = (int(min_tenths), int(max_tenths)) + self._precise_framerate_mode_key = mode_key + return self._precise_framerate_range_tenths + + def _current_mode_key(self) -> Tuple[int, int, int]: + """(width, height, bytes/pixel) — what the PRECISE_FRAMERATE range depends on.""" + _, _, width, height = self._camera.get_Roi() + return int(width), int(height), int(self._get_pixel_size_in_bytes()) + + def get_max_frame_rate(self, exposure_time_ms: Optional[float] = None) -> float: + """Highest free-run rate for the current binning / ROI / bit depth at the given exposure. + + Pure query (no SDK writes): the readout-limited estimate of _continuous_max_framerate + at that exposure, bounded by the cached PRECISE_FRAMERATE maximum when the cache is for + the current mode. Within ~1.5% of the SDK's own maximum in every mode measured. + """ + fps = self._continuous_max_framerate(exposure_time_ms) + rng = self._precise_framerate_range_tenths + if rng is not None: + try: + if self._precise_framerate_mode_key == self._current_mode_key(): + fps = min(fps, rng[1] / 10.0) + except Exception: + pass + return fps + + def set_frame_rate(self, fps: float) -> float: + """Set the frame rate via the PRECISE_FRAMERATE option (CONTINUOUS mode only). + + _calculate_strobe_info (~:128-140) drives PRECISE_FRAMERATE to MAX on mode + switch; set_frame_rate must be called **after** entering CONTINUOUS to take + effect, and recording restores nothing (next acquisition resets exposure → MAX again). + + Works whether or not the stream is running: the option *write* is accepted while + the camera is stopped and takes effect on the next Start (the range *read* is not, + hence the cached range — see _refresh_precise_framerate_range). + + Args: + fps: Desired frame rate in frames per second. If None or <= 0, returns + the camera's achievable continuous maximum without changing settings. + + Returns: + The frame rate the camera will actually deliver, in fps: the requested rate + clamped to the PRECISE_FRAMERATE range and then bounded by the exposure/readout- + limited continuous maximum (PRECISE_FRAMERATE only slows the sensor down — its + reported maximum ignores the exposure time, so at 100 ms exposure the camera + still delivers 10 fps whatever the option says). When the option is unavailable + on this model it is the continuous maximum (see _continuous_max_framerate). + """ + continuous_max = self._continuous_max_framerate() + if fps is None or fps <= 0: + return continuous_max + framerate_range = self._refresh_precise_framerate_range() + if framerate_range is not None: + min_tenths, max_tenths = framerate_range + tenths = clamp_precise_framerate_tenths(fps, min_tenths, max_tenths) + else: + # Range unknown: the stream has not run since the last resolution / + # bit-depth change. Bound the request by the readout-limited maximum + # (within ~1.5% of the SDK's MAX_PRECISE_FRAMERATE in every binning / + # bit-depth mode measured on the ITR3CMOS26000KMA) and try the write — + # but the SDK validates a stopped-state write against the range of the + # mode it last streamed in, so it may well be rejected (see below). + tenths = max(1, int(round(min(fps, continuous_max) * 10.0))) + live = self._raw_camera_stream_started + if live: + # Writing PRECISE_FRAMERATE into a RUNNING stream corrupts the frame being + # integrated at that moment when a hardware ROI is set (bench: 1.8-3.6x + # over-exposed, saturated on a bright scene; full-frame is unaffected), so + # skip the write when the camera already reports the value. It cannot be + # avoided altogether: with a ROI the SDK re-clamps a stopped-state write + # to the full-frame maximum at Start, so a higher rate only takes while + # running — callers discard the frames around the write (see + # frame_rate_hint_live_write_ts / ContinuousFrameSource.start). + try: + if int(self._camera.get_Option(toupcam.TOUPCAM_OPTION_PRECISE_FRAMERATE)) == tenths: + return min(tenths / 10.0, continuous_max) + except toupcam.HRESULTException: + pass + try: + self._camera.put_Option(toupcam.TOUPCAM_OPTION_PRECISE_FRAMERATE, tenths) + if live: + self.frame_rate_hint_live_write_ts = time.time() + except toupcam.HRESULTException as ex: + # The sensor keeps whatever PRECISE_FRAMERATE was set before (it does not + # fall back to free-run), so the caller should re-apply the hint once the + # stream is running — ContinuousFrameSource.start() does exactly that. + self._log.warning( + f"set precise-framerate {tenths / 10.0:g} fps rejected while the stream is stopped " + f"({control.toupcam_exceptions.explain(ex)}); the previous pacing stays in effect " + "until the hint is re-applied with the stream running" + ) + return continuous_max + return min(tenths / 10.0, continuous_max) + @staticmethod def _user_gain_to_toupcam(user_gain): """ diff --git a/software/control/core/acquisition_setup.py b/software/control/core/acquisition_setup.py new file mode 100644 index 000000000..00f83f66c --- /dev/null +++ b/software/control/core/acquisition_setup.py @@ -0,0 +1,171 @@ +"""Shared acquisition setup helpers. + +Helpers used by MultiPointController and RecordZStackController to set up +experiment directories and the pre-warmed job-runner subprocess without +duplicating logic across controller classes. +""" + +import os +from datetime import datetime +from typing import Optional, Tuple + +import control._def +import squid.acquisition_state +import squid.logging +from control import utils + +_log = squid.logging.get_logger(__name__) + + +def compute_pixel_size_um(objective_store, camera) -> Optional[float]: + """Compute the physical pixel size in µm from objective and camera metadata. + + Returns the product of the objective's pixel-size factor and the camera's + binned pixel size in µm, or None if either value is unavailable or an + exception is raised. + + Args: + objective_store: ObjectiveStore (or compatible object) with + ``get_pixel_size_factor() -> Optional[float]``. + camera: AbstractCamera (or compatible) with + ``get_pixel_size_binned_um() -> Optional[float]``. + + Returns: + Pixel size in µm, or None. + """ + try: + pixel_factor = objective_store.get_pixel_size_factor() + sensor_pixel_um = camera.get_pixel_size_binned_um() + if pixel_factor is not None and sensor_pixel_um is not None: + return float(pixel_factor) * float(sensor_pixel_um) + return None + except Exception: + return None + + +def create_experiment_dir(base_path: str, experiment_id: str, add_timestamp: bool = True) -> Tuple[str, str]: + """Resolve a unique experiment ID and create its output directory. + + With *add_timestamp* (the default) appends a timestamp to *experiment_id* + (spaces replaced with underscores) to guarantee uniqueness, then creates the + directory tree under *base_path*. With ``add_timestamp=False`` the folder is + *experiment_id* verbatim and a ``FileExistsError`` is raised if it already + exists (the fluidics protocol runner names session folders itself and never + reuses one). + + Args: + base_path: Root directory for all experiments. + experiment_id: Human-readable experiment name supplied by the user. + add_timestamp: Append a timestamp to make the folder name unique. + + Returns: + A ``(resolved_id, dir_path)`` tuple where *resolved_id* is the + (possibly timestamped) identifier and *dir_path* is the absolute path + of the newly created directory. + """ + if add_timestamp: + resolved_id = experiment_id.replace(" ", "_") + "_" + datetime.now().strftime("%Y-%m-%d_%H-%M-%S.%f") + else: + resolved_id = experiment_id + dir_path = os.path.join(base_path, resolved_id) + if not add_timestamp and os.path.exists(dir_path): + raise FileExistsError(dir_path) + utils.ensure_directory_exists(dir_path) + return resolved_id, dir_path + + +class PrewarmedJobRunnerSlot: + """Owns one pre-warmed JobRunner subprocess kept ready for the next acquisition. + + Shared by MultiPointController and RecordZStackController so the + warm-up / consume / re-warm / shutdown lifecycle — and its invariants (the + runner must stay paired with the backpressure values it was created from; + a consumed slot immediately re-warms for the next run) — lives in one place. + + Known limitation: pre-warming for the NEXT acquisition starts when ``take()`` + is called (i.e. when the CURRENT acquisition begins). If another acquisition + starts before warm-up finishes (~1.2 s), the worker will wait for the + subprocess. This only affects rapid-fire manual clicking; real workloads + (full plate scans, time-lapse with intervals >2 s) are unaffected. + """ + + def __init__(self, logger): + self._log = logger + self._runner = None + self._bp_values = None + + def start(self) -> None: + """Start a JobRunner subprocess warming up in the background.""" + from control.core.backpressure import create_backpressure_values + from control.core.job_processing import JobRunner + + self._log.info("Pre-warming job runner subprocess...") + # Shared backpressure values pair the runner with the worker's + # BackpressureController for consistent cross-process tracking. + self._bp_values = create_backpressure_values() + self._runner = JobRunner( + bp_pending_jobs=self._bp_values[0], + bp_pending_bytes=self._bp_values[1], + bp_capacity_event=self._bp_values[2], + ) + self._runner.start() + + def take(self): + """Consume the pre-warmed runner and start warming a fresh one. + + Returns ``(runner, bp_values)``; both are None when multiprocessing is + off or the slot was already consumed. Use them together or not at all — + a runner without its matching values would track different counters + than the BackpressureController built from them. + """ + runner, bp_values = self._runner, self._bp_values + self._runner = None + self._bp_values = None + if control._def.Acquisition.USE_MULTIPROCESSING: + self.start() + return runner, bp_values + + def shutdown_runner(self, runner, timeout_s: float = 1.0, context: str = "") -> None: + """Shut down *runner* (previously returned by ``take()``), tolerating errors.""" + if runner is not None: + try: + runner.shutdown(timeout_s=timeout_s) + except Exception as e: + self._log.error(f"Error shutting down pre-warmed runner {context}: {e}") + + def close(self, timeout_s: float = 1.0) -> None: + """Shut down the currently held pre-warmed runner (application shutdown).""" + if self._runner is not None: + self._log.info("Shutting down pre-warmed job runner...") + self.shutdown_runner(self._runner, timeout_s=timeout_s, context="during close") + self._runner = None + self._bp_values = None + + +def start_run_state_writer( + experiment_id: str, output_path: str, *, timepoints: int, region_fov_coords: dict, channels: int, z: int +): + """Start the acquisition watchdog's run.json breadcrumb, or return the null writer. + + The run's size goes in as timepoints x regions x fovs x channels x z images. A breadcrumb + failure must never stop an acquisition: it is logged, and the null writer stands in so + beat()/end() stay safe to call. + """ + try: + config_path = (getattr(control._def, "CACHED_CONFIG_FILE_PATH", None) or "").strip() or None + return squid.acquisition_state.RunStateWriter.start( + experiment_id=experiment_id, + pid=os.getpid(), + config_path=config_path, + output_path=output_path, + expected={ + "timepoints": timepoints, + "regions": len(region_fov_coords), + "fovs": sum(len(coords) for coords in region_fov_coords.values()), + "channels": channels, + "z": z, + }, + ) + except Exception as e: + _log.warning(f"Failed to write acquisition watchdog start state: {e}") + return squid.acquisition_state.NullRunStateWriter() diff --git a/software/control/core/multi_point_controller.py b/software/control/core/multi_point_controller.py index 8e998b7ed..a1659f04e 100644 --- a/software/control/core/multi_point_controller.py +++ b/software/control/core/multi_point_controller.py @@ -6,7 +6,6 @@ import tempfile import time import yaml -from datetime import datetime from enum import Enum from threading import Thread from typing import Optional, Tuple, Any @@ -17,6 +16,7 @@ from control import utils, utils_acquisition import control._def from control.core.auto_focus_controller import AutoFocusController +from control.core.acquisition_setup import create_experiment_dir, PrewarmedJobRunnerSlot, start_run_state_writer from control.core.multi_point_utils import MultiPointControllerFunctions, ScanPositionInformation, AcquisitionParameters from control.core.scan_coordinates import ScanCoordinates from control.core.laser_auto_focus_controller import LaserAutofocusController @@ -203,13 +203,10 @@ def __init__( self._memory_monitor: Optional[MemoryMonitor] = None self._slack_notifier = None # Optional SlackNotifier for notifications - # Pre-warm job runner subprocess at controller init (reduces acquisition start delay) - # Backpressure values (tuple) are created here and shared with both the pre-warmed runner - # and the BackpressureController in the worker, ensuring consistent tracking. - self._prewarmed_job_runner: Optional["JobRunner"] = None - self._prewarmed_bp_values: Optional["BackpressureValues"] = None + # Pre-warm job runner subprocess at controller init (reduces acquisition start delay). + self._prewarm = PrewarmedJobRunnerSlot(self._log) if control._def.Acquisition.USE_MULTIPROCESSING: - self._start_prewarmed_job_runner() + self._prewarm.start() self.NX = 1 self.deltaX = control._def.Acquisition.DX @@ -260,83 +257,18 @@ def __init__( self._start_position: Optional[squid.abc.Pos] = None - def _start_prewarmed_job_runner(self): - """Start a job runner subprocess that warms up in the background. - - This reduces acquisition start delay by having the subprocess already - running when the user clicks 'Start Acquisition'. - - Also creates backpressure values that will be used by both the - pre-warmed runner and the BackpressureController in the worker. - - Known limitation: Pre-warming for the NEXT acquisition is started when - the CURRENT acquisition begins (i.e., when ``get_prewarmed_job_runner()`` - is called). If the user starts another acquisition before pre-warming - finishes (~1.2s), the worker will wait for the subprocess. This only - affects rapid-fire manual clicking; real workloads (full plate scans, - time-lapse with intervals >2s) are unaffected. - """ - from control.core.job_processing import JobRunner - from control.core.backpressure import create_backpressure_values - - self._log.info("Pre-warming job runner subprocess...") - # Create shared backpressure values for cross-process tracking - self._prewarmed_bp_values = create_backpressure_values() - - self._prewarmed_job_runner = JobRunner( - bp_pending_jobs=self._prewarmed_bp_values[0], - bp_pending_bytes=self._prewarmed_bp_values[1], - bp_capacity_event=self._prewarmed_bp_values[2], - ) - self._prewarmed_job_runner.start() - - def _cleanup_prewarmed_runner( - self, - runner: Optional["JobRunner"], - timeout_s: float = 1.0, - context: str = "", - ) -> None: - """Shutdown a pre-warmed job runner. - - Args: - runner: JobRunner to shutdown, or None - timeout_s: Timeout for runner shutdown - context: Context string for error messages (e.g., "during close") - """ - if runner is not None: - try: - runner.shutdown(timeout_s=timeout_s) - except Exception as e: - self._log.error(f"Error shutting down pre-warmed runner {context}: {e}") - def get_prewarmed_job_runner(self) -> Tuple[Optional["JobRunner"], Optional["BackpressureValues"]]: - """The pre-warmed save subprocess and its backpressure values for the acquisition about to start. + """Consume the pre-warmed job runner and its shared backpressure values. Zarr v3 is saved in the acquisition process (InProcessZarrRunner), so a Zarr run gets (None, None) and the warm subprocess stays ready for a later TIFF run. Any other format - consumes it (the references are cleared so it is only used once) and a fresh one starts - warming for the next acquisition. - - Usage: - runner, bp_values = controller.get_prewarmed_job_runner() - worker = MultiPointWorker(..., prewarmed_job_runner=runner, - prewarmed_bp_values=bp_values) + delegates to the shared PrewarmedJobRunnerSlot: returns (runner, bp_values) + — both None when multiprocessing is off — and immediately starts warming a + fresh runner for the next acquisition. """ if SaveSettings().file_saving_option == control._def.FileSavingOption.ZARR_V3: return None, None - - runner = self._prewarmed_job_runner - bp_values = self._prewarmed_bp_values - - # Clear references (so they're only used once) - self._prewarmed_job_runner = None - self._prewarmed_bp_values = None - - # Start warming up a new one for the next acquisition - if control._def.Acquisition.USE_MULTIPROCESSING: - self._start_prewarmed_job_runner() - - return runner, bp_values + return self._prewarm.take() def set_alignment_widget(self, alignment_widget): """Set the alignment widget for coordinate offset during acquisitions.""" @@ -469,16 +401,10 @@ def start_new_experiment(self, experiment_ID, add_timestamp=True): add_timestamp=False: folder = experiment_ID verbatim; raises FileExistsError if it already exists (the fluidics protocol runner names session folders itself and never reuses one). """ - if add_timestamp: - self.experiment_ID = experiment_ID.replace(" ", "_") + "_" + datetime.now().strftime("%Y-%m-%d_%H-%M-%S.%f") - else: - self.experiment_ID = experiment_ID + self.experiment_ID, experiment_dir = create_experiment_dir( + self.base_path, experiment_ID, add_timestamp=add_timestamp + ) self.recording_start_time = time.time() - # create a new folder - experiment_dir = os.path.join(self.base_path, self.experiment_ID) - if not add_timestamp and os.path.exists(experiment_dir): - raise FileExistsError(experiment_dir) - utils.ensure_directory_exists(experiment_dir) # Save acquisition configuration via ConfigRepository self.liveController.microscope.config_repo.save_acquisition_output( output_dir=experiment_dir, @@ -956,27 +882,16 @@ def finish_fn(): protocol=run_protocol_info, ) - # Acquisition watchdog: drop the "running" breadcrumb (covers GUI + MCP-server runs). - # self._run_state_writer is already a NullRunStateWriter (set before the outer try); it - # stays one if start() below fails, so a breadcrumb write failure never breaks acquisition. - try: - expected = { - "timepoints": self.Nt, - "regions": len(scan_position_information.scan_region_coords_mm), - "fovs": sum(len(c) for c in scan_position_information.scan_region_fov_coords_mm.values()), - "channels": len(self.selected_configurations), - "z": self.NZ, - } - config_path = (getattr(control._def, "CACHED_CONFIG_FILE_PATH", None) or "").strip() or None - self._run_state_writer = squid.acquisition_state.RunStateWriter.start( - experiment_id=self.experiment_ID, - pid=os.getpid(), - config_path=config_path, - output_path=experiment_path, - expected=expected, - ) - except Exception as e: - self._log.warning(f"Failed to write acquisition watchdog start state: {e}") + # Acquisition watchdog: drop the "running" breadcrumb (covers GUI + MCP-server runs); the + # null writer stands in if it cannot be written, so that never breaks the acquisition. + self._run_state_writer = start_run_state_writer( + experiment_id=self.experiment_ID, + output_path=experiment_path, + timepoints=self.Nt, + region_fov_coords=scan_position_information.scan_region_fov_coords_mm, + channels=len(self.selected_configurations), + z=self.NZ, + ) # The pre-warmed save subprocess and its backpressure values (None, None for a Zarr v3 run) prewarmed_runner, prewarmed_bp_values = self.get_prewarmed_job_runner() @@ -1002,10 +917,7 @@ def finish_fn(): ) except Exception: # The runner handed to us is ours to clean up; a replacement is already warming (None for Zarr v3). - self._cleanup_prewarmed_runner( - prewarmed_runner, - context="after worker creation failure", - ) + self._prewarm.shutdown_runner(prewarmed_runner, context="after worker creation failure") raise # Signal after worker creation so backpressure_controller is available @@ -1197,15 +1109,7 @@ def close(self, timeout_s: float = 5.0) -> None: _PROCESS_TERMINATE_TIMEOUT_S. """ # Clean up pre-warmed job runner if it exists - if self._prewarmed_job_runner is not None: - self._log.info("Shutting down pre-warmed job runner...") - self._cleanup_prewarmed_runner( - self._prewarmed_job_runner, - timeout_s=self._PROCESS_TERMINATE_TIMEOUT_S, - context="during close", - ) - self._prewarmed_job_runner = None - self._prewarmed_bp_values = None + self._prewarm.close(timeout_s=self._PROCESS_TERMINATE_TIMEOUT_S) # Abort any running acquisition try: diff --git a/software/control/core/multi_point_worker.py b/software/control/core/multi_point_worker.py index 8d7a19e08..a2a3129a6 100644 --- a/software/control/core/multi_point_worker.py +++ b/software/control/core/multi_point_worker.py @@ -1,4 +1,5 @@ import math +import contextlib import os import queue import threading @@ -48,6 +49,7 @@ JobResult, ) from control.core.in_process_zarr_runner import InProcessZarrRunner +from control.core.acquisition_setup import compute_pixel_size_um from control.core.mosaic_utils import ( calculate_overlap_pixels, parse_well_id, @@ -75,7 +77,638 @@ class SummarizeResult(NamedTuple): had_results: bool # True if any results were pulled from queue -class MultiPointWorker: +class MultiPointWorkerBase: + """Shared acquisition mechanics (camera, stage, channel apply, single-frame + capture, frame callback, job dispatch, backpressure, abort, progress). + + Owns NO orchestration loop — subclasses provide run() and the acquisition + state it operates on. Subclasses MUST call super().__init__(...) first, then + build the real job runners / backpressure controller and refine the + placeholder state (use_piezo, time_point, slack notifier, etc.). + """ + + def __init__( + self, + scope: Microscope, + live_controller: LiveController, + callbacks: MultiPointControllerFunctions, + abort_requested_fn: Callable[[], bool], + request_abort_fn: Callable[[], None], + run_state_writer=None, + ): + # Use type(self).__name__ so each subclass instance gets a logger named + # after its own concrete class (e.g. "RecordZStackWorker"), rather than + # after the class where the expression is written. __class__ inside a + # method always refers to the defining class (MultiPointWorkerBase), not + # the runtime type, so type(self) is required to produce per-subclass names. + self._log = squid.logging.get_logger(type(self).__name__) + self._timing = utils.TimingManager("MultiPointWorker Timer Manager") + + # Hardware / controller handles shared by all acquisition workers. + self.microscope: Microscope = scope + self.camera: AbstractCamera = scope.camera + self.microcontroller: Microcontroller = scope.low_level_drivers.microcontroller + self.stage: squid.abc.AbstractStage = scope.stage + self.piezo: Optional[PiezoStage] = scope.addons.piezo_stage + self.liveController = live_controller + + # Callback / abort plumbing shared by all acquisition workers. + self.callbacks: MultiPointControllerFunctions = callbacks + self.abort_requested_fn: Callable[[], bool] = abort_requested_fn + self.request_abort_fn: Callable[[], None] = request_abort_fn + self._abort_cause = None # set to "error" by auto-abort paths (timeout / failed jobs) + self.end_reason: Optional[str] = None # set in run()'s finally; read by the controller + # Acquisition watchdog breadcrumb (run.json); the null writer when none is wired (tests, side paths). + self._run_state = run_state_writer or squid.acquisition_state.NullRunStateWriter() + + # Optional SlackNotifier — subclasses set the real one if present. + self._slack_notifier = None + + # Counters touched by the shared capture / frame-callback path. Subclasses + # may reset these per timepoint, but they must exist for the base methods. + self.image_count = 0 + self._timepoint_fov_count = 0 + self._timepoint_image_count = 0 + self._acquisition_error_count = 0 + + # Capture state refined by the subclass __init__ from acquisition params. + # Defaults are placeholders so lifted methods never read an unset attribute; + # subclasses overwrite these with the real per-acquisition values. + self.use_piezo = False + self.time_point = 0 + self.z_piezo_um = 0.0 + + # Callback-based capture bookkeeping. This is for keeping track of whether + # or not we have the last image we tried to capture. + # NOTE(imo): Once we do overlapping triggering, we'll want to keep a queue of images we are expecting. + # For now, this is an improvement over blocking immediately while waiting for the next image! + self._ready_for_next_trigger = threading.Event() + # Set this to true so that the first frame capture can proceed. + self._ready_for_next_trigger.set() + # This is cleared when the image callback is no longer processing an image. If true, an image is still + # in flux and we need to make sure the object doesn't disappear. + self._image_callback_idle = threading.Event() + self._image_callback_idle.set() + # This is protected by the threading event above (aka set after clear, take copy before set) + self._current_capture_info: Optional[CaptureInfo] = None + # This is only touched via the image callback path. Don't touch it outside of there! + self._current_round_images = {} + + # Job-runner / backpressure handles. The real ones are heavily dependent on + # MultiPoint-specific acquisition state (selected configs, scan coords, zarr + # writer info, ...), so they are built by the subclass __init__. The base + # methods (_finish_jobs, _summarize_runner_outputs, _image_callback) only + # need these attributes to exist; placeholders keep them self-contained. + self._backpressure: Optional[BackpressureController] = None + self._job_runners: List[Tuple[Type[Job], JobRunner]] = [] + # The settings in effect now, for the whole acquisition. + self._save_settings = SaveSettings() + self._abort_on_failed_job = True + self._first_job_dispatched = False # Track if we've waited for subprocess warmup + + def update_use_piezo(self, value): + self.use_piezo = value + self._log.info(f"MultiPointWorker: updated use_piezo to {value}") + + def wait_till_operation_is_completed(self): + self.microcontroller.wait_till_operation_is_completed() + + def move_to_z_level(self, z_mm): + self._log.debug("moving z") + self.stage.move_z_to(z_mm) + self._sleep(SCAN_STABILIZATION_TIME_MS_Z / 1000) + + def _select_config(self, config: AcquisitionChannel): + self.callbacks.signal_current_configuration(config) + self.liveController.set_microscope_mode(config) + self.wait_till_operation_is_completed() + + def _create_backpressure(self, prewarmed_bp_values: Optional[BackpressureValues] = None) -> BackpressureController: + """Build the acquisition BackpressureController from the global throttling knobs. + + When pre-warmed backpressure values are supplied they are reused, so the + controller tracks the same cross-process counters as the pre-warmed + JobRunner it is paired with. + """ + bp_kwargs = { + "max_jobs": control._def.ACQUISITION_MAX_PENDING_JOBS, + "max_mb": control._def.ACQUISITION_MAX_PENDING_MB, + "timeout_s": control._def.ACQUISITION_THROTTLE_TIMEOUT_S, + "enabled": control._def.ACQUISITION_THROTTLING_ENABLED, + } + if prewarmed_bp_values is not None: + bp_kwargs["bp_values"] = prewarmed_bp_values + return BackpressureController(**bp_kwargs) + + def _channel_zarr_metadata(self, configs) -> Tuple[List[str], List[str], List[Optional[int]]]: + """Names, display colors, and illumination wavelengths for zarr channel metadata.""" + illumination_config = self.microscope.config_repo.get_illumination_config() + names = [cfg.name for cfg in configs] + colors = [cfg.display_color for cfg in configs] + wavelengths = [ + cfg.get_illumination_wavelength(illumination_config) if illumination_config else None for cfg in configs + ] + return names, colors, wavelengths + + def _adopt_or_create_job_runner( + self, + prewarmed_job_runner: Optional[JobRunner], + prewarmed_bp_values: Optional[BackpressureValues], + zarr_writer_info: Optional[ZarrWriterInfo], + cleanup_stale_ome_files: bool, + job_name: str, + ) -> JobRunner: + """Adopt the pre-warmed JobRunner when it is ready, else create and start a fresh one. + + IMPORTANT: the pre-warmed runner is adopted only when BOTH it AND its + backpressure values were supplied — a runner without matching values would + track different counters than the BackpressureController built from them + (see _create_backpressure). A supplied runner that is not ready (possibly + hung during warmup) is shut down to avoid a resource leak before a fresh + runner is created. + """ + if prewarmed_job_runner is not None and prewarmed_bp_values is not None: + if prewarmed_job_runner.is_ready(): + self._log.info(f"Using pre-warmed job runner for {job_name} jobs") + # Configure it with the current acquisition settings. + prewarmed_job_runner.set_acquisition_info(self.acquisition_info) + if zarr_writer_info: + prewarmed_job_runner.set_zarr_writer_info(zarr_writer_info) + return prewarmed_job_runner + self._log.warning( + f"Pre-warmed job runner not ready (possibly hung during warmup), " + f"shutting it down and creating new one for {job_name}" + ) + try: + prewarmed_job_runner.shutdown(timeout_s=1.0) + except Exception as e: + self._log.error(f"Error shutting down hung pre-warmed runner: {e}") + self._log.info(f"Creating job runner for {job_name} jobs") + job_runner = JobRunner( + self.acquisition_info, + cleanup_stale_ome_files=cleanup_stale_ome_files, + # Share the current log file with the subprocess worker. + log_file_path=squid.logging.get_current_log_file_path(), + # Pass backpressure shared values for cross-process tracking. + bp_pending_jobs=self._backpressure.pending_jobs_value, + bp_pending_bytes=self._backpressure.pending_bytes_value, + bp_capacity_event=self._backpressure.capacity_event, + zarr_writer_info=zarr_writer_info, + ) + # Subprocess starts warming up in background - don't block here. + job_runner.start() + return job_runner + + def _frame_wait_timeout_s(self): + return (self.camera.get_total_frame_time() / 1e3) + 10 + + def _wait_for_outstanding_callback_images(self): + """Block until any in-flight triggered frame has been dispatched/processed.""" + self._log.info("Waiting for any outstanding frames.") + if not self._ready_for_next_trigger.wait(self._frame_wait_timeout_s()): + self._log.warning("Timed out waiting for the last outstanding frames at end of acquisition!") + + if not self._image_callback_idle.wait(self._frame_wait_timeout_s()): + self._log.warning("Timed out waiting for the last image to process!") + + # No matter what, set the flags so things can continue + self._ready_for_next_trigger.set() + self._image_callback_idle.set() + + def _sleep(self, sec): + time_to_sleep = max(sec, 1e-6) + # self._log.debug(f"Sleeping for {time_to_sleep} [s]") + time.sleep(time_to_sleep) + + def _emit_plate_layout(self, image: np.ndarray) -> None: + """Hook for subclasses to emit a plate layout on the first image. + + No-op in the base; MultiPointWorker overrides this with the plate-based + scan implementation. Lives here so the shared frame callback can call it + without referencing subclass-only behavior. + """ + return + + def _summarize_runner_outputs(self, drain_all: bool = False) -> SummarizeResult: + """Process job results from output queues. + + Args: + drain_all: If True, process ALL available results. If False, process at most one per queue. + + Returns: + SummarizeResult with none_failed and had_results. + """ + none_failed = True + had_results = False + for job_class, job_runner in self._job_runners: + if job_runner is None: + continue + out_queue = job_runner.output_queue() + if out_queue is None: + # Queue was cleared during shutdown + continue + if not drain_all and not job_runner.is_alive() and job_runner.has_pending(): + self._log.error( + f"{job_class.__name__} save subprocess exited (exit code {getattr(job_runner, 'exitcode', None)}) " + f"with jobs pending; aborting the acquisition" + ) + self._abort_due_to_error() + while True: + try: + job_result: JobResult = out_queue.get_nowait() + none_failed = none_failed and self._summarize_job_result(job_result) + had_results = True + if not drain_all: + break # Only process one result per queue if not draining + except queue.Empty: + break + except ValueError: + # Queue was closed during shutdown - nothing more to drain + break + + return SummarizeResult(none_failed=none_failed, had_results=had_results) + + def _summarize_job_result(self, job_result: JobResult) -> bool: + """ + Prints a summary, then returns True if the result was successful or False otherwise. + """ + if job_result.exception is not None: + self._log.error(f"Error while running job {job_result.job_id}: {job_result.exception}") + self._acquisition_error_count += 1 + if self._abort_on_failed_job: + self._log.error("A save job failed, aborting acquisition because abort_on_failed_job=True") + self._abort_due_to_error() + + # Send Slack error notification + if self._slack_notifier is not None: + try: + context = {"job_id": job_result.job_id} + self._slack_notifier.notify_error( + str(job_result.exception), + context, + ) + except Exception as e: + self._log.warning(f"Failed to send Slack error notification: {e}") + return False + else: + self._log.info(f"Got result for job {job_result.job_id}, it completed!") + # Handle ZarrWriteResult - notify viewer that frame is written + if isinstance(job_result.result, ZarrWriteResult): + r = job_result.result + self.callbacks.signal_zarr_frame_written(r.fov, r.time_point, r.z_index, r.channel_name, r.region_idx) + return True + + def _create_job(self, job_class: Type[Job], info: CaptureInfo, image: np.ndarray) -> Optional[Job]: + """Create a job instance for the given job class. + + Returns None if the job should be skipped. + """ + return job_class( + capture_info=info, capture_image=JobImage(image_array=image), save_settings=self._save_settings + ) + + def _finish_jobs(self, timeout_s=10): + # Drain and summarize all currently available job results before waiting for completion + self._summarize_runner_outputs(drain_all=True) + + active_runners = [ + (job_class, job_runner) for job_class, job_runner in self._job_runners if job_runner is not None + ] + + self._log.info(f"Waiting for jobs to finish on {len(active_runners)} job runners before shutting them down...") + timeout_time = time.time() + timeout_s + + def timed_out(): + return time.time() > timeout_time + + def time_left(): + return max(timeout_time - time.time(), 0) + + # Wait for all pending jobs across all runners (round-robin to avoid blocking on one) + while not timed_out(): + any_pending = False + for job_class, job_runner in active_runners: + if job_runner.has_pending(): + any_pending = True + break + if not any_pending: + break + # Process any available results while waiting + self._summarize_runner_outputs(drain_all=True) + time.sleep(0.1) + else: + # Timed out - kill any runners that still have pending jobs + abandoned = False + for job_class, job_runner in active_runners: + if job_runner.has_pending(): + self._log.error( + f"Timed out after {timeout_s} [s] waiting for jobs to finish. Pending jobs for {job_class.__name__} abandoned!!!" + ) + job_runner.kill() + abandoned = True + if abandoned: + # Data is missing, so the run ends as an error (and the Zarr stores are sealed as aborted). + self._abort_due_to_error() + + # Drain results before shutdown (a failed job seen here aborts the run too, via _summarize_job_result) + self._summarize_runner_outputs(drain_all=True) + + # Shut down the save subprocesses in parallel (in background to avoid blocking on subprocess termination); + # the in-process Zarr runner is shut down inline below, because its shutdown seals the stores. + # Using daemon threads is safe here because: + # 1. All jobs are complete and results are already drained + # 2. The subprocess termination is best-effort cleanup only + # 3. If app exits before threads complete, OS will terminate subprocesses anyway + # 4. This prevents slow subprocess termination from blocking acquisition completion + log = self._log # Capture for closure + + def shutdown_runner(job_runner, timeout): + try: + job_runner.shutdown(timeout) + except Exception as e: + log.error(f"Error shutting down job runner in background: {e}") + + self._log.info("Shutting down job runners...") + remaining_time = time_left() + # A user Stop, an error abort, or a crash out of run(): the store must not claim completeness. + aborted = bool(self.abort_requested_fn()) + for job_class, job_runner in active_runners: + if isinstance(job_runner, InProcessZarrRunner): + # Its shutdown seals the stores (aborted ones as aborted); do it here so the + # acquisition is not reported finished before the metadata says so. + try: + if not job_runner.shutdown(remaining_time, aborted=aborted): + self._abort_due_to_error() # a write was still in flight: the stores were sealed aborted + except Exception as e: + log.error(f"Error shutting down the in-process Zarr runner: {e}") + continue + t = threading.Thread(target=shutdown_runner, args=(job_runner, remaining_time), daemon=True) + t.start() + + # Final drain of all output queues (should be empty, but check anyway) + self._summarize_runner_outputs(drain_all=True) + + # Release backpressure resources now that all jobs are complete + try: + self._backpressure.close() + except Exception as e: + self._log.error(f"Error closing backpressure controller: {e}") + + def _create_in_process_zarr_runner(self, zarr_writer_info: Optional[ZarrWriterInfo]) -> InProcessZarrRunner: + """Zarr is saved in this process (see in_process_zarr_runner.py); the runner shares our backpressure counters.""" + runner = InProcessZarrRunner( + zarr_writer_info=zarr_writer_info, + bp_values=( + self._backpressure.pending_jobs_value, + self._backpressure.pending_bytes_value, + self._backpressure.capacity_event, + ), + ) + runner.start() + return runner + + def _abort_due_to_error(self) -> None: + """Abort the run due to an internal error (vs a user abort). + + The worker only ever aborts itself on error conditions; user aborts arrive + via the external abort flag. Tagging the cause here lets + MultiPointWorker._compute_end_reason classify the end as "error" instead + of "user_abort". + """ + self._abort_cause = "error" + self.request_abort_fn() + + def _compute_end_reason(self) -> str: + if self.abort_requested_fn(): + return "error" if self._abort_cause == "error" else "user_abort" + if self._acquisition_error_count > 0: + return "completed_with_errors" + return "completed" + + def _run_state_beat(self) -> None: + """Watchdog heartbeat: the base capture path calls it per image, subclasses per FOV / timepoint.""" + self._run_state.beat( + { + "timepoint": self.time_point, + "fov": self._timepoint_fov_count, + "images": self.image_count, + } + ) + + def _end_run_state(self, duration_s: float) -> str: + """Settle why the run ended, close the watchdog breadcrumb with the run's stats, return the reason.""" + self.end_reason = self._compute_end_reason() + self._run_state.end( + self.end_reason, + { + "total_images": self.image_count, + "total_timepoints": self.time_point, + "total_duration_seconds": duration_s, + "errors_encountered": self._acquisition_error_count, + }, + ) + return self.end_reason + + @contextlib.contextmanager + def _camera_streaming(self, on_frame: Optional[Callable] = None): + """Stream the camera for the block, with on_frame registered when given. + + On the way out: wait for the last in-flight frame, detach the callback and stop + streaming (mirrors ContinuousFrameSource.stop()), each failure logged, so the next + phase starts on a stopped camera whatever happened inside the block. + """ + cb_id = None + try: + self.camera.start_streaming() + if on_frame is not None: + cb_id = self.camera.add_frame_callback(on_frame) + yield + finally: + if cb_id is not None: + self._wait_for_outstanding_callback_images() + try: + self.camera.remove_frame_callback(cb_id) + except Exception: + self._log.exception("Failed to remove the frame callback") + try: + self.camera.stop_streaming() + except Exception: + self._log.exception("Failed to stop streaming") + + def _image_callback(self, camera_frame: CameraFrame): + try: + if self._ready_for_next_trigger.is_set(): + self._log.warning( + "Got an image in the image callback, but we didn't send a trigger. Ignoring the image." + ) + return + + self._image_callback_idle.clear() + with self._timing.get_timer("_image_callback"): + self._log.debug(f"In Image callback for frame_id={camera_frame.frame_id}") + info = self._current_capture_info + self._current_capture_info = None + + self._ready_for_next_trigger.set() + if not info: + self._log.error("In image callback, no current capture info! Something is wrong. Aborting.") + self._abort_due_to_error() + return + + image = camera_frame.frame + if not camera_frame or image is None: + self._log.warning("image in frame callback is None. Something is really wrong, aborting!") + self._abort_due_to_error() + return + + # Increment image counter for Slack notification stats + self._timepoint_image_count += 1 + self.image_count += 1 + self._run_state_beat() + + with self._timing.get_timer("job creation and dispatch"): + # Wait for subprocess to be ready before first dispatch + if not self._first_job_dispatched: + for job_class, job_runner in self._job_runners: + if job_runner is not None: + t_wait_start = time.perf_counter() + if job_runner.wait_ready(timeout_s=10.0): + t_wait_end = time.perf_counter() + wait_ms = (t_wait_end - t_wait_start) * 1000 + if wait_ms > 10: # Only log if we actually had to wait + self._log.info(f"Job runner ready (waited {wait_ms:.0f}ms for subprocess)") + else: + self._log.warning(f"Job runner for {job_class.__name__} not ready after 10s") + self._first_job_dispatched = True + + for job_class, job_runner in self._job_runners: + job = self._create_job(job_class, info, image) + if job is None: + continue # Skip if job creation returns None (e.g., downsampled views disabled for this image) + if job_runner is not None: + if not job_runner.dispatch(job): + self._log.error("Failed to dispatch multiprocessing job!") + self._abort_due_to_error() + return + else: + try: + # In-process fallback (multiprocessing off) for the TIFF job classes; + # SaveZarrJob always has an InProcessZarrRunner. + # NOTE(imo): We don't have any way of people using results, so for now just + # grab and ignore it. + result = job.run() + except Exception: + self._log.exception("Failed to execute job, abandoning acquisition!") + self._abort_due_to_error() + return + + height, width = image.shape[:2] + # with self._timing.get_timer("crop_image"): + # image_to_display = utils.crop_image( + # image, + # round(width * self.display_resolution_scaling), + # round(height * self.display_resolution_scaling), + # ) + # Emit plate layout once on the first image so the unified mosaic + # widget can lay out the plate grid before tiles start arriving. + self._emit_plate_layout(image) + with self._timing.get_timer("image_to_display*.emit"): + self.callbacks.signal_new_image(camera_frame, info) + + finally: + self._image_callback_idle.set() + + def acquire_camera_image( + self, config, file_ID: str, current_path: str, k: int, region_id: int, fov: int, config_idx: int + ): + self._select_config(config) + + # trigger acquisition (including turning on the illumination) and read frame + camera_illumination_time = self.camera.get_exposure_time() + if self.liveController.trigger_mode == TriggerMode.SOFTWARE: + self.liveController.turn_on_illumination() + self.wait_till_operation_is_completed() + camera_illumination_time = None + elif self.liveController.trigger_mode == TriggerMode.HARDWARE: + if "Fluorescence" in config.name and ENABLE_NL5 and NL5_USE_DOUT: + # TODO(imo): This used to use the "reset_image_ready_flag=False" on the read_frame, but oinly the toupcam camera implementation had the + # "reset_image_ready_flag" arg, so this is broken for all other cameras. Also this used to do some other funky stuff like setting internal camera flags. + # I am pretty sure this is broken! + self.microscope.addons.nl5.start_acquisition() + # This is some large timeout that we use just so as to not block forever + with self._timing.get_timer("_ready_for_next_trigger.wait"): + if not self._ready_for_next_trigger.wait(self._frame_wait_timeout_s()): + self._log.error("Frame callback never set _have_last_triggered_image callback! Aborting acquisition.") + self._abort_due_to_error() + return + + # Backpressure check AFTER previous frame dispatched, BEFORE next trigger + # This is when we know the previous image's jobs have been dispatched (and counters incremented) + if self._backpressure.should_throttle(): + with self._timing.get_timer("backpressure.wait_for_capacity"): + got_capacity = self._backpressure.wait_for_capacity() + if not got_capacity: + self._log.error( + f"Backpressure timeout - disk I/O cannot keep up. Stats: {self._backpressure.get_stats()}" + ) + + with self._timing.get_timer("get_ready_for_trigger re-check"): + # This should be a noop - we have the frame already. Still, check! + while not self.camera.get_ready_for_trigger(): + self._sleep(0.001) + + self._ready_for_next_trigger.clear() + with self._timing.get_timer("current_capture_info ="): + # Even though the capture time will be slightly after this, we need to capture and set the capture info + # before the trigger to be 100% sure the callback doesn't stomp on it. + # NOTE(imo): One level up from acquire_camera_image, we have acquire_pos. We're careful to use that as + # much as we can, but don't use it here because we'd rather take the position as close as possible to the + # real capture time for the image info. Ideally we'd use this position for the caller's acquire_pos as well. + current_capture_info = CaptureInfo( + position=self.stage.get_pos(), + z_index=k, + capture_time=time.time(), + z_piezo_um=(self.z_piezo_um if self.use_piezo else None), + configuration=config, + save_directory=current_path, + file_id=file_ID, + region_id=region_id, + fov=fov, + configuration_idx=config_idx, + time_point=self.time_point, + ) + self._current_capture_info = current_capture_info + with self._timing.get_timer("send_trigger"): + self.camera.send_trigger(illumination_time=camera_illumination_time) + + with self._timing.get_timer("exposure_time_done_sleep_hw or wait_for_image_sw"): + if self.liveController.trigger_mode == TriggerMode.HARDWARE: + exposure_done_time = time.time() + self.camera.get_total_frame_time() / 1e3 + # Even though we can do overlapping triggers, we want to make sure that we don't move before our exposure + # is done. So we still need to at least sleep for the total frame time corresponding to this exposure. + self._sleep(max(0.0, exposure_done_time - time.time())) + else: + # In SW trigger mode (or anything not HARDWARE mode), there's indeterminism in the trigger timing. + # To overcome this, just wait until the frame for this capture actually comes into the image + # callback. That way we know we have it. This also helps by making sure the illumination for this + # frame is on from before the trigger until after we get the frame (which guarantees it will be on + # for the full exposure). + # + # If we wait for longer than 5x the exposure + 2 seconds, abort the acquisition because something is + # wrong. + non_hw_frame_timeout = 5 * self.camera.get_total_frame_time() / 1e3 + 2 + if not self._ready_for_next_trigger.wait(non_hw_frame_timeout): + self._log.error("Timed out waiting {non_hw_frame_timeout} [s] for a frame, aborting acquisition.") + self._abort_due_to_error() + # Let this fall through so we still turn off illumination. Let the caller actually break out + # of the acquisition. + + # turn off the illumination if using software trigger + if self.liveController.trigger_mode == TriggerMode.SOFTWARE: + self.liveController.turn_off_illumination() + + +class MultiPointWorker(MultiPointWorkerBase): def __init__( self, scope: Microscope, @@ -95,35 +728,28 @@ def __init__( prewarmed_bp_values: Optional["BackpressureValues"] = None, run_state_writer=None, ): - self._log = squid.logging.get_logger(__class__.__name__) - self._timing = utils.TimingManager("MultiPointWorker Timer Manager") + super().__init__( + scope=scope, + live_controller=live_controller, + callbacks=callbacks, + abort_requested_fn=abort_requested_fn, + request_abort_fn=request_abort_fn, + run_state_writer=run_state_writer, + ) self._alignment_widget = alignment_widget # Optional AlignmentWidget for coordinate offset self._slack_notifier = slack_notifier # Optional SlackNotifier for notifications # Slack notification tracking counters self._timepoint_image_count = 0 - self._timepoint_fov_count = 0 self._timepoint_start_time = 0.0 self._acquisition_error_count = 0 self._laser_af_successes = 0 self._laser_af_failures = 0 self._current_z_offset_um: float = 0.0 - self.microscope: Microscope = scope - self.camera: AbstractCamera = scope.camera - self.microcontroller: Microcontroller = scope.low_level_drivers.microcontroller - self.stage: squid.abc.AbstractStage = scope.stage - self.piezo: Optional[PiezoStage] = scope.addons.piezo_stage - self.liveController = live_controller self.autofocusController: Optional[AutoFocusController] = auto_focus_controller self.laser_auto_focus_controller: Optional[LaserAutofocusController] = laser_auto_focus_controller self.objectiveStore: ObjectiveStore = objective_store - self.callbacks: MultiPointControllerFunctions = callbacks - self.abort_requested_fn: Callable[[], bool] = abort_requested_fn - self.request_abort_fn: Callable[[], None] = request_abort_fn - self._run_state = run_state_writer or squid.acquisition_state.NullRunStateWriter() - self._abort_cause = None # set to "error" by auto-abort paths (timeout / failed jobs) - self.end_reason: Optional[str] = None # set in run()'s finally; read by the controller self.NZ = acquisition_parameters.NZ self.deltaZ = acquisition_parameters.deltaZ @@ -143,15 +769,7 @@ def __init__( self.selected_configurations = acquisition_parameters.selected_configurations # Pre-compute acquisition metadata that remains constant throughout the run. - try: - pixel_factor = self.objectiveStore.get_pixel_size_factor() - sensor_pixel_um = self.camera.get_pixel_size_binned_um() - if pixel_factor is not None and sensor_pixel_um is not None: - self._pixel_size_um = float(pixel_factor) * float(sensor_pixel_um) - else: - self._pixel_size_um = None - except Exception: - self._pixel_size_um = None + self._pixel_size_um = compute_pixel_size_um(self.objectiveStore, self.camera) self._time_increment_s = self.dt if self.Nt > 1 and self.dt > 0 else None self._physical_size_z_um = abs(self.deltaZ) * 1000 if self.NZ > 1 else None self.timestamp_acquisition_started = acquisition_parameters.acquisition_start_time @@ -168,7 +786,7 @@ def __init__( physical_size_y_um=self._pixel_size_um, ) - self.time_point = 0 + self.time_point = 0 # also set in base __init__; re-set here for clarity self.af_fov_count = 0 self.num_fovs = 0 self.total_scans = 0 @@ -178,38 +796,23 @@ def __init__( ) self.scan_region_coords_mm = acquisition_parameters.scan_position_information.scan_region_coords_mm self.scan_region_names = acquisition_parameters.scan_position_information.scan_region_names - self.z_stacking_config = acquisition_parameters.z_stacking_config # default 'from bottom' - self.z_range = acquisition_parameters.z_range - - self.crop = SEGMENTATION_CROP - - self.t_dpc = [] - self.t_inf = [] - self.t_over = [] - - self.count = 0 - - self.merged_image = None - self.image_count = 0 - - # This is for keeping track of whether or not we have the last image we tried to capture. - # NOTE(imo): Once we do overlapping triggering, we'll want to keep a queue of images we are expecting. - # For now, this is an improvement over blocking immediately while waiting for the next image! - self._ready_for_next_trigger = threading.Event() - # Set this to true so that the first frame capture can proceed. - self._ready_for_next_trigger.set() - # This is cleared when the image callback is no longer processing an image. If true, an image is still - # in flux and we need to make sure the object doesn't disappear. - self._image_callback_idle = threading.Event() - self._image_callback_idle.set() - # This is protected by the threading event above (aka set after clear, take copy before set) - self._current_capture_info: Optional[CaptureInfo] = None - # This is only touched via the image callback path. Don't touch it outside of there! - self._current_round_images = {} + self.z_stacking_config = acquisition_parameters.z_stacking_config # default 'from bottom' + self.z_range = acquisition_parameters.z_range + + self.crop = SEGMENTATION_CROP + + self.t_dpc = [] + self.t_inf = [] + self.t_over = [] + + self.count = 0 + + self.merged_image = None + # image_count, the trigger-ready/idle events, and capture-info bookkeeping + # are initialized in MultiPointWorkerBase.__init__. self.skip_saving = acquisition_parameters.skip_saving - # The settings in effect now, for the whole acquisition. - self._save_settings = SaveSettings() + # self._save_settings is initialized in MultiPointWorkerBase.__init__. file_saving_option = self._save_settings.file_saving_option use_ome_tiff = file_saving_option == FileSavingOption.OME_TIFF use_zarr_v3 = file_saving_option == FileSavingOption.ZARR_V3 @@ -242,25 +845,13 @@ def __init__( # Initialize backpressure controller for throttling acquisition when queue fills up. # If pre-warmed values are provided, use them for consistent tracking with the # pre-warmed job runner. Otherwise, BackpressureController creates its own values. - bp_kwargs = { - "max_jobs": control._def.ACQUISITION_MAX_PENDING_JOBS, - "max_mb": control._def.ACQUISITION_MAX_PENDING_MB, - "timeout_s": control._def.ACQUISITION_THROTTLE_TIMEOUT_S, - "enabled": control._def.ACQUISITION_THROTTLING_ENABLED, - } - if prewarmed_bp_values is not None: - bp_kwargs["bp_values"] = prewarmed_bp_values - self._backpressure = BackpressureController(**bp_kwargs) + self._backpressure = self._create_backpressure(prewarmed_bp_values) # For now, use 1 runner per job class. There's no real reason/rationale behind this, though. The runners # can all run any job type. But 1 per is a reasonable arbitrary arrangement while we don't have a lot # of job types. If we have a lot of custom jobs, this could cause problems via resource hogging. - self._job_runners: List[Tuple[Type[Job], JobRunner]] = [] self._log.info(f"Acquisition.USE_MULTIPROCESSING = {Acquisition.USE_MULTIPROCESSING}") - # Get the current log file path to share with subprocess workers - log_file_path = squid.logging.get_current_log_file_path() - # Build ZarrWriterInfo if using ZARR_V3 format # Output structure depends on acquisition type and settings: # - HCS (wells): {experiment_path}/plate.ome.zarr/{row}/{col}/{fov}/0 (5D per FOV, OME-NGFF compliant) @@ -279,15 +870,9 @@ def __init__( region_fov_counts[str(region_id)] = len(coords) # Extract channel metadata for zarr output - channel_names = [cfg.name for cfg in self.selected_configurations] - channel_colors = [cfg.display_color for cfg in self.selected_configurations] - - # Get wavelengths from illumination config - channel_wavelengths = [] - illumination_config = self.microscope.config_repo.get_illumination_config() - for cfg in self.selected_configurations: - wavelength = cfg.get_illumination_wavelength(illumination_config) if illumination_config else None - channel_wavelengths.append(wavelength) + channel_names, channel_colors, channel_wavelengths = self._channel_zarr_metadata( + self.selected_configurations + ) zarr_writer_info = ZarrWriterInfo( base_path=self.experiment_path, @@ -313,72 +898,32 @@ def __init__( self._log.info(f"ZARR_V3 output: {mode_str}, base path: {self.experiment_path}") # Zarr is saved in this process; the TIFF formats keep the save subprocess. - # Use the pre-warmed subprocess if available, otherwise create new ones. - # IMPORTANT: Only use the pre-warmed runner if BOTH runner AND backpressure values - # are available. Using a runner without matching backpressure values would cause - # the BackpressureController to track different counters than the JobRunner. - can_use_prewarmed = prewarmed_job_runner is not None and prewarmed_bp_values is not None - used_prewarmed = False - for job_class in job_classes: + # Use the pre-warmed job runner if available (first job class only), + # otherwise create new ones; see _adopt_or_create_job_runner for the + # pairing/readiness rules. + for i, job_class in enumerate(job_classes): job_runner = None if job_class is SaveZarrJob: job_runner = self._create_in_process_zarr_runner(zarr_writer_info) - if can_use_prewarmed and not used_prewarmed: + if i == 0 and prewarmed_job_runner is not None: # Nothing here will use the subprocess the controller handed over. self._log.info("Zarr v3 saves in-process; releasing the pre-warmed save subprocess") try: prewarmed_job_runner.shutdown(timeout_s=1.0) except Exception as e: self._log.error(f"Error shutting down the unused pre-warmed runner: {e}") - can_use_prewarmed = False elif Acquisition.USE_MULTIPROCESSING: - # Try to use pre-warmed runner for the first job class - if can_use_prewarmed and not used_prewarmed: - if prewarmed_job_runner.is_ready(): - self._log.info(f"Using pre-warmed job runner for {job_class.__name__} jobs") - job_runner = prewarmed_job_runner - # Configure it with current acquisition settings - job_runner.set_acquisition_info(self.acquisition_info) - if zarr_writer_info: - job_runner.set_zarr_writer_info(zarr_writer_info) - used_prewarmed = True - else: - self._log.warning( - f"Pre-warmed job runner not ready (possibly hung during warmup), " - f"shutting it down and creating new one for {job_class.__name__}" - ) - # Shutdown the hung pre-warmed runner to avoid resource leak - try: - prewarmed_job_runner.shutdown(timeout_s=1.0) - except Exception as e: - self._log.error(f"Error shutting down hung pre-warmed runner: {e}") - # Don't try to use pre-warmed runner again for subsequent job classes - can_use_prewarmed = False - - if job_runner is None: - self._log.info(f"Creating job runner for {job_class.__name__} jobs") - job_runner = control.core.job_processing.JobRunner( - self.acquisition_info, - cleanup_stale_ome_files=use_ome_tiff, - log_file_path=log_file_path, - # Pass backpressure shared values for cross-process tracking - bp_pending_jobs=self._backpressure.pending_jobs_value, - bp_pending_bytes=self._backpressure.pending_bytes_value, - bp_capacity_event=self._backpressure.capacity_event, - # Pass zarr writer info for ZARR_V3 format - zarr_writer_info=zarr_writer_info, - ) - job_runner.start() - # Subprocess starts warming up in background - don't block here - + job_runner = self._adopt_or_create_job_runner( + prewarmed_job_runner if i == 0 else None, + prewarmed_bp_values if i == 0 else None, + zarr_writer_info, + cleanup_stale_ome_files=use_ome_tiff, + job_name=job_class.__name__, + ) self._job_runners.append((job_class, job_runner)) self._abort_on_failed_job = abort_on_failed_jobs self._first_job_dispatched = False # Track if we've waited for subprocess warmup - def update_use_piezo(self, value): - self.use_piezo = value - self._log.info(f"MultiPointWorker: updated use_piezo to {value}") - def _is_well_based_acquisition(self) -> bool: """Check if regions represent a valid well-based acquisition. @@ -446,50 +991,8 @@ def _is_well_based_acquisition(self) -> bool: ) return True - def _create_in_process_zarr_runner(self, zarr_writer_info: Optional[ZarrWriterInfo]) -> InProcessZarrRunner: - """Zarr is saved in this process (see in_process_zarr_runner.py); the runner shares our backpressure counters.""" - runner = InProcessZarrRunner( - zarr_writer_info=zarr_writer_info, - bp_values=( - self._backpressure.pending_jobs_value, - self._backpressure.pending_bytes_value, - self._backpressure.capacity_event, - ), - ) - runner.start() - return runner - - def _abort_due_to_error(self) -> None: - """Abort the run due to an internal error (vs a user abort). - - The worker only ever aborts itself on error conditions; user aborts arrive - via the external abort flag. Tagging the cause here lets _compute_end_reason - classify the end as "error" instead of "user_abort". - """ - self._abort_cause = "error" - self.request_abort_fn() - - def _run_state_beat(self) -> None: - self._run_state.beat( - { - "timepoint": self.time_point, - "fov": self._timepoint_fov_count, - "images": self.image_count, - } - ) - - def _compute_end_reason(self) -> str: - if self._run_state_fatal: - return "error" - if self.abort_requested_fn(): - return "error" if self._abort_cause == "error" else "user_abort" - if self._acquisition_error_count > 0: - return "completed_with_errors" - return "completed" - def run(self): this_image_callback_id = None - self._run_state_fatal = False try: start_time = time.perf_counter_ns() self.camera.start_streaming() @@ -567,7 +1070,7 @@ def run(self): self._abort_due_to_error() except Exception as e: self._log.exception(e) - self._run_state_fatal = True + self._abort_due_to_error() # the run ends as "error" and the stores seal aborted raise finally: # We do this above, but there are some paths that skip the proper end of the acquisition so make @@ -580,17 +1083,8 @@ def run(self): self._finish_jobs() # Determine why the acquisition ended (drives the watchdog + the in-process finish msg). - reason = self.end_reason = self._compute_end_reason() total_duration = time.time() - self.timestamp_acquisition_started - self._run_state.end( - reason, - { - "total_images": self.image_count, - "total_timepoints": self.time_point, - "total_duration_seconds": total_duration, - "errors_encountered": self._acquisition_error_count, - }, - ) + reason = self._end_run_state(total_duration) # Send Slack acquisition finished notification via callback (ensures ordering with timepoint notifications) if self._slack_notifier is not None: @@ -655,109 +1149,6 @@ def _wait_for_laser_engine(self) -> None: f"while waiting on channel(s) {channels}; aborting acquisition" ) - def _wait_for_outstanding_callback_images(self): - # If there are outstanding frames, wait for them to come in. - self._log.info("Waiting for any outstanding frames.") - if not self._ready_for_next_trigger.wait(self._frame_wait_timeout_s()): - self._log.warning("Timed out waiting for the last outstanding frames at end of acquisition!") - - if not self._image_callback_idle.wait(self._frame_wait_timeout_s()): - self._log.warning("Timed out waiting for the last image to process!") - - # No matter what, set the flags so things can continue - self._ready_for_next_trigger.set() - self._image_callback_idle.set() - - def _finish_jobs(self, timeout_s=10): - # Drain and summarize all currently available job results before waiting for completion - self._summarize_runner_outputs(drain_all=True) - - active_runners = [ - (job_class, job_runner) for job_class, job_runner in self._job_runners if job_runner is not None - ] - - self._log.info(f"Waiting for jobs to finish on {len(active_runners)} job runners before shutting them down...") - timeout_time = time.time() + timeout_s - - def timed_out(): - return time.time() > timeout_time - - def time_left(): - return max(timeout_time - time.time(), 0) - - # Wait for all pending jobs across all runners (round-robin to avoid blocking on one) - while not timed_out(): - any_pending = False - for job_class, job_runner in active_runners: - if job_runner.has_pending(): - any_pending = True - break - if not any_pending: - break - # Process any available results while waiting - self._summarize_runner_outputs(drain_all=True) - time.sleep(0.1) - else: - # Timed out - kill any runners that still have pending jobs - abandoned = False - for job_class, job_runner in active_runners: - if job_runner.has_pending(): - self._log.error( - f"Timed out after {timeout_s} [s] waiting for jobs to finish. Pending jobs for {job_class.__name__} abandoned!!!" - ) - job_runner.kill() - abandoned = True - if abandoned: - # Data is missing, so the run ends as an error (and the Zarr stores are sealed as aborted). - self._abort_due_to_error() - - # Drain results before shutdown (a failed job seen here aborts the run too, via _summarize_job_result) - self._summarize_runner_outputs(drain_all=True) - - # Shut down the save subprocesses in parallel (in background to avoid blocking on subprocess termination); - # the in-process Zarr runner is shut down inline below, because its shutdown seals the stores. - # Using daemon threads is safe here because: - # 1. All jobs are complete and results are already drained - # 2. The subprocess termination is best-effort cleanup only - # 3. If app exits before threads complete, OS will terminate subprocesses anyway - # 4. This prevents slow subprocess termination from blocking acquisition completion - log = self._log # Capture for closure - - def shutdown_runner(job_runner, timeout): - try: - job_runner.shutdown(timeout) - except Exception as e: - log.error(f"Error shutting down job runner in background: {e}") - - self._log.info("Shutting down job runners...") - remaining_time = time_left() - # A user Stop, an error abort, or a crash out of run(): the store must not claim completeness. - aborted = bool(self.abort_requested_fn()) or self._run_state_fatal - for job_class, job_runner in active_runners: - if isinstance(job_runner, InProcessZarrRunner): - # Its shutdown seals the stores (aborted ones as aborted); do it here so the - # acquisition is not reported finished before the metadata says so. - try: - if not job_runner.shutdown(remaining_time, aborted=aborted): - self._abort_due_to_error() # a write was still in flight: the stores were sealed aborted - except Exception as e: - log.error(f"Error shutting down the in-process Zarr runner: {e}") - continue - t = threading.Thread(target=shutdown_runner, args=(job_runner, remaining_time), daemon=True) - t.start() - - # Final drain of all output queues (should be empty, but check anyway) - self._summarize_runner_outputs(drain_all=True) - - # Release backpressure resources now that all jobs are complete - try: - self._backpressure.close() - except Exception as e: - self._log.error(f"Error closing backpressure controller: {e}") - - def wait_till_operation_is_completed(self): - self.microcontroller.wait_till_operation_is_completed() - def run_single_time_point(self): try: start = time.time() @@ -839,131 +1230,48 @@ def initialize_coordinates_dataframe(self): def update_coordinates_dataframe(self, region_id, z_level, pos: squid.abc.Pos, fov=None): base_data = { "z_level": [z_level], - "x (mm)": [pos.x_mm], - "y (mm)": [pos.y_mm], - "z (um)": [pos.z_mm * 1000], - "time": [datetime.now().strftime("%Y-%m-%d_%H-%M-%S.%f")], - } - piezo_data = {"z_piezo (um)": [self.z_piezo_um]} if self.use_piezo else {} - - new_row = pd.DataFrame({"region": [region_id], "fov": [fov], **base_data, **piezo_data}) - - self.coordinates_pd = pd.concat([self.coordinates_pd, new_row], ignore_index=True) - - def move_to_coordinate(self, coordinate_mm, region_id, fov): - x_mm = coordinate_mm[0] - y_mm = coordinate_mm[1] - - if self._alignment_widget is not None and self._alignment_widget.has_offset: - x_mm, y_mm = self._alignment_widget.apply_offset(x_mm, y_mm) - self._log.info( - f"moving to coordinate ({x_mm:.4f}, {y_mm:.4f}) " - f"[original: ({coordinate_mm[0]:.4f}, {coordinate_mm[1]:.4f}), offset applied]" - ) - else: - self._log.info(f"moving to coordinate {coordinate_mm}") - - self.stage.move_x_to(x_mm) - self._sleep(SCAN_STABILIZATION_TIME_MS_X / 1000) - - self.stage.move_y_to(y_mm) - self._sleep(SCAN_STABILIZATION_TIME_MS_Y / 1000) - - # check if z is included in the coordinate - if (self.do_reflection_af or self.do_autofocus) and self.time_point > 0: - if (region_id, fov) in self._last_time_point_z_pos: - last_z_mm = self._last_time_point_z_pos[(region_id, fov)] - self.move_to_z_level(last_z_mm) - self._log.info(f"Moved to last z position {last_z_mm} [mm]") - return - else: - self._log.warning(f"No last z position found for region {region_id}, fov {fov}") - if len(coordinate_mm) == 3: - z_mm = coordinate_mm[2] - self.move_to_z_level(z_mm) - - def move_to_z_level(self, z_mm): - self._log.debug("moving z") - self.stage.move_z_to(z_mm) - self._sleep(SCAN_STABILIZATION_TIME_MS_Z / 1000) - - def _summarize_runner_outputs(self, drain_all: bool = False) -> SummarizeResult: - """Process job results from output queues. - - Args: - drain_all: If True, process ALL available results. If False, process at most one per queue. - - Returns: - SummarizeResult with none_failed and had_results. - """ - none_failed = True - had_results = False - for job_class, job_runner in self._job_runners: - if job_runner is None: - continue - out_queue = job_runner.output_queue() - if out_queue is None: - # Queue was cleared during shutdown - continue - if not drain_all and not job_runner.is_alive() and job_runner.has_pending(): - self._log.error( - f"{job_class.__name__} save subprocess exited (exit code {getattr(job_runner, 'exitcode', None)}) " - f"with jobs pending; aborting the acquisition" - ) - self._abort_due_to_error() - while True: - try: - job_result: JobResult = out_queue.get_nowait() - none_failed = none_failed and self._summarize_job_result(job_result) - had_results = True - if not drain_all: - break # Only process one result per queue if not draining - except queue.Empty: - break - except ValueError: - # Queue was closed during shutdown - nothing more to drain - break + "x (mm)": [pos.x_mm], + "y (mm)": [pos.y_mm], + "z (um)": [pos.z_mm * 1000], + "time": [datetime.now().strftime("%Y-%m-%d_%H-%M-%S.%f")], + } + piezo_data = {"z_piezo (um)": [self.z_piezo_um]} if self.use_piezo else {} - return SummarizeResult(none_failed=none_failed, had_results=had_results) + new_row = pd.DataFrame({"region": [region_id], "fov": [fov], **base_data, **piezo_data}) - def _summarize_job_result(self, job_result: JobResult) -> bool: - """ - Prints a summary, then returns True if the result was successful or False otherwise. - """ - if job_result.exception is not None: - self._log.error(f"Error while running job {job_result.job_id}: {job_result.exception}") - self._acquisition_error_count += 1 - if self._abort_on_failed_job: - self._log.error("A save job failed, aborting acquisition because abort_on_failed_job=True") - self._abort_due_to_error() + self.coordinates_pd = pd.concat([self.coordinates_pd, new_row], ignore_index=True) - # Send Slack error notification - if self._slack_notifier is not None: - try: - context = {"job_id": job_result.job_id} - self._slack_notifier.notify_error( - str(job_result.exception), - context, - ) - except Exception as e: - self._log.warning(f"Failed to send Slack error notification: {e}") - return False + def move_to_coordinate(self, coordinate_mm, region_id, fov): + x_mm = coordinate_mm[0] + y_mm = coordinate_mm[1] + + if self._alignment_widget is not None and self._alignment_widget.has_offset: + x_mm, y_mm = self._alignment_widget.apply_offset(x_mm, y_mm) + self._log.info( + f"moving to coordinate ({x_mm:.4f}, {y_mm:.4f}) " + f"[original: ({coordinate_mm[0]:.4f}, {coordinate_mm[1]:.4f}), offset applied]" + ) else: - self._log.info(f"Got result for job {job_result.job_id}, it completed!") - # Handle ZarrWriteResult - notify viewer that frame is written - if isinstance(job_result.result, ZarrWriteResult): - r = job_result.result - self.callbacks.signal_zarr_frame_written(r.fov, r.time_point, r.z_index, r.channel_name, r.region_idx) - return True + self._log.info(f"moving to coordinate {coordinate_mm}") - def _create_job(self, job_class: Type[Job], info: CaptureInfo, image: np.ndarray) -> Optional[Job]: - """Create a job instance for the given job class. + self.stage.move_x_to(x_mm) + self._sleep(SCAN_STABILIZATION_TIME_MS_X / 1000) - Returns None if the job should be skipped. - """ - return job_class( - capture_info=info, capture_image=JobImage(image_array=image), save_settings=self._save_settings - ) + self.stage.move_y_to(y_mm) + self._sleep(SCAN_STABILIZATION_TIME_MS_Y / 1000) + + # check if z is included in the coordinate + if (self.do_reflection_af or self.do_autofocus) and self.time_point > 0: + if (region_id, fov) in self._last_time_point_z_pos: + last_z_mm = self._last_time_point_z_pos[(region_id, fov)] + self.move_to_z_level(last_z_mm) + self._log.info(f"Moved to last z position {last_z_mm} [mm]") + return + else: + self._log.warning(f"No last z position found for region {region_id}, fov {fov}") + if len(coordinate_mm) == 3: + z_mm = coordinate_mm[2] + self.move_to_z_level(z_mm) def _emit_plate_layout(self, image: np.ndarray) -> None: """Emit plate_view_init for the unified mosaic widget on the first image. @@ -1216,11 +1524,6 @@ def acquire_at_position(self, region_id, current_path, fov): # Increment FOV counter for Slack notification stats self._timepoint_fov_count += 1 - def _select_config(self, config: AcquisitionChannel): - self.callbacks.signal_current_configuration(config) - self.liveController.set_microscope_mode(config) - self.wait_till_operation_is_completed() - def perform_autofocus(self, region_id, fov): if not self.do_reflection_af: # contrast-based AF; perform AF only if when not taking z stack or doing z stack from center @@ -1410,186 +1713,6 @@ def _log_ignored_offsets(self) -> None: reason = "laser AF off" if not self.do_reflection_af else "'Apply channel offset' unchecked" self._log.info(f"[multi-point] {reason} — ignoring non-zero z-offsets on channels: [{summary}]") - def _image_callback(self, camera_frame: CameraFrame): - try: - if self._ready_for_next_trigger.is_set(): - self._log.warning( - "Got an image in the image callback, but we didn't send a trigger. Ignoring the image." - ) - return - - self._image_callback_idle.clear() - with self._timing.get_timer("_image_callback"): - self._log.debug(f"In Image callback for frame_id={camera_frame.frame_id}") - info = self._current_capture_info - self._current_capture_info = None - - self._ready_for_next_trigger.set() - if not info: - self._log.error("In image callback, no current capture info! Something is wrong. Aborting.") - self._abort_due_to_error() - return - - image = camera_frame.frame - if not camera_frame or image is None: - self._log.warning("image in frame callback is None. Something is really wrong, aborting!") - self._abort_due_to_error() - return - - # Increment image counter for Slack notification stats - self._timepoint_image_count += 1 - self.image_count += 1 - self._run_state_beat() - - with self._timing.get_timer("job creation and dispatch"): - # Wait for subprocess to be ready before first dispatch - if not self._first_job_dispatched: - for job_class, job_runner in self._job_runners: - if job_runner is not None: - t_wait_start = time.perf_counter() - if job_runner.wait_ready(timeout_s=10.0): - t_wait_end = time.perf_counter() - wait_ms = (t_wait_end - t_wait_start) * 1000 - if wait_ms > 10: # Only log if we actually had to wait - self._log.info(f"Job runner ready (waited {wait_ms:.0f}ms for subprocess)") - else: - self._log.warning(f"Job runner for {job_class.__name__} not ready after 10s") - self._first_job_dispatched = True - - for job_class, job_runner in self._job_runners: - job = self._create_job(job_class, info, image) - if job is None: - continue # Skip if job creation returns None (e.g., downsampled views disabled for this image) - if job_runner is not None: - if not job_runner.dispatch(job): - self._log.error("Failed to dispatch multiprocessing job!") - self._abort_due_to_error() - return - else: - try: - # In-process fallback (multiprocessing off) for the TIFF job classes; - # SaveZarrJob always has an InProcessZarrRunner. - # NOTE(imo): We don't have any way of people using results, so for now just - # grab and ignore it. - result = job.run() - except Exception: - self._log.exception("Failed to execute job, abandoning acquisition!") - self._abort_due_to_error() - return - - height, width = image.shape[:2] - # with self._timing.get_timer("crop_image"): - # image_to_display = utils.crop_image( - # image, - # round(width * self.display_resolution_scaling), - # round(height * self.display_resolution_scaling), - # ) - # Emit plate layout once on the first image so the unified mosaic - # widget can lay out the plate grid before tiles start arriving. - self._emit_plate_layout(image) - with self._timing.get_timer("image_to_display*.emit"): - self.callbacks.signal_new_image(camera_frame, info) - - finally: - self._image_callback_idle.set() - - def _frame_wait_timeout_s(self): - return (self.camera.get_total_frame_time() / 1e3) + 10 - - def acquire_camera_image( - self, config, file_ID: str, current_path: str, k: int, region_id: int, fov: int, config_idx: int - ): - self._select_config(config) - - # trigger acquisition (including turning on the illumination) and read frame - camera_illumination_time = self.camera.get_exposure_time() - if self.liveController.trigger_mode == TriggerMode.SOFTWARE: - self.liveController.turn_on_illumination() - self.wait_till_operation_is_completed() - camera_illumination_time = None - elif self.liveController.trigger_mode == TriggerMode.HARDWARE: - if "Fluorescence" in config.name and ENABLE_NL5 and NL5_USE_DOUT: - # TODO(imo): This used to use the "reset_image_ready_flag=False" on the read_frame, but oinly the toupcam camera implementation had the - # "reset_image_ready_flag" arg, so this is broken for all other cameras. Also this used to do some other funky stuff like setting internal camera flags. - # I am pretty sure this is broken! - self.microscope.addons.nl5.start_acquisition() - # This is some large timeout that we use just so as to not block forever - with self._timing.get_timer("_ready_for_next_trigger.wait"): - if not self._ready_for_next_trigger.wait(self._frame_wait_timeout_s()): - self._log.error("Frame callback never set _have_last_triggered_image callback! Aborting acquisition.") - self._abort_due_to_error() - return - - # Backpressure check AFTER previous frame dispatched, BEFORE next trigger - # This is when we know the previous image's jobs have been dispatched (and counters incremented) - if self._backpressure.should_throttle(): - with self._timing.get_timer("backpressure.wait_for_capacity"): - got_capacity = self._backpressure.wait_for_capacity() - if not got_capacity: - self._log.error( - f"Backpressure timeout - disk I/O cannot keep up. Stats: {self._backpressure.get_stats()}" - ) - - with self._timing.get_timer("get_ready_for_trigger re-check"): - # This should be a noop - we have the frame already. Still, check! - while not self.camera.get_ready_for_trigger(): - self._sleep(0.001) - - self._ready_for_next_trigger.clear() - with self._timing.get_timer("current_capture_info ="): - # Even though the capture time will be slightly after this, we need to capture and set the capture info - # before the trigger to be 100% sure the callback doesn't stomp on it. - # NOTE(imo): One level up from acquire_camera_image, we have acquire_pos. We're careful to use that as - # much as we can, but don't use it here because we'd rather take the position as close as possible to the - # real capture time for the image info. Ideally we'd use this position for the caller's acquire_pos as well. - current_capture_info = CaptureInfo( - position=self.stage.get_pos(), - z_index=k, - capture_time=time.time(), - z_piezo_um=(self.z_piezo_um if self.use_piezo else None), - configuration=config, - save_directory=current_path, - file_id=file_ID, - region_id=region_id, - fov=fov, - configuration_idx=config_idx, - time_point=self.time_point, - ) - self._current_capture_info = current_capture_info - with self._timing.get_timer("send_trigger"): - self.camera.send_trigger(illumination_time=camera_illumination_time) - - with self._timing.get_timer("exposure_time_done_sleep_hw or wait_for_image_sw"): - if self.liveController.trigger_mode == TriggerMode.HARDWARE: - exposure_done_time = time.time() + self.camera.get_total_frame_time() / 1e3 - # Even though we can do overlapping triggers, we want to make sure that we don't move before our exposure - # is done. So we still need to at least sleep for the total frame time corresponding to this exposure. - self._sleep(max(0.0, exposure_done_time - time.time())) - else: - # In SW trigger mode (or anything not HARDWARE mode), there's indeterminism in the trigger timing. - # To overcome this, just wait until the frame for this capture actually comes into the image - # callback. That way we know we have it. This also helps by making sure the illumination for this - # frame is on from before the trigger until after we get the frame (which guarantees it will be on - # for the full exposure). - # - # If we wait for longer than 5x the exposure + 2 seconds, abort the acquisition because something is - # wrong. - non_hw_frame_timeout = 5 * self.camera.get_total_frame_time() / 1e3 + 2 - if not self._ready_for_next_trigger.wait(non_hw_frame_timeout): - self._log.error("Timed out waiting {non_hw_frame_timeout} [s] for a frame, aborting acquisition.") - self._abort_due_to_error() - # Let this fall through so we still turn off illumination. Let the caller actually break out - # of the acquisition. - - # turn off the illumination if using software trigger - if self.liveController.trigger_mode == TriggerMode.SOFTWARE: - self.liveController.turn_off_illumination() - - def _sleep(self, sec): - time_to_sleep = max(sec, 1e-6) - # self._log.debug(f"Sleeping for {time_to_sleep} [s]") - time.sleep(time_to_sleep) - def acquire_rgb_image(self, config, file_ID, current_path, k, region_id, fov): # go through the channels rgb_channels = ["BF LED matrix full_R", "BF LED matrix full_G", "BF LED matrix full_B"] diff --git a/software/control/core/record_zstack_controller.py b/software/control/core/record_zstack_controller.py new file mode 100644 index 000000000..1c9f294e5 --- /dev/null +++ b/software/control/core/record_zstack_controller.py @@ -0,0 +1,431 @@ +import math +import os +import time +from dataclasses import dataclass, field +from threading import Event, Thread +from typing import Dict, List, Optional + +import yaml + +import squid.logging +from control.core.acquisition_setup import compute_pixel_size_um, start_run_state_writer +from control.models.acquisition_config import AcquisitionChannel +from control.utils import serialize_for_yaml as _serialize_for_yaml + +log = squid.logging.get_logger("RecordZStackController") + + +def frame_count(fps: float, duration_s: float) -> int: + return int(round(fps * duration_s)) + + +def validate_record_zstack_params( + *, + base_path: str, + selected_well_count: int, + recording_enabled: bool, + fps: float, + duration_s: float, + recording_channel_name: Optional[str], + zstack_enabled: bool, + z_min: float, + z_max: float, + step: float, + zstack_channel_names: List[str], + use_laser_af: bool, + laser_af_has_reference: bool, + recording_nz: int = 1, + recording_dz_um: float = 1.0, + recording_exposure_ms: Optional[float] = None, + zstack_exposures_ms: Optional[Dict[str, float]] = None, +) -> Optional[str]: + """Return an error string describing the first validation failure, or None if valid. + + Pure business rules for a record/z-stack acquisition, kept beside the + parameter dataclass and geometry helpers they validate (the widget's + ``validate()`` delegates here; no Qt required). + """ + if not base_path: + return "Base path must be set before starting acquisition." + if selected_well_count < 1: + return "At least one well must be selected." + if not recording_enabled and not zstack_enabled: + return "At least one phase (Recording or Z-Stack) must be enabled." + if recording_enabled: + if fps <= 0: + return "Recording FPS must be greater than 0." + if duration_s <= 0: + return "Recording duration must be greater than 0." + if frame_count(fps, duration_s) < 1: + return f"Recording: fps×duration yields 0 frames (fps={fps}, duration={duration_s}s). Increase one or both." + if not recording_channel_name: + return "A channel must be chosen for the Recording phase." + if recording_nz < 1: + return "Recording Nz must be at least 1." + if recording_nz > 1 and recording_dz_um <= 0: + return "Recording dz must be > 0 when Nz > 1." + if recording_exposure_ms is not None and recording_exposure_ms <= 0: + return f"Recording: exposure of {recording_channel_name} must be greater than 0 ms." + if zstack_enabled: + if z_max <= z_min: + return "Z-Stack: z_max must be greater than z_min." + if step <= 0: + return "Z-Stack: step must be greater than 0." + if not zstack_channel_names: + return "Z-Stack: at least one channel must be selected." + for name, exposure_ms in (zstack_exposures_ms or {}).items(): + if exposure_ms <= 0: + return f"Z-Stack: exposure of {name} must be greater than 0 ms." + if use_laser_af and not laser_af_has_reference: + return "Laser AF is enabled but no reference position has been captured." + return None + + +def zstack_plane_count(z_min_um: float, z_max_um: float, step_um: float) -> int: + if step_um <= 0 or z_max_um < z_min_um: + raise ValueError("require step>0 and z_max>=z_min") + # epsilon absorbs float representation error so e.g. 6.0/1.0 -> 5.999... still floors to 6 + return int(math.floor((z_max_um - z_min_um) / step_um + 1e-9)) + 1 + + +def zstack_offsets_um(z_min_um: float, z_max_um: float, step_um: float) -> List[float]: + return [round(z_min_um + i * step_um, 6) for i in range(zstack_plane_count(z_min_um, z_max_um, step_um))] + + +def resolve_effective_fps(camera, requested_fps: float, exposure_time_ms: float) -> float: + """The rate a recording at *exposure_time_ms* will actually run at: *requested_fps* + bounded by ``camera.get_max_frame_rate(exposure_time_ms)`` (readout / exposure / + ROI-limited free-run maximum). A camera without the query, or one that fails it, + leaves the request unchanged. + """ + try: + achievable = float(camera.get_max_frame_rate(exposure_time_ms)) + except Exception: + return float(requested_fps) + if not achievable > 0: + return float(requested_fps) + return float(min(requested_fps, achievable)) + + +def recording_plane_offsets_um(bottom_um: float, nz: int, dz_um: float) -> List[float]: + """Offsets (µm, relative to the z reference) of the Nz recording planes. + + Plane j sits at bottom_um + j*dz_um. Nz=1 is the single-plane case and + ignores dz_um (the widget hides dz entirely for Nz=1). + """ + if nz < 1: + raise ValueError("require Nz >= 1") + if nz > 1 and dz_um <= 0: + raise ValueError("require dz > 0 when Nz > 1") + return [round(bottom_um + j * dz_um, 6) for j in range(nz)] + + +def _build_objective_info(objective_store, camera) -> dict: + """Build the informational `objective:` YAML section. + + Mirrors the dict multi_point_controller.py builds before calling + _save_acquisition_yaml, adapted to tolerate camera=None (the record widget's + Save-button path may not always have a live camera reference). + """ + current_objective = objective_store.current_objective + objective_dict = objective_store.objectives_dict.get(current_objective, {}) + + camera_binning = list(camera.get_binning()) if camera is not None else None + # compute_pixel_size_um tolerates camera=None (returns None on any failure). + pixel_size_um = compute_pixel_size_um(objective_store, camera) + + return { + "name": current_objective, + "magnification": objective_dict.get("magnification"), + "pixel_size_um": pixel_size_um, + "camera_binning": camera_binning, + } + + +def _save_record_zstack_yaml( + params: "RecordZStackAcquisitionParameters", + yaml_path: str, + scan_coordinates=None, + objective_info: dict = None, +) -> None: + """Save full record/z-stack acquisition settings to *yaml_path*. + + Mirrors multi_point_controller.py's _save_acquisition_yaml for the + wellplate/flexible widgets, but kept separate: this widget's shape + (recording phase with fps/duration/z-offset, z-stack as min/max/step, + two channel lists) doesn't fit the shared builder without polluting it + with fields the other two widgets have no reason to know about. + """ + yaml_dict = { + "acquisition": { + "experiment_id": params.experiment_id, + "start_time": time.strftime("%Y-%m-%d %H:%M:%S"), + "widget_type": "record_zstack", + "xy_mode": params.xy_mode, + }, + "objective": objective_info or {}, + "time_series": { + "nt": params.Nt, + "delta_t_s": params.dt_s, + }, + "autofocus": { + "laser_af": params.use_laser_af, + }, + "recording": { + "enabled": params.recording_enabled, + "channel": _serialize_for_yaml(params.recording_channel) if params.recording_channel else None, + "fps": params.fps, + "effective_fps": params.effective_fps, + "duration_s": params.duration_s, + "bottom_z_offset_um": params.recording_bottom_z_offset_um, + "nz": params.recording_Nz, + "dz_um": params.recording_dz_um, + }, + "z_stack": { + "enabled": params.zstack_enabled, + "channels": [_serialize_for_yaml(ch) for ch in params.zstack_channels], + "z_min_um": params.z_min_um, + "z_max_um": params.z_max_um, + "z_step_um": params.z_step_um, + }, + } + + if params.xy_mode == "Select Wells": + region_centers = scan_coordinates.region_centers if scan_coordinates is not None else {} + region_shapes = scan_coordinates.region_shapes if scan_coordinates is not None else {} + yaml_dict["wellplate_scan"] = { + "scan_size_mm": params.scan_size_mm, + "overlap_percent": params.overlap_percent, + "regions": [ + {"name": name, "center_mm": _serialize_for_yaml(center), "shape": region_shapes.get(name)} + for name, center in region_centers.items() + ], + } + + # Let OSError/yaml.YAMLError propagate: both real call sites already handle + # failures appropriately one level up -- run_acquisition()'s snapshot call site + # wraps this in try/except Exception: log.exception(...) so a failed settings + # snapshot never aborts a real acquisition, and the Save Settings button + # handler (_on_save_settings_clicked) wraps this in try/except Exception: + # QMessageBox.warning(...) so the user is told the save failed. Swallowing the + # error here made that button's warning dialog unreachable. + with open(yaml_path, "w", encoding="utf-8") as f: + f.write(f"# Record/Z-Stack Acquisition Parameters - {params.experiment_id}\n\n") + yaml.dump(yaml_dict, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + +@dataclass +class RecordZStackAcquisitionParameters: + base_path: str + experiment_id: str + Nt: int = 1 + dt_s: float = 0.0 + use_laser_af: bool = False + # recording phase + recording_enabled: bool = False + recording_channel: Optional[AcquisitionChannel] = None + fps: float = 10.0 + duration_s: float = 1.0 + # Rate the recording will actually be made at: min(fps, what the camera can + # deliver at the recording exposure in its current binning / ROI). Resolved + # by RecordZStackController.run_acquisition BEFORE the run starts (so every + # FOV and timepoint uses one known rate and acquisition.yaml records it); the + # widget shows the same number in the confirmation dialog. + effective_fps: Optional[float] = None + # Recording planes: plane j at z_ref + recording_bottom_z_offset_um + j*recording_dz_um. + recording_bottom_z_offset_um: float = 0.0 + recording_Nz: int = 1 + recording_dz_um: float = 1.0 + # z-stack phase + zstack_enabled: bool = False + zstack_channels: List[AcquisitionChannel] = field(default_factory=list) + z_min_um: float = -3.0 + z_max_um: float = 3.0 + z_step_um: float = 1.0 + # XY / well-selection state (needed to save a full reusable settings snapshot) + xy_mode: str = "Select Wells" + scan_size_mm: float = 0.1 + overlap_percent: float = 10.0 + + +class RecordZStackController: + """Controller that spawns RecordZStackWorker on a daemon thread. + + Constructor arguments mirror MultiPointController's signature where the concept + overlaps. Call run_acquisition(params) with a fully-built + RecordZStackAcquisitionParameters object (as the widget does via build_parameters()). + """ + + def __init__( + self, + microscope, + live_controller, + laser_autofocus_controller, + objective_store, + scan_coordinates, + callbacks, + display_frame_fn=None, + display_fps: float = 0.0, + ): + self._microscope = microscope + self._live_controller = live_controller + self._laser_af = laser_autofocus_controller + self._objective_store = objective_store + self._scan_coordinates = scan_coordinates + self._callbacks = callbacks + self._display_frame_fn = display_frame_fn + self._display_fps = display_fps + + self._abort_event: Event = Event() + self._thread: Optional[Thread] = None + + # ---------------------------------------------------------------------- acquisition + + def acquisition_in_progress(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def request_abort(self) -> None: + """Signal the running worker to stop after the current FOV.""" + self._abort_event.set() + + def run_acquisition(self, params: RecordZStackAcquisitionParameters) -> None: + """Spawn the worker thread. + + *params* must be a fully-built RecordZStackAcquisitionParameters (as produced + by the widget's build_parameters() method). + """ + from control.core.acquisition_setup import create_experiment_dir + from control.core.record_zstack_worker import RecordZStackWorker + + if params.recording_enabled and params.recording_channel is None: + # The worker applies the recording channel (exposure) at every FOV; + # without a channel the z-stack phase's last channel would set the + # exposure for later FOVs and the resolved rate would be wrong. The + # widget always supplies one; reject the rest. + raise ValueError("recording_enabled requires a recording_channel") + if params.recording_enabled: + # Fix the recording rate now, before anything starts: the camera's + # free-run maximum at the recording exposure bounds the request, and + # every FOV / timepoint, the dataset size, time_increment_s and + # acquisition.yaml all use this one number. + exposure_ms = params.recording_channel.camera_settings.exposure_time_ms + params.effective_fps = resolve_effective_fps(self._microscope.camera, params.fps, exposure_ms) + if params.effective_fps < params.fps * (1 - 1e-6): + log.warning( + f"camera cannot deliver {params.fps:g} fps at {exposure_ms:g} ms exposure " + f"(max {params.effective_fps:.2f}); recording at {params.effective_fps:.2f} fps" + ) + else: + log.info(f"recording at {params.effective_fps:g} fps") + + # Resolve and create a timestamped unique output directory. + resolved_id, experiment_dir = create_experiment_dir(params.base_path, params.experiment_id) + params.experiment_id = resolved_id + + # Snapshot the acquisition settings into the experiment directory + # (mirrors MultiPointController.start_new_experiment) so the run is + # reproducible/auditable: acquisition_channels.yaml records objective + + # every channel used by either phase. + try: + # Dedupe by name (recording entry wins): channels are identified by + # name, so a channel used by both phases must not appear twice with + # different settings in the snapshot. + channels = [] + seen_names = set() + candidates = [] + if params.recording_enabled: + candidates.append(params.recording_channel) + if params.zstack_enabled: + candidates.extend(params.zstack_channels) + for ch in candidates: + if ch.name not in seen_names: + seen_names.add(ch.name) + channels.append(ch) + self._microscope.config_repo.save_acquisition_output( + output_dir=experiment_dir, + objective=self._objective_store.current_objective, + channels=channels, + confocal_mode=self._live_controller.is_confocal_mode(), + ) + except Exception: + log.exception("Failed to save acquisition settings snapshot to the experiment directory") + + # Full reusable settings snapshot (superset of acquisition_channels.yaml above, + # written alongside it — not a replacement; see design doc's "Snapshot files" decision). + try: + objective_info = _build_objective_info(self._objective_store, self._microscope.camera) + _save_record_zstack_yaml( + params, + os.path.join(experiment_dir, "acquisition.yaml"), + self._scan_coordinates, + objective_info, + ) + except Exception: + log.exception("Failed to save full record_zstack acquisition.yaml snapshot") + + # Collect scan coordinates: {region_id: [(x_mm, y_mm[, z_mm]), ...]} + scan_region_fov_coords = {} + if self._scan_coordinates is not None: + scan_region_fov_coords = dict(self._scan_coordinates.region_fov_coordinates) + + # Clear abort event for this run (thread-safe: Event.clear() is atomic). + # The small window between clear() and the worker thread starting is safe + # under the single-acquisition-at-a-time assumption enforced by the widget + # (toggle_acquisition checks acquisition_in_progress() before calling here). + self._abort_event.clear() + + # Acquisition watchdog breadcrumb, as MultiPointController writes it. channels x z are the + # z-stack images per FOV (the recording is a stream, not counted as images). + channels, z = ( + (len(params.zstack_channels), zstack_plane_count(params.z_min_um, params.z_max_um, params.z_step_um)) + if params.zstack_enabled + else (0, 0) + ) + run_state_writer = start_run_state_writer( + experiment_id=params.experiment_id, + output_path=experiment_dir, + timepoints=params.Nt, + region_fov_coords=scan_region_fov_coords, + channels=channels, + z=z, + ) + try: + worker = RecordZStackWorker( + scope=self._microscope, + live_controller=self._live_controller, + laser_auto_focus_controller=self._laser_af, + objective_store=self._objective_store, + params=params, + callbacks=self._callbacks, + abort_requested_fn=lambda: self._abort_event.is_set(), + request_abort_fn=self.request_abort, + scan_region_fov_coords=scan_region_fov_coords, + display_frame_fn=self._display_frame_fn, + display_fps=self._display_fps, + run_state_writer=run_state_writer, + ) + except Exception: + # No worker will end the breadcrumb; close it so the watchdog does not read a hang. + run_state_writer.end("error", None) + raise + + self._thread = Thread(target=worker.run, name="RecordZStack-acquisition", daemon=True) + self._thread.start() + + def join(self, timeout: Optional[float] = None) -> None: + """Wait for the acquisition thread to finish (useful in tests and scripts).""" + if self._thread is not None: + self._thread.join(timeout=timeout) + + # ---------------------------------------------------------------------- cleanup + + def close(self, timeout_s: float = 5.0) -> None: + """Abort any running acquisition.""" + if self.acquisition_in_progress(): + self._abort_event.set() + if self._thread is not None: + self._thread.join(timeout=timeout_s) + if self._thread.is_alive(): + log.warning(f"RecordZStack acquisition thread did not stop within {timeout_s}s") + + self._thread = None diff --git a/software/control/core/record_zstack_worker.py b/software/control/core/record_zstack_worker.py new file mode 100644 index 000000000..5400b95e5 --- /dev/null +++ b/software/control/core/record_zstack_worker.py @@ -0,0 +1,735 @@ +"""RecordZStackWorker — per-FOV orchestration for "Record + Z-Stack" acquisitions. + +For each (time point, region, FOV) the worker: + 1. moves XY to the FOV, + 2. establishes a Z reference (laser AF if requested+available, else current Z), + 3. (optional) records a continuous high-fps stream to a per-FOV recording zarr, + 4. (optional) acquires a software-triggered z-stack saved via the inherited + ``SaveZarrJob`` dispatch path. + +The recording phase reuses the ``StreamingCapture`` primitive +(``ContinuousFrameSource`` + ``RecordingRouter`` + ``CountStop`` + +``RecordingWriter``). The z-stack phase reuses ``MultiPointWorkerBase``'s +shared single-frame capture + frame callback + job dispatch machinery, so the +z-stack is saved in this process by an ``InProcessZarrRunner`` behind the same +``BackpressureController`` ``MultiPointWorker`` uses. +""" + +import os +import time +from typing import Callable, Dict, List, Optional, Tuple + +import numpy as np + +import squid.logging +from control._def import ( + SCAN_STABILIZATION_TIME_MS_X, + SCAN_STABILIZATION_TIME_MS_Y, + SCAN_STABILIZATION_TIME_MS_Z, + TriggerMode, +) +from squid.abc import CameraAcquisitionMode +from control.core.acquisition_setup import compute_pixel_size_um +from control.core.multi_point_worker import MultiPointWorkerBase +from control.core.streaming_capture import ( + ContinuousFrameSource, + CountStop, + RecordingRouter, + RecordingWriter, + StreamingCapture, +) +from control.core.zarr_writer import ZarrAcquisitionConfig +from control.core.record_zstack_controller import ( + RecordZStackAcquisitionParameters, + frame_count, + recording_plane_offsets_um, + zstack_offsets_um, + zstack_plane_count, +) +from control.core.job_processing import ( + AcquisitionInfo, + SaveZarrJob, + ZarrWriterInfo, +) +from control.core.backpressure import BackpressureController + +log = squid.logging.get_logger("RecordZStackWorker") + + +class RecordZStackWorker(MultiPointWorkerBase): + """Per-FOV record-and-z-stack acquisition worker. + + Scan coords are supplied as ``{region_id: [(x_mm, y_mm[, z_mm]), ...]}``. + """ + + # How long the end of the run waits for a recording whose drain outlived its finalize() before + # declaring the store not final: the same budget finalize() itself had. + DRAIN_SETTLE_TIMEOUT_S = RecordingWriter.FINALIZE_TIMEOUT_S + + def __init__( + self, + scope, + live_controller, + laser_auto_focus_controller, + objective_store, + params: RecordZStackAcquisitionParameters, + callbacks, + abort_requested_fn: Callable[[], bool], + request_abort_fn: Callable[[], None], + scan_region_fov_coords: Dict[object, List[Tuple]], + display_frame_fn: Optional[Callable] = None, + display_fps: float = 0.0, + run_state_writer=None, + ): + super().__init__( + scope=scope, + live_controller=live_controller, + callbacks=callbacks, + abort_requested_fn=abort_requested_fn, + request_abort_fn=request_abort_fn, + run_state_writer=run_state_writer, + ) + + self.params = params + # Plain-callable live-preview tap (Qt-free; supplied by the GUI wrapper). + self._display_frame_fn = display_frame_fn + self._display_fps = display_fps + self.laser_af = laser_auto_focus_controller + self.objectiveStore = objective_store + self._scan: Dict[object, List[Tuple]] = scan_region_fov_coords or {} + + # Experiment output layout (mirrors MultiPointWorker.experiment_path). + self.base_path = params.base_path + self.experiment_ID = params.experiment_id + self.experiment_path = os.path.join(self.base_path or "", self.experiment_ID or "") + + # Z-stack channels drive the inherited SaveZarrJob path. + self.zstack_channels: List = list(params.zstack_channels or []) + self._NZ = ( + zstack_plane_count(params.z_min_um, params.z_max_um, params.z_step_um) if params.zstack_enabled else 1 + ) + + # Pre-compute acquisition-wide metadata (pixel size etc.) for zarr/job info. + self._pixel_size_um = compute_pixel_size_um(self.objectiveStore, self.camera) + + self._time_increment_s = params.dt_s if params.Nt > 1 and params.dt_s > 0 else None + self._physical_size_z_um = abs(params.z_step_um) if self._NZ > 1 else None + + # Build the z-stack job runner + backpressure (only when a z-stack will run). + # Mirrors MultiPointWorker.__init__: per-FOV 5D non-HCS zarr output. + self.acquisition_info = AcquisitionInfo( + total_time_points=params.Nt, + total_z_levels=self._NZ, + total_channels=len(self.zstack_channels), + channel_names=[c.name for c in self.zstack_channels], + experiment_path=self.experiment_path, + time_increment_s=self._time_increment_s, + physical_size_z_um=self._physical_size_z_um, + physical_size_x_um=self._pixel_size_um, + physical_size_y_um=self._pixel_size_um, + ) + + # Per-acquisition fixed geometry — compute once and reuse for every FOV + # (z-stack offsets and the recording frame shape don't change mid-run). + self._zstack_offsets: List[float] = ( + zstack_offsets_um(params.z_min_um, params.z_max_um, params.z_step_um) if params.zstack_enabled else [] + ) + # Probing captures a frame, so it happens in run() after live view stops. + self._frame_shape: Optional[Tuple[int, int, np.dtype]] = None + # Recordings whose drain thread outlived their finalize(): see _settle_draining_recordings. + self._draining_writers: List[RecordingWriter] = [] + # Achievable recording fps, probed once on the first FOV (the recording + # exposure is fixed, so the camera's clamp can't change between FOVs). + self._effective_fps: Optional[float] = None + # True when the camera reported it will deliver at (or below) the requested + # rate — it paces itself to the hint, or the request is at its free-run + # maximum — so every frame is wanted and the RecordingRouter fills slots + # sequentially instead of downsampling by arrival time. + # Set at the top of run(); _wait_for_dt paces timepoint STARTS from it. + self._acq_start_time: Optional[float] = None + + if params.zstack_enabled and self.zstack_channels: + self._setup_zstack_job_runner() + else: + # Still need a (disabled) backpressure controller so base methods that + # reference self._backpressure don't crash if ever reached. + self._backpressure = BackpressureController(enabled=False) + + # ------------------------------------------------------------------ setup + def _setup_zstack_job_runner(self) -> None: + self._backpressure = self._create_backpressure() + + # Channel metadata for zarr output. + channel_names, channel_colors, channel_wavelengths = self._channel_zarr_metadata(self.zstack_channels) + + # FOV counts per region (non-HCS per-FOV 5D output -> count only used for 6D). + region_fov_counts = {str(region_id): len(coords) for region_id, coords in self._scan.items()} + + zarr_writer_info = ZarrWriterInfo( + base_path=self.experiment_path, + t_size=self.params.Nt, + c_size=len(self.zstack_channels), + z_size=self._NZ, + is_hcs=False, + use_6d_fov=False, + region_fov_counts=region_fov_counts, + pixel_size_um=self._pixel_size_um, + z_step_um=self._physical_size_z_um, + time_increment_s=self._time_increment_s, + channel_names=channel_names, + channel_colors=channel_colors, + channel_wavelengths=channel_wavelengths, + ) + self._job_runners = [(SaveZarrJob, self._create_in_process_zarr_runner(zarr_writer_info))] + + # -------------------------------------------------------------------- run + def run(self): + """Top-level orchestration loop (runs on the acquisition thread). + + Recording manages its own CONTINUOUS streaming/callback via + ``ContinuousFrameSource``; the z-stack phase manages its own + software-trigger streaming + frame callback inside ``zstack()``. The + camera is left stopped between FOVs/phases, so this loop owns no camera + callback of its own. + """ + run_t0 = time.monotonic() + # Quiesce live view once for the whole acquisition (restored in finally). + was_live = bool(self.liveController.is_live) + # Capture pre-acquisition hardware state so the finally can put the + # camera/MCU/LiveController back the way the user had them: both phases + # change the trigger mode, and every z-stack channel apply overwrites + # the current channel configuration (exposure/gain/illumination). + prev_trigger_mode = self.liveController.trigger_mode + prev_configuration = self.liveController.currentConfiguration + if was_live: + try: + self.liveController.stop_live() + except Exception: + log.exception("Failed to stop live view before acquisition") + if self.params.zstack_enabled and self.liveController.trigger_mode != TriggerMode.SOFTWARE: + self._switch_to_software_trigger_streaming() + + try: + if self.params.recording_enabled: + # Size the recording datasets from one real processed frame. + # Deferred to here (not __init__) so the probe capture only + # touches the camera after live view has been stopped. + self._frame_shape = self._probe_frame_shape() + + if self.params.zstack_enabled and self._job_runners: + self._backpressure.reset() + + self._acq_start_time = time.monotonic() + for t_idx in range(self.params.Nt): + self.time_point = t_idx + self._timepoint_fov_count = 0 + action = self._pace_timepoint(t_idx) + if action == "skip": + continue + if action == "abort": + break + if self.abort_requested_fn(): + break + + for region_id, fovs in self._scan.items(): + for fov_idx, coord in enumerate(fovs): + if self.abort_requested_fn(): + return + self._move_xy(coord) + z_ref = self.establish_reference() + + if self.params.recording_enabled: + self.record(t_idx, region_id, fov_idx, z_ref) + if self.params.zstack_enabled and self.zstack_channels: + self.zstack(t_idx, region_id, fov_idx, z_ref) + # Drain the finished save results after every FOV (non-blocking): a failed write + # requests the abort (_summarize_job_result) and the loop's abort check stops the run. + self._summarize_runner_outputs(drain_all=True) + self._timepoint_fov_count += 1 + self._run_state_beat() + except Exception as e: + log.exception(e) + self._abort_due_to_error() # tags the cause, so the run ends as "error" rather than a user abort + finally: + # Drain + shut down z-stack job runners (also closes backpressure). + if self._job_runners: + try: + self._finish_jobs() + except Exception: + log.exception("Error finishing z-stack jobs") + # Restore pre-acquisition hardware state: channel configuration first + # (exposure/gain/illumination source), then trigger mode (which for + # HARDWARE reads currentConfiguration.exposure_time), so camera + + # LiveController + MCU agree again before live view resumes. + if prev_configuration is not None: + try: + self.liveController.set_microscope_mode(prev_configuration) + except Exception: + log.exception("Failed to restore channel configuration after acquisition") + if prev_trigger_mode is not None: + try: + self.liveController.set_trigger_mode(prev_trigger_mode) + except Exception: + log.exception("Failed to restore trigger mode after acquisition") + # Restart live view once, only if it was running before the acquisition. + if was_live: + try: + self.liveController.start_live() + except Exception: + log.exception("Failed to restart live view after acquisition") + # Recordings whose drain outlived finalize(): wait for them to seal, so .done means the + # directory is final, and fail the run on a write error that surfaced after finalize(). + try: + self._settle_draining_recordings(self.DRAIN_SETTLE_TIMEOUT_S) + if self._draining_writers: + raise RuntimeError( + f"{len(self._draining_writers)} recording store(s) still being written " + f"{self.DRAIN_SETTLE_TIMEOUT_S:g}s after the run ended; not writing .done" + ) + except RuntimeError as e: + log.error(str(e)) + self._abort_due_to_error() + # Completion marker for downstream watchers (mirrors multipoint's + # _on_acquisition_completed). Written on abort too: the directory is + # final either way, and the zarr attrs record completeness. + if not self._draining_writers: + try: + from control.utils import create_done_file + + if os.path.isdir(self.experiment_path): + create_done_file(self.experiment_path) + except Exception: + log.exception("Failed to write completion marker (.done)") + self._end_run_state(time.monotonic() - run_t0) + try: + self.callbacks.signal_acquisition_finished() + except Exception: + log.exception("signal_acquisition_finished callback failed") + + def _switch_to_software_trigger_streaming(self) -> None: + """Put the z-stack's software trigger in place once, at the start, with the camera streaming. + + Why: a ToupTek camera moves its trigger source off the hardware input only when the trigger + mode changes on a running stream. Every later mode write in this run happens on a stopped + stream (each phase stops the camera on the way out), so a hardware-to-software switch made + there leaves the source on the hardware input and every z-stack trigger is ignored: no + z-stack, "Timed out waiting ... for a frame". Measured on an ITR3CMOS26000KMA; the SDK + documentation does not describe it, and other models are untested. + + Scope: called only for runs with a z-stack that start outside software trigger. Runs that + start in software trigger, and recording-only runs, are unchanged. The user's mode is + restored in run()'s finally as before; the camera driver is not changed. + + Side effect: a camera left stopped (e.g. by a previous run) is started here. + + If the switch fails it is only logged, and zstack() switches as before. On a ToupTek camera + that later switch happens on a stopped stream, so it does not cure the trigger source; it + keeps other cameras and the LiveController in sync. + """ + try: + if not self.camera.get_is_streaming(): + self.camera.start_streaming() + self.liveController.set_trigger_mode(TriggerMode.SOFTWARE) + except Exception: + log.exception("Failed to switch to software trigger before the acquisition") + + # ------------------------------------------------------------- reference + def establish_reference(self) -> float: + """Return the Z reference (mm) for this FOV. + + Uses laser AF ``move_to_target(0)`` when requested and a reference is set; + on failure (raise or soft-fail return) falls back to the current stage Z. + """ + if self.params.use_laser_af and self.laser_af is not None and self._laser_af_has_reference(): + try: + ok = self.laser_af.move_to_target(0.0) + if not ok: + log.warning("laser AF move_to_target(0) reported failure; using current Z") + except Exception as e: + log.warning(f"laser AF failed at FOV, falling back to current Z: {e}") + return self.stage.get_pos().z_mm + + def _laser_af_has_reference(self) -> bool: + return bool(self.laser_af.laser_af_properties.has_reference) + + # ---------------------------------------------------------------- record + def record(self, t_idx: int, region_id, fov_idx: int, z_ref: float) -> int: + """Record per-plane continuous streams for this FOV, then restore Z. + + Plane j records at ``z_ref + recording_bottom_z_offset_um + j*recording_dz_um`` + (one Zarr store per plane). The recording channel, achievable fps, and + dataset frame count are all established once per FOV and shared across + every plane. The stage moves to the first plane with illumination OFF, + illumination then turns on for the remainder of the FOV + (not toggled between planes), and the camera-mode + stage-Z restore + always runs on the way out — normal completion, an abort between + planes, a plane raising the fail-fast ``RuntimeError`` below, or the + first move / illumination raising. + Returns the total number of frames emitted across all planes. + """ + # Apply the recording channel (exposure/gain/illumination settings). + self._select_config(self.params.recording_channel) + + # The mode switch happens first because toupcam resets its frame-rate + # strategy on mode change. + self.camera.set_acquisition_mode(CameraAcquisitionMode.CONTINUOUS) + effective_fps = self._apply_recording_rate() + T = max(1, frame_count(effective_fps, self.params.duration_s)) + + offsets = recording_plane_offsets_um( + self.params.recording_bottom_z_offset_um, self.params.recording_Nz, self.params.recording_dz_um + ) + n_planes = len(offsets) + + # Move to the first plane with illumination OFF (the sample must not be + # illuminated during the Z move + settle). The CONTINUOUS stream does not gate + # illumination per-frame, and set_microscope_mode only energizes + # illumination when live (we are not live here), so illumination is + # turned on explicitly here and stays on across all planes of this FOV + # (no flicker between planes, fewer MCU commands); _record_one_plane + # assumes it is already energized. + total_emitted = 0 + try: + self._move_z_to_offset(z_ref, offsets[0]) + self.liveController.turn_on_illumination() + for plane_idx, plane_offset_um in enumerate(offsets): + if plane_idx > 0: + if self.abort_requested_fn(): + log.info(f"abort requested; skipping recording planes {plane_idx}..{n_planes - 1}") + break + self._move_z_to_offset(z_ref, plane_offset_um) + total_emitted += self._record_one_plane( + t_idx, region_id, fov_idx, plane_idx, n_planes, plane_offset_um, T + ) + finally: + self.liveController.turn_off_illumination() + # Restore the camera to a software-trigger-friendly state and Z + # reference. Runs on every exit path — normal completion, the + # abort break above, and a plane raising the fail-fast + # RuntimeError below — so the stage is never left at a plane + # offset. Exceptions here are only logged so they don't mask an + # in-flight RuntimeError from _record_one_plane. + try: + self.camera.set_acquisition_mode(CameraAcquisitionMode.SOFTWARE_TRIGGER) + except Exception: + log.exception("Failed to restore software-trigger acquisition mode after recording") + self.move_to_z_level(z_ref) + return total_emitted + + def _apply_recording_rate(self) -> float: + """Apply the recording frame rate to the camera and return the rate the recording runs at. + + Sizes the dataset, pacing, and time metadata from the fps the camera can + actually deliver: a camera clamped below the requested rate (exposure + limit, PRECISE_FRAMERATE max) can never fill fps*duration frames within + duration seconds — the run would stall to the timeout and leave the + trailing planes blank. The probe runs once per acquisition (the recording + exposure is fixed, so the achievable rate can't change between FOVs); later + FOVs just re-assert the cached rate after their own mode switch. Every + plane records at the same fps. + """ + if self._effective_fps is not None: + try: + self.camera.set_frame_rate(self._effective_fps) + except Exception: + log.exception("failed to re-apply cached frame rate") + return self._effective_fps + + # The rate was resolved by the controller before the run started + # (params.effective_fps, also in acquisition.yaml); a caller that built + # the params by hand falls back to the requested rate. Applying the + # hint tells us whether the camera paces itself and, as a safety net, + # whether it can do even less than resolved. + target_fps = self.params.effective_fps or self.params.fps + effective_fps = target_fps + try: + achievable_fps = self.camera.set_frame_rate(target_fps) + if achievable_fps and 0 < achievable_fps < target_fps * (1 - 0.01): + log.warning( + f"camera reports {achievable_fps:.2f} fps, below the resolved {target_fps:g} fps; " + f"recording at {achievable_fps:.2f} fps (acquisition.yaml has the resolved value)" + ) + effective_fps = achievable_fps + except Exception: + log.exception("set_frame_rate failed; assuming the resolved fps") + self._effective_fps = effective_fps + log.info(f"recording at {effective_fps:g} fps") + return effective_fps + + def _record_one_plane( + self, + t_idx: int, + region_id, + fov_idx: int, + plane_idx: int, + n_planes: int, + plane_offset_um: float, + T: int, + ) -> int: + """Record one plane's continuous stream to its own Zarr store. + + Assumes illumination is already on, the fps/mode/T setup already ran in + ``record()``, and the stage is already at the plane. Returns the number + of frames emitted for this plane. + """ + self._settle_draining_recordings(timeout_s=0.0) # an earlier plane's late write error fails the run here + rec_channel = self.params.recording_channel + effective_fps = self._effective_fps + out = self._recording_path(t_idx, region_id, fov_idx, plane_idx=plane_idx, n_planes=n_planes) + y, x, dtype = self._frame_shape + # requested vs effective fps travel with the data: the store is sized and + # timed from the rate the camera can deliver, which may be below what the + # user typed (the GUI warns before starting; this is the on-disk record). + extra_attrs = {"requested_fps": float(self.params.fps), "effective_fps": float(effective_fps)} + if n_planes > 1: + extra_attrs.update( + { + "plane_index": plane_idx, + "plane_z_offset_um": plane_offset_um, + "n_planes": n_planes, + } + ) + cfg = ZarrAcquisitionConfig( + output_path=out, + shape=(T, 1, 1, y, x), + dtype=dtype, + pixel_size_um=self._pixel_size_um if self._pixel_size_um is not None else 1.0, + z_step_um=None, + time_increment_s=(1.0 / effective_fps) if effective_fps and effective_fps > 0 else None, + channel_names=[rec_channel.name], + channel_colors=[rec_channel.display_color], + channel_wavelengths=[None], + is_hcs=False, + extra_squid_attrs=extra_attrs, + ) + writer = RecordingWriter(cfg) + cap = StreamingCapture( + ContinuousFrameSource(self.camera, effective_fps, already_configured=True), + RecordingRouter(effective_fps), # paced or not is decided from the running-stream rate at start + CountStop(T), + writer, + abort_fn=self.abort_requested_fn, + display_fn=self._display_frame_fn, + display_fps=self._display_fps, + ) + # Generous timeout: enough to gather T frames even at a slow effective rate. + timeout = self.params.duration_s * 3 + 5 + emitted = cap.run(timeout=timeout) + log.info( + f"recording done t={t_idx} region={region_id} fov={fov_idx} " + f"plane={plane_idx + 1}/{n_planes}: {emitted}/{T} frames" + ) + if not writer.wait_sealed(0.0): + # finalize() gave up its join: the drain seals the store when it finishes, and write errors + # that surface after this point are picked up by the next plane or before .done is written. + self._draining_writers.append(writer) + # Fail fast on write errors OR a wedged drain thread OR dropped frames: + # either way the cause is almost always systematic (full disk, stalled + # mount), so continuing would burn the timeout at every remaining + # plane/FOV producing blank data. A wedged finalize returns before + # errors are countable, which is why write_error_count alone is not + # sufficient. record()'s finally still restores illumination/camera + # mode/stage-Z before this propagates; run() then catches it, aborts, + # and signals finished. + if writer.write_error_count > 0 or writer.finalize_wedged or writer.dropped_count > 0: + raise RuntimeError( + f"recording failed at t={t_idx} region={region_id} fov={fov_idx} plane={plane_idx} " + f"(write errors={writer.write_error_count}, dropped={writer.dropped_count}, " + f"drain wedged={writer.finalize_wedged}); store sealed incomplete: {out}" + ) + return emitted + + def _settle_draining_recordings(self, timeout_s: float) -> None: + """Pick up the recordings whose drain thread outlived their finalize(). + + Waits up to timeout_s for them to seal (0 just checks), forgets the sealed ones, and raises + on one that sealed with write errors: finalize() returned before those surfaced, so this is + where they fail the run. Stores still being written stay listed. + """ + deadline = time.monotonic() + timeout_s + still_open, failed = [], [] + for writer in self._draining_writers: + if not writer.wait_sealed(max(0.0, deadline - time.monotonic())): + still_open.append(writer) + elif writer.write_error_count > 0: + failed.append(writer) + self._draining_writers = still_open + if failed: + raise RuntimeError( + f"{len(failed)} recording(s) failed after finalize() returned " + f"(first: {failed[0].output_path}, write errors={failed[0].write_error_count}); stores sealed incomplete" + ) + + # ---------------------------------------------------------------- zstack + def zstack(self, t_idx: int, region_id, fov_idx: int, z_ref: float) -> None: + """Acquire a software-triggered z-stack via the inherited capture path. + + Each plane/channel goes through ``acquire_camera_image`` -> ``_image_callback`` + -> ``SaveZarrJob`` dispatch. Restores Z to ``z_ref`` at the end. + """ + offsets = self._zstack_offsets + + # The inherited capture path (acquire_camera_image) branches on + # liveController.trigger_mode, so the LiveController and the camera must agree + # on software-trigger mode. set_trigger_mode(SOFTWARE) sets both the camera + # acquisition mode and the microcontroller trigger mode. Skipped once the + # mode is already SOFTWARE (every FOV after the first): the camera side is + # re-asserted by record()'s finally, and re-sending the MCU command per FOV + # would be a pure no-op round trip. No per-FOV restore either: run()'s + # finally restores the user's trigger mode once at the end of the + # acquisition. Manage the streaming lifecycle locally so it never + # interferes with the recording phase's CONTINUOUS streaming. + if self.liveController.trigger_mode != TriggerMode.SOFTWARE: + try: + self.liveController.set_trigger_mode(TriggerMode.SOFTWARE) + except Exception: + log.exception("Failed to set software-trigger mode for z-stack") + # Fall back to setting the camera directly so capture can still proceed. + try: + self.camera.set_acquisition_mode(CameraAcquisitionMode.SOFTWARE_TRIGGER) + # Keep the LiveController's view of the mode in sync with the + # camera: the inherited acquire_camera_image branches on + # liveController.trigger_mode to gate illumination, so a stale + # mode here would capture the entire z-stack dark. + self.liveController.trigger_mode = TriggerMode.SOFTWARE + except Exception: + log.exception("Failed to set camera software-trigger mode for z-stack") + current_path = self._zstack_dir(region_id) + try: + # The stream is stopped again on the way out, so the next FOV's recording phase + # starts ContinuousFrameSource on a stopped camera. + with self._camera_streaming(self._image_callback): + # Make sure the trigger gate starts open. + self._ready_for_next_trigger.set() + for z_idx, off_um in enumerate(offsets): + self._move_z_to_offset(z_ref, off_um) + for c_idx, config in enumerate(self.zstack_channels): + if self.abort_requested_fn(): + return + file_id = f"{region_id}_{fov_idx}" + self.acquire_camera_image( + config=config, + file_ID=file_id, + current_path=current_path, + k=z_idx, + region_id=region_id, + fov=fov_idx, + config_idx=c_idx, + ) + finally: + self.move_to_z_level(z_ref) + + # --------------------------------------------------------------- helpers + def _move_xy(self, coord) -> None: + self.stage.move_x_to(coord[0]) + self._sleep(SCAN_STABILIZATION_TIME_MS_X / 1000) + self.stage.move_y_to(coord[1]) + self._sleep(SCAN_STABILIZATION_TIME_MS_Y / 1000) + # (x, y, z) coords carry a stored per-FOV focus plane (flexible regions, + # update_fov_z) — honor it like MultiPointWorker.move_to_coordinate, or + # establish_reference() would reuse the previous FOV's Z on tilted samples. + if len(coord) > 2 and coord[2] is not None: + self.stage.move_z_to(coord[2]) + self.wait_till_operation_is_completed() + self._sleep(SCAN_STABILIZATION_TIME_MS_Z / 1000) + + def _move_z_to_offset(self, z_ref: float, offset_um: float) -> None: + """Move to ``z_ref + offset_um`` (offset in µm, z_ref in mm) via the stage. + + Delegates to the inherited ``move_to_z_level`` (blocking stage move + + Z stabilization sleep, same as MultiPointWorker's scan loop). + """ + self.move_to_z_level(z_ref + offset_um / 1000.0) + + def _probe_frame_shape(self) -> Tuple[int, int, np.dtype]: + """Return (Y, X, dtype) for the recording dataset from one real processed frame. + + Frames delivered to callbacks pass through ``_process_raw_frame`` (software + crop, rotation, ROI), so their shape can differ from ``get_resolution()`` + (the raw binned sensor size). Sizing the dataset from the sensor size makes + every ``write_frame`` fail on cropped cameras — a blank recording. + + Probe in CONTINUOUS mode — the same mode the recording phase uses — and read + one free-run frame, so the probed shape is exactly what recording writes. + CONTINUOUS needs no ``send_trigger``, so a Live view left in SOFTWARE-trigger + mode (with an outstanding trigger that makes ``get_ready_for_trigger`` reject + an immediate trigger) can't block the probe — the failure that previously + dropped it onto the wrong ``get_resolution`` size and failed every write. + """ + frame = None + try: + self.camera.set_acquisition_mode(CameraAcquisitionMode.CONTINUOUS) + with self._camera_streaming(): + cam_frame = self.camera.read_camera_frame() + if cam_frame is not None: + frame = cam_frame.frame + except Exception: + log.exception("probe-frame capture failed") + if frame is None: + # No usable frame: sizing from get_resolution() (raw sensor size) would + # mismatch the cropped delivery and fail every write, so abort with a + # clear reason instead of grinding out a blank, incomplete recording. + raise RuntimeError( + "recording could not capture a probe frame to size the dataset " + "(camera delivered no frame in CONTINUOUS mode); aborting recording" + ) + if frame.ndim != 2: + # A color frame (Y, X, 3) would silently produce a 2-D dataset that + # every write then fails against — reject it up front. + raise ValueError( + f"recording supports monochrome frames only; camera delivered shape {frame.shape} " + f"(set the camera to a mono pixel format for the recording phase)" + ) + return int(frame.shape[0]), int(frame.shape[1]), frame.dtype + + def _recording_path(self, t_idx: int, region_id, fov_idx: int, plane_idx: int = 0, n_planes: int = 1) -> str: + """Per-(t, region, fov[, plane]) recording dataset path under {experiment}/recording. + + Single-plane recordings (n_planes == 1) are named ``fov_{k}.ome.zarr``; + multi-plane recordings get one store per plane: ``fov_{k}_z{j}.ome.zarr``. + """ + fov_name = f"fov_{fov_idx}.ome.zarr" if n_planes == 1 else f"fov_{fov_idx}_z{plane_idx}.ome.zarr" + return os.path.join(self.experiment_path, "recording", f"t{t_idx}", str(region_id), fov_name) + + def _zstack_dir(self, region_id) -> str: + """Directory passed as ``current_path`` to the inherited capture path. + + SaveZarrJob ignores this (it builds its own path from ZarrWriterInfo), but + SaveImageJob/SaveOMETiffJob would use it, so keep it valid. + """ + path = os.path.join(self.experiment_path, "zstack", str(region_id)) + return path + + def _pace_timepoint(self, t_idx: int) -> str: + """Decide how to handle time point ``t_idx``: 'run', 'skip', or 'abort'. + + Starts are paced on the absolute grid ``acquisition_start + t_idx*dt``. + A slot whose start already passed is SKIPPED (grid-preserving, mirrors + MultiPointWorker's skip loop) — running it late would silently stretch + the real sampling interval while the recorded ``time_increment_s`` + metadata still claims ``dt``. + """ + if t_idx == 0 or self.params.dt_s <= 0: + return "run" + # run() sets _acq_start_time before the timepoint loop, so pacing is + # always anchored to the acquisition start. + deadline = self._acq_start_time + t_idx * self.params.dt_s + if time.monotonic() > deadline: + log.warning( + f"skipping time point {t_idx}: per-timepoint work exceeded dt={self.params.dt_s:g}s " + f"(grid-preserving skip, mirrors MultiPointWorker)" + ) + return "skip" + return "run" if self._wait_for_dt(deadline) else "abort" + + def _wait_for_dt(self, deadline: float) -> bool: + """Sleep until *deadline* (a time.monotonic() timestamp), abort-aware. + + Uses time.monotonic() so an NTP clock step mid-acquisition cannot skew + or collapse the remaining intervals. Returns False on abort. + """ + while time.monotonic() < deadline: + if self.abort_requested_fn(): + return False + self._sleep(min(0.1, max(0.0, deadline - time.monotonic()))) + return True diff --git a/software/control/core/streaming_capture.py b/software/control/core/streaming_capture.py new file mode 100644 index 000000000..5465929ac --- /dev/null +++ b/software/control/core/streaming_capture.py @@ -0,0 +1,711 @@ +import queue +import threading +import time +from collections import deque +from typing import Any, Callable, Deque, Optional, Tuple + +import numpy as np + +import squid.logging +from control.core.zarr_writer import ZarrAcquisitionConfig, ZarrWriter +from squid.abc import CameraAcquisitionMode + +_log = squid.logging.get_logger("RecordingWriter") +_SENTINEL = object() + + +class CountStop: + def __init__(self, target: int): + self.target = target + + def met(self, emitted: int) -> bool: + return emitted >= self.target + + def expected(self) -> int: + """Expected total frame count, used for partial-capture warnings.""" + return self.target + + +class RecordingRouter: + """Maps incoming frames to (t,c,z)=(slot,0,0) at `fps`. + + Two regimes, chosen by the caller from what the camera reported for the + requested rate (see RecordZStackWorker.record): + + ``paced=False`` (default) — the camera free-runs FASTER than `fps` and the + router downsamples: each accepted frame goes to the slot NEAREST its arrival + time relative to the first frame (``slot = round(elapsed / period)``, ties + rounding down); a frame whose nearest slot is already filled is rejected. + This anchors pacing absolutely (host-delivery jitter around the period cannot + reject at-rate frames or halve the capture rate) and, after a delivery stall, + a burst of frames does NOT back-fill the missed slots — the stall stays in + the data as fill-value holes, keeping the time axis honest (the store is + then sealed incomplete by the under-delivery path). + + ``paced=True`` — the camera delivers at (or below) `fps`, either because it + paces itself to the hint (ToupTek PRECISE_FRAMERATE, simulated camera) or + because `fps` IS its free-run maximum. Every frame is a wanted frame, so + frames fill slots sequentially and are never rejected: a sensor free-running + a few percent off the nominal rate, or a frame whose host-side callback ran + late (a GUI repaint holding the GIL), must not cost a slot. Only a gap of at + least ``max(STALL_PERIODS * period, MIN_STALL_S)`` since the previous frame + is treated as a real stall and leaves ``round(gap / period) - 1`` holes; the + absolute floor matters at high rates, where a period is shorter than an + ordinary host hiccup (at 108 fps a 20-50 ms GIL pause is 2-5 periods and cost + 0.5% of frames without it). A genuine one-frame camera drop inside that + window is absorbed as a one-period shift of the remaining time axis — for a + hardware-paced USB3 sensor that is rare, and far cheaper than a recording + flagged incomplete. On the bench the nearest-slot rule rejected ~4% of + frames at the camera's 28 fps maximum (sensor at ~28.5 fps) and sealed every + such recording incomplete. + """ + + STALL_PERIODS = 1.75 + MIN_STALL_S = 0.1 + + def __init__(self, fps: float, paced: bool = False): + self._period = 1.0 / fps if fps and fps > 0 else 0.0 + self._paced = bool(paced) + self._stall_gap = max(self.STALL_PERIODS * self._period, self.MIN_STALL_S) + self._t_index = 0 # next unfilled slot + self._first_ts: Optional[float] = None + self._last_ts: Optional[float] = None + self._stalls = 0 # paced mode: gaps treated as stalls + self._holes = 0 # paced mode: slots left as fill by those stalls + + @property + def paced(self) -> bool: + return self._paced + + @property + def nominal_period(self) -> Optional[float]: + return self._period if self._period > 0 else None + + @property + def stall_count(self) -> int: + return self._stalls + + @property + def hole_count(self) -> int: + return self._holes + + def set_paced_from_rate(self, running_fps: float) -> None: + """Decide the regime from the rate the camera reports for the hint with the stream running. + + A camera that reports it delivers at (or below) the nominal rate paces itself: sequential + slots. One that reports a higher rate free-runs faster and is downsampled by arrival time. + Decided from the running stream, not a stopped-state probe: toupcam validates a stopped + write against the range of the mode it last streamed in, so after a binning or bit-depth + change it is rejected and reports the free-run maximum, while the live write at start + succeeds and the sensor paces itself. + """ + if self._period <= 0: + return + self._paced = bool(running_fps) and running_fps <= (1.0 / self._period) * (1 + 1e-6) + regime = "camera-paced, sequential slots" if self._paced else "free-run, downsampled by arrival time" + _log.info(f"recording routed {regime} (camera reports {running_fps:.2f} fps with the stream running)") + + def measured_period(self) -> Optional[float]: + """Mean wall-clock spacing per slot actually delivered: (last - first) / slots spanned. + + Holes count as spanned slots (their time really elapsed), so this is the true + time increment of the store's t axis. None until two frames were routed. + In paced mode slot advance is purely sequential, so a source that runs slower + or faster than the nominal rate is absorbed without holes and only this + measurement can tell — the capture stamps it into the store when it differs + from the nominal period (see StreamingCapture.run). + """ + if self._first_ts is None or self._last_ts is None or self._t_index < 2: + return None + return (self._last_ts - self._first_ts) / (self._t_index - 1) + + def route(self, timestamp: float) -> Optional[Tuple[int, int, int]]: + if self._first_ts is None: + self._first_ts = timestamp + slot = 0 + elif self._period <= 0: + slot = self._t_index + elif self._paced: + gap = timestamp - self._last_ts + holes = 0 + if gap >= self._stall_gap: + holes = max(0, int(gap / self._period + 0.5) - 1) + if holes: + self._stalls += 1 + self._holes += holes + # Hot camera-callback thread: log the first stall and then only + # every 50th; the capture logs a per-run summary after stop(). + if self._stalls == 1 or self._stalls % 50 == 0: + _log.warning( + f"recording stall: {gap * 1000:.0f} ms between frames at slot {self._t_index} " + f"({holes} slot(s) left as fill; {self._stalls} stall(s) so far)" + ) + slot = self._t_index + holes + else: + # Nearest slot; the epsilon makes exact half-period ties round DOWN + # so a 2x-rate camera still has alternate frames rejected. + slot = int((timestamp - self._first_ts) / self._period + 0.5 - 1e-9) + if slot < self._t_index: + return None + self._last_ts = timestamp + idx = (slot, 0, 0) + self._t_index = slot + 1 + return idx + + +class RecordingWriter: + """Bounded-queue writer that drains frames to a ZarrWriter on a background thread. + + The hot camera callback calls `enqueue` (truly non-blocking); the background + thread calls `ZarrWriter.submit_frame` and waits for the writes, which may block on I/O. The queue is + bounded so that a slow disk eventually fills it: when full, `enqueue` drops the + frame immediately (never blocks the camera delivery thread) and logs a warning. + + After `start()` the drain thread is the SOLE owner of the ZarrWriter: only it + calls `submit_frame`, `finalize`, and `abort`. The main thread only calls + `initialize()` (before the thread starts) and then enqueues items / signals stop. + This prevents the data race where `abort()` used to call `self._writer.abort()` + concurrently with the drain thread still inside a write. + """ + + # Bound for the drain-thread join in finalize(); a caller that must know the store is final + # (RecordZStackWorker before .done) gives wait_sealed() the same budget. + FINALIZE_TIMEOUT_S = 30.0 + + def __init__( + self, + config: ZarrAcquisitionConfig, + max_queue: int = 256, + max_bytes: int = 2 * 1024**3, + max_inflight: int = 8, + ): + self._writer = ZarrWriter(config) + self.output_path = config.output_path + self._q: "queue.Queue" = queue.Queue(maxsize=max_queue) + # Writes submitted to TensorStore but not yet reaped. Keeping several in + # flight lets encode / file create / write / fsync of consecutive frames + # overlap; reaping synchronously after every submit (the old write_frame + # loop) capped the drain at ~25 frames/s of 8 MB frames on an NVMe bench, + # well under the camera's 28 fps, while 8 in flight sustains >100. + self._max_inflight = max(1, int(max_inflight)) + self._thread = threading.Thread(target=self._drain, daemon=True) + self._dropped = 0 + self._write_errors = 0 + # Byte cap alongside the count cap: 256 full-resolution 16-bit frames is + # ~13 GB, so a count-only bound can OOM the process on a slow disk. + self._max_bytes = max_bytes + self._held_bytes = 0 + self._bytes_lock = threading.Lock() + self._abort_requested = threading.Event() + # Set instead of the abort flag when finalize() gives up on a wedged + # drain: the drain writes the remaining CAPTURED frames whenever the + # stall clears, then exits and seals (abort would discard them). + # Write errors may still be accruing after finalize() returns, so callers + # must not trust write_error_count == 0 as "healthy" — see finalize_wedged. + self._flush_and_exit = threading.Event() + # (captured, expected) reported by the capture when frames never + # arrived (camera stall) — drops/errors are counted here, but only the + # capture knows about frames it expected and never saw. + self._incomplete_info: Optional[Tuple[int, int]] = None + # Measured t-axis spacing reported by the capture when it differs from the + # nominal one (source ran slower/faster than the paced rate); stamped into + # the store's metadata by whichever seal path runs. + self._measured_time_increment_s: Optional[float] = None + # True only once the drain thread has actually been started. finalize()/ + # abort() must not join (or push the sentinel to) a thread that never + # started, otherwise a failure in start()'s initialize() would surface as + # "cannot join thread before it is started" and mask the real error. + self._started = False + + def start(self) -> None: + """Initialize the underlying ZarrWriter and start the drain thread. + + ``initialize()`` runs BEFORE the thread starts. If it raises, the thread + is never started and ``_started`` stays False, so a later finalize()/abort() + cleanly no-ops the join and the original ``initialize()`` error propagates. + """ + self._writer.initialize() + try: + self._thread.start() + except Exception: + # initialize() already opened the writer, but the drain thread (its + # sole owner after start()) will never run to close it — release it + # here before propagating so we don't leak the ZarrWriter. + self._writer.abort() + raise + self._started = True + + def enqueue(self, frame: np.ndarray, t: int, c: int, z: int) -> None: + """Truly non-blocking enqueue: drops the frame on a full queue. + + Runs on the hot camera delivery thread, so it must never block. When the + bounded queue is full (drain thread cannot keep up with disk I/O) the frame + is dropped and counted rather than waiting for space. + """ + nbytes = int(frame.nbytes) + with self._bytes_lock: + over_cap = self._held_bytes + nbytes > self._max_bytes + if not over_cap: + self._held_bytes += nbytes + if over_cap: + self._count_drop(f"recording byte cap reached ({self._max_bytes} B)", t) + return + try: + self._q.put_nowait((frame, t, c, z)) + except queue.Full: + self._release_bytes(nbytes) + self._count_drop("recording queue full", t) + + def _count_drop(self, reason: str, t: int) -> None: + self._dropped += 1 + # Rate-limited: this runs on the hot camera delivery thread, and a + # stalled disk would otherwise log (and do handler I/O) at fps rate. + if self._dropped == 1 or self._dropped % 100 == 0: + _log.warning(f"{reason}; dropped frame t={t} (total dropped={self._dropped})") + + def _count_write_error(self, t: int, error: Exception) -> None: + self._write_errors += 1 + _log.error(f"recording write_frame failed t={t}: {error}") + + def _release_bytes(self, nbytes: int) -> None: + with self._bytes_lock: + self._held_bytes -= nbytes + + def _reap(self, future, nbytes: int, t: int) -> None: + """Wait for one submitted write; count a failure; release its byte accounting.""" + try: + future.result() + except Exception as e: + self._count_write_error(t, e) + finally: + self._release_bytes(nbytes) + + def _drain(self) -> None: + """Background thread: sole owner of ZarrWriter after start(). + + Reads the queue with a short timeout so it can notice an abort between + frames. Each frame is submitted to TensorStore and reaped later, keeping + up to ``max_inflight`` writes outstanding; every submission is reaped + (and its errors counted) before the store is sealed. On exit, calls + writer.abort() or writer.finalize() as appropriate. + """ + inflight: Deque[Tuple[Any, int, int]] = deque() + try: + while True: + if self._abort_requested.is_set(): + break + try: + item = self._q.get(timeout=0.1) + except queue.Empty: + if self._flush_and_exit.is_set(): + break # backlog flushed after a wedged finalize() + # Idle: reap what has completed so errors surface promptly. + while inflight and inflight[0][0].done(): + self._reap(*inflight.popleft()) + continue + if item is _SENTINEL: + break + frame, t, c, z = item + nbytes = int(frame.nbytes) + try: + future = self._writer.submit_frame(frame, t=t, c=c, z=z) + except Exception as e: + self._count_write_error(t, e) + self._release_bytes(nbytes) + continue + inflight.append((future, nbytes, t)) + while len(inflight) >= self._max_inflight: + self._reap(*inflight.popleft()) + finally: + # Every submission is reaped before sealing, on every exit path + # (sentinel, abort, flush-and-exit): the seal must reflect all + # write errors, and abort()/finalize() must not race in-flight I/O. + while inflight: + self._reap(*inflight.popleft()) + measured = self._measured_time_increment_s + if self._abort_requested.is_set(): + self._writer.abort(time_increment_s=measured) + elif self._write_errors > 0 or self._dropped > 0 or self._incomplete_info is not None: + # Never stamp acquisition_complete=True on a store with failed + # writes, dropped frames, or frames that never arrived: some + # planes are silent fill values. Seal acquisition_complete=False + # with the counts, but do NOT mark it "aborted" — this was not a + # user abort, and the queue was drained so no captured data is + # lost. + extra = { + "incomplete": True, + "write_errors": self._write_errors, + "dropped_frames": self._dropped, + } + if self._incomplete_info is not None: + extra["captured_frames"], extra["expected_frames"] = self._incomplete_info + _log.error(f"recording store sealed INCOMPLETE: {extra}") + self._writer.abort(mark_aborted=False, extra_attrs=extra, time_increment_s=measured) + else: + self._writer.finalize(time_increment_s=measured) + + @property + def dropped_count(self) -> int: + """Total frames dropped due to a full queue (diagnosable in slow-disk runs).""" + return self._dropped + + @property + def write_error_count(self) -> int: + """Total write_frame failures on the drain thread (0 on a healthy run).""" + return self._write_errors + + @property + def finalize_wedged(self) -> bool: + """True if finalize() gave up on a wedged drain thread. + + In that state write_error_count may still be 0 (the errors happen after + finalize() returned), so fail-fast callers must treat wedged as failure. + """ + return self._flush_and_exit.is_set() + + def set_measured_time_increment(self, seconds: float) -> None: + """Record the t-axis spacing the source actually delivered (see RecordingRouter.measured_period). + + Called by StreamingCapture before finalize()/abort(); the seal rewrites the + store's ``time_increment_s`` (and the OME t scale) with it, keeping the + nominal value alongside, so a recording that ran off its nominal rate is + never mis-timed on disk. + """ + self._measured_time_increment_s = float(seconds) + + def mark_incomplete(self, captured: int, expected: int) -> None: + """Record that the capture under-delivered (frames never arrived). + + Called by StreamingCapture before finalize() when the stop condition + was not met with no user abort — drops and write errors are counted + internally, but only the capture knows about frames it expected and + never saw. Makes the drain seal the store acquisition_complete=False. + """ + self._incomplete_info = (int(captured), int(expected)) + + def finalize(self, timeout_s: float = FINALIZE_TIMEOUT_S) -> bool: + """Flush the queue, join the drain thread (which seals the ZarrWriter). + + Returns True once the drain thread has exited, i.e. the store is sealed. False means + it is still writing (wedged, or slow but progressing): write_error_count is not final + and the store is not, so a caller that needs either must wait_sealed() first. + + The sentinel push is bounded: with the drain thread wedged inside a + stalled write and the queue full, a bare ``put`` would block the + acquisition thread forever (the join timeout below would never be + reached). After ``timeout_s`` of no queue space, fall back to the + abort path and return — the daemon drain thread seals the store + whenever the stalled write finally returns. + """ + if not self._started: + # start() never got the thread running (e.g. initialize() raised). + # Nothing to flush or join; let the original error propagate. + return True + deadline = time.monotonic() + timeout_s + while True: + try: + self._q.put(_SENTINEL, timeout=min(1.0, max(0.1, timeout_s))) + break + except queue.Full: + if time.monotonic() >= deadline: + _log.error( + f"drain thread wedged (queue full for {timeout_s:.0f}s); giving up the wait — " + f"the captured backlog will be flushed and sealed whenever the stall clears" + ) + # Flush-and-exit, NOT abort: the queued frames are captured + # data; the daemon drain writes them once the stalled write + # returns, then exits and seals the store. + self._flush_and_exit.set() + return False + # The put and the join share ONE budget: a sentinel accepted late must + # not be followed by a fresh full-length join (callers would block for + # up to ~2x timeout_s). + if not self.wait_sealed(max(0.1, deadline - time.monotonic())): + # Slow but progressing backlog — NOT wedged (flagging it would + # fail-fast whole multi-well runs over stores that finish fine). + # The daemon drain seals the store when it finishes. + _log.warning("drain thread still writing after finalize() join timeout; store seals when it finishes") + return False + return True + + def wait_sealed(self, timeout_s: float) -> bool: + """Wait up to timeout_s for the drain thread to exit (it seals the store on its way out). + + True once the store is sealed and write_error_count is final, or when the drain never + started (nothing was in flight); False while writes are still in flight. + """ + if not self._started: + return True + self._thread.join(timeout=max(0.0, timeout_s)) + return not self._thread.is_alive() + + def abort(self) -> None: + """Signal the drain thread to stop (which aborts the ZarrWriter).""" + self._abort_requested.set() + if not self._started: + # start() never got the thread running (e.g. initialize() raised). + return + try: + self._q.put_nowait(_SENTINEL) + except queue.Full: + pass + if not self.wait_sealed(5.0): + _log.warning("drain thread still alive after abort() join timeout") + + +class ContinuousFrameSource: + """Wraps a camera and delivers frames via callback. + + Calls set_acquisition_mode(CONTINUOUS), set_frame_rate, registers a frame + callback, and starts/stops streaming. + """ + + def __init__(self, camera, fps: float, already_configured: bool = False): + self._camera = camera + self._fps = fps + # True when the caller already set CONTINUOUS mode + frame rate (the + # achievable-fps probe in record() does exactly that): skip repeating + # both, which on toupcam costs a mode switch plus a strobe/exposure + # re-send per FOV. + self._already_configured = already_configured + self._cb_id: Optional[int] = None + + def start(self, on_frame: Callable) -> Optional[float]: + """Register on_frame, start the stream and apply the rate hint; returns the rate the camera + reports for it with the stream running (None if the write failed), for the router's regime.""" + # Order matters: switching to CONTINUOUS resets the frame-rate strategy to + # MAX on toupcam, wiping any earlier fps hint, so the mode switch comes + # first. The hint itself is (re)applied AFTER the stream is up: on toupcam + # the PRECISE_FRAMERATE range is only readable, and a write only validated + # against the current resolution / bit depth, while the pull-mode stream + # runs — a hint written while stopped after a binning change is rejected + # (or clamped to the previous mode's range) and the sensor keeps pacing at + # whatever was set before. The caller's earlier set_frame_rate() probe + # already sized the dataset; this call just makes the hardware match it. + if not self._already_configured: + self._camera.set_acquisition_mode(CameraAcquisitionMode.CONTINUOUS) + self._discard_until: Optional[float] = None + self._discarded = 0 + + def _filtered(camera_frame): + # A frame-rate hint written into the running stream corrupts the frame + # integrating at that moment (toupcam with a hardware ROI, where the + # stopped-state write is re-clamped at Start and the running write is the + # only way to reach the ROI's higher rate). Drop what the camera delivers + # within a period of that write — the corrupted frame and at most one + # clean in-flight one — so it never becomes frame 0 of the recording. + until = self._discard_until + if until is not None: + if camera_frame.timestamp < until: + self._discarded += 1 + return + self._discard_until = None + if self._discarded: + _log.info(f"discarded {self._discarded} frame(s) around the live frame-rate write") + on_frame(camera_frame) + + self._cb_id = self._camera.add_frame_callback(_filtered) + self._camera.start_streaming() + t_start = time.time() + running_fps = None + try: + running_fps = self._camera.set_frame_rate(self._fps) + except Exception: + _log.exception("failed to apply the frame-rate hint after stream start") + # isinstance, not a None check: the simulated camera answers unknown + # attributes with a sentinel object instead of raising. + live_write = getattr(self._camera, "frame_rate_hint_live_write_ts", None) + if isinstance(live_write, (int, float)) and live_write >= t_start: + period = 1.0 / self._fps if self._fps and self._fps > 0 else 0.0 + self._discard_until = live_write + period + 0.02 + return running_fps + + def stop(self) -> None: + self._camera.stop_streaming() + if self._cb_id is not None: + self._camera.remove_frame_callback(self._cb_id) + self._cb_id = None + + +class StreamingCapture: + """Orchestrates a frame source, router, stop condition, and writer. + + ``run()`` starts the source, routes each incoming frame through the router, + enqueues accepted frames to the writer, and stops when the stop condition is + met or ``abort_fn`` returns True. + + The ``_on_frame`` callback runs on the hot camera thread — it must stay cheap + (route + enqueue + a throttled non-blocking preview tap; no blocking I/O). + + Args: + frame_source: Any object with ``start(on_frame)`` / ``stop()`` interface. + router: ``RecordingRouter`` (or an object with its public members) — maps timestamps to (t,c,z). + stop_condition: ``CountStop`` (or compatible) — ``met(emitted)`` returns bool, ``expected()`` + the total frame count or None when there is none. + writer: ``RecordingWriter`` (or an object with its public members). + abort_fn: Zero-argument callable; returns True to abort early. + display_fn: Optional callable receiving a frame (np.ndarray) for live + preview, throttled to display_fps. Failures are logged once and + disable the preview for this capture; they never affect recording. + display_fps: Preview rate in frames/s. <= 0 disables the preview tap. + timeout: Optional seconds to wait for completion. If the source does not + trigger the done event within this time ``run()`` still stops and + finalizes (returns frames emitted so far). None means wait forever. + """ + + def __init__( + self, + frame_source, + router, + stop_condition, + writer, + abort_fn: Callable[[], bool], + display_fn: Optional[Callable] = None, + display_fps: float = 0.0, + ): + self._source = frame_source + self._router = router + self._stop = stop_condition + self._writer = writer + self._abort_fn = abort_fn + self._emitted = 0 + self._done = threading.Event() + self._aborted = False + # Expected total frame count is fixed for the capture's lifetime, so + # resolve it once here instead of re-probing on every hot-path frame. + self._expected: Optional[int] = stop_condition.expected() + + # Throttled live-preview tap (plain callable — no Qt in this module). + # display_fps <= 0 disables the tap entirely. + self._display_fn = display_fn if (display_fn is not None and display_fps > 0) else None + self._display_interval = (1.0 / display_fps) if display_fps > 0 else float("inf") + self._last_display_ts = float("-inf") + + def _on_frame(self, camera_frame) -> None: + """Hot-thread callback: route + enqueue + throttled preview tap. Must not block.""" + if self._done.is_set(): + return + if self._abort_fn(): + self._aborted = True + self._done.set() + return + # Out-of-bounds guard: if the stop condition is already met for the current + # emitted count, do not route/enqueue. A frame that arrives in-flight after + # CountStop(T) is satisfied would otherwise route to t-index == T and enqueue + # into a (T, ...)-shaped dataset (out of bounds). Re-check here, not just at + # entry, so once _emitted == T no further frame is ever emitted. + if self._stop.met(self._emitted): + self._done.set() + return + self._maybe_display(camera_frame) + idx = self._router.route(camera_frame.timestamp) + if idx is not None: + if self._expected is not None and idx[0] >= self._expected: + # The router's slot ran past the dataset (a delivery stall + # pushed the timeline beyond T): nothing left to record into. + self._done.set() + return + self._writer.enqueue(camera_frame.frame, *idx) + self._emitted += 1 + if self._stop.met(self._emitted): + self._done.set() + + def _maybe_display(self, camera_frame) -> None: + """Throttled preview tap. Must never harm the recording: any exception + is logged once and disables the preview for the rest of this capture.""" + if self._display_fn is None: + return + if camera_frame.timestamp - self._last_display_ts < self._display_interval: + return + try: + self._display_fn(camera_frame.frame) + self._last_display_ts = camera_frame.timestamp + except Exception: + _log.exception("live-preview display_fn failed; disabling preview for this capture") + self._display_fn = None + + # Relative deviation of the delivered spacing from the nominal one above which the + # store is stamped with the measured value. Host-timestamp jitter on the first and + # last frame is well under 1% over any recording longer than a couple of seconds. + RATE_DEVIATION_TOLERANCE = 0.02 + + def _report_delivered_rate(self) -> None: + """After the source stopped: summarise stalls, and hand the writer the measured + t-axis spacing when the source ran off the nominal rate. + + In paced mode the router never rejects frames, so a camera delivering slower + (or faster) than the rate it reported fills every slot and the store would + seal complete with a wrong ``time_increment_s``. Measuring the spacing from + the routed timestamps and stamping it keeps the time axis honest (the nominal + value is kept alongside for reference). + """ + router = self._router + if router.stall_count: + _log.warning(f"recording had {router.stall_count} stall(s), {router.hole_count} slot(s) left as fill") + nominal = router.nominal_period + measured = router.measured_period() + if not nominal or measured is None or measured <= 0: + return + deviation = (measured - nominal) / nominal + if abs(deviation) <= self.RATE_DEVIATION_TOLERANCE: + return + _log.warning( + f"recording delivered at {1.0 / measured:.2f} fps, nominal {1.0 / nominal:.2f} fps " + f"({deviation * 100:+.1f}%); the store's time_increment_s is stamped with the measured spacing" + ) + self._writer.set_measured_time_increment(measured) + + def run(self, timeout: Optional[float] = None) -> int: + """Start capture, block until done (or timeout), and return emitted count.""" + self._writer.start() + try: + running_fps = self._source.start(self._on_frame) + if running_fps is not None: + self._router.set_paced_from_rate(running_fps) + # Poll abort_fn while waiting: the frame callback also samples it, but + # if the camera delivers no frames at all (stall, misconfigured + # trigger) the callback never runs and a bare wait(timeout) would + # ignore Stop for the full timeout — and then seal the store as + # complete. + deadline = (time.monotonic() + timeout) if timeout is not None else None + while not self._done.wait(0.2): + if self._abort_fn(): + self._aborted = True + self._done.set() + break + if deadline is not None and time.monotonic() >= deadline: + break + finally: + # Assumes source.stop() quiesces the camera delivery thread. With cameras + # that don't join their callback thread on stop, a final in-flight frame may + # reach writer.enqueue after finalize — harmless with RecordingWriter (the + # drain thread has exited and the store is sealed, so the frame is queued + # and never written). + self._source.stop() + # source.stop() above quiesces the camera delivery thread, so reading + # self._emitted here is safe without a lock: no callback thread mutates + # it after this point (and CPython int load/store is atomic anyway). + expected = self._expected + self._report_delivered_rate() + if self._aborted: + # Aborted mid-capture: seal the recording as incomplete, not complete. + self._writer.abort() + else: + if expected is not None and self._emitted < expected: + # Stop condition was not met (slow camera / stall / timeout): + # some zarr planes are fill values, not real data. Tell the + # writer so the store is sealed acquisition_complete=False. + _log.warning( + f"streaming capture incomplete: captured {self._emitted}/{expected} " + f"frames; missing planes are blank fill" + ) + self._writer.mark_incomplete(self._emitted, expected) + self._writer.finalize() + # Surface total dropped frames so slow-disk runs are diagnosable without + # grepping individual per-frame warnings. + dropped = self._writer.dropped_count + if dropped > 0: + _log.warning( + f"streaming capture finished: {dropped} frame(s) dropped total " f"(queue full / slow disk)" + ) + return self._emitted diff --git a/software/control/core/zarr_writer.py b/software/control/core/zarr_writer.py index 469ac415b..63af63de8 100644 --- a/software/control/core/zarr_writer.py +++ b/software/control/core/zarr_writer.py @@ -72,6 +72,9 @@ class ZarrAcquisitionConfig: compression: ZarrCompression = ZarrCompression.FAST is_hcs: bool = True # Default to HCS (5D); non-HCS uses 6D with FOV dimension plate_name: str = "plate" + # Optional extra keys merged into the "_squid" attributes at metadata-write + # time (e.g. per-plane recording metadata: plane_index, plane_z_offset_um). + extra_squid_attrs: Optional[Dict[str, object]] = None @property def ndim(self) -> int: @@ -588,6 +591,9 @@ def _write_zarr_metadata(self) -> None: }, } + if config.extra_squid_attrs: + zattrs["_squid"].update(config.extra_squid_attrs) + # Write metadata to zarr.json attributes (strict Zarr v3 compliance) # For HCS, output_path is the array path ({fov}/0), but OME-NGFF metadata # should be at the parent group level ({fov}/zarr.json), not the array level. @@ -628,7 +634,9 @@ def submit_frame(self, image: np.ndarray, t: int, c: int, z: int, fov: Optional[ The returned object's ``.result()`` blocks until the write is committed (and raises if it failed). TensorStore copies the source array as part of the write, so ``image`` may be released once the future completes. Keeping several submissions in flight lets TensorStore - overlap encode, file creation, write and fsync of consecutive frames. + overlap encode, file creation, write and fsync of consecutive frames (on the bench a single synchronous + writer topped out at ~25 frames/s of 8 MB frames, while 4-8 in flight sustained >100 frames/s + to the same NVMe). Args: image: 2D image array (Y, X) @@ -672,8 +680,63 @@ def write_frame(self, image: np.ndarray, t: int, c: int, z: int, fov: Optional[i """submit_frame() and wait for it, so the data is on disk for other readers once this returns.""" self.submit_frame(image, t, c, z, fov).result() - def finalize(self) -> None: - """Finalize the dataset (blocking).""" + def _stamp_time_increment(self, zarr_json: dict, time_increment_s: float) -> None: + """Replace the t-axis spacing in the store's metadata with a measured value. + + Updates ``_squid.time_increment_s`` and the OME multiscale ``scale`` entry for + the t axis, keeping the configured value as ``_squid.nominal_time_increment_s``. + Used when the recording source ran off its nominal rate (see + StreamingCapture._report_delivered_rate) so the time axis on disk is honest. + """ + attrs = zarr_json.get("attributes", {}) + squid_attrs = attrs.get("_squid") + if squid_attrs is not None: + squid_attrs.setdefault("nominal_time_increment_s", squid_attrs.get("time_increment_s")) + squid_attrs["time_increment_s"] = time_increment_s + try: + multiscale = attrs["ome"]["multiscales"][0] + t_axis = [a["name"] for a in multiscale["axes"]].index("t") + for dataset in multiscale["datasets"]: + for transform in dataset.get("coordinateTransformations", []): + if transform.get("type") == "scale": + transform["scale"][t_axis] = time_increment_s + except (KeyError, IndexError, ValueError, TypeError): + log.warning("could not update the OME t-axis scale with the measured time increment") + zarr_json["attributes"] = attrs + + def _seal_metadata( + self, + zarr_json_path: str, + complete: bool, + aborted: bool = False, + extra_attrs: Optional[Dict[str, object]] = None, + time_increment_s: Optional[float] = None, + ) -> None: + """Record in zarr.json how the acquisition ended (see finalize / abort for the arguments).""" + if not os.path.exists(zarr_json_path): + return + with open(zarr_json_path, "r") as f: + zarr_json = json.load(f) + attrs = zarr_json.get("attributes", {}) + if "_squid" in attrs: + attrs["_squid"]["acquisition_complete"] = complete + if aborted: + attrs["_squid"]["aborted"] = True + if extra_attrs: + attrs["_squid"].update(extra_attrs) + if time_increment_s is not None: + self._stamp_time_increment(zarr_json, time_increment_s) + with open(zarr_json_path, "w") as f: + json.dump(zarr_json, f, indent=2) + + def finalize(self, time_increment_s: Optional[float] = None) -> None: + """Finalize the dataset (blocking). + + Args: + time_increment_s: measured t-axis spacing to stamp into the metadata in + place of the configured one (see _stamp_time_increment); None keeps + the configured value. + """ if self._finalized: log.warning("Writer already finalized") return @@ -683,15 +746,7 @@ def finalize(self) -> None: # Update metadata with completion status (in zarr.json attributes) zarr_json_path = self._get_metadata_zarr_json_path() try: - if os.path.exists(zarr_json_path): - with open(zarr_json_path, "r") as f: - zarr_json = json.load(f) - attrs = zarr_json.get("attributes", {}) - if "_squid" in attrs: - attrs["_squid"]["acquisition_complete"] = True - zarr_json["attributes"] = attrs - with open(zarr_json_path, "w") as f: - json.dump(zarr_json, f, indent=2) + self._seal_metadata(zarr_json_path, complete=True, time_increment_s=time_increment_s) except (OSError, json.JSONDecodeError) as e: log.error(f"Failed to finalize zarr metadata at {zarr_json_path}: {e}") # Don't raise - data is already written, just log the metadata issue @@ -699,29 +754,41 @@ def finalize(self) -> None: self._finalized = True log.info(f"Zarr v3 dataset finalized: {self._config.output_path}") - def abort(self) -> None: - """Abort and clean up (blocking). + def abort( + self, + mark_aborted: bool = True, + extra_attrs: Optional[Dict[str, object]] = None, + time_increment_s: Optional[float] = None, + ) -> None: + """Seal the store as incomplete and clean up (blocking). + + Args: + mark_aborted: stamp ``aborted: True`` (a user/programmatic abort). + Pass False for non-abort incomplete seals (write errors, + dropped frames, under-delivery) so tooling can distinguish + "user pressed Stop" from "finished with missing planes". + extra_attrs: extra keys merged into ``_squid`` (e.g. error/drop + counts) alongside ``acquisition_complete: False``. + time_increment_s: measured t-axis spacing to stamp into the + metadata in place of the configured one; None keeps it. Uses try-finally to ensure cleanup always happens, even if an unexpected exception occurs during abort. """ - log.warning("Aborting Zarr writer...") + log.warning("Aborting Zarr writer..." if mark_aborted else "Sealing Zarr writer as incomplete...") try: # Mark as incomplete in metadata (in zarr.json attributes) zarr_json_path = self._get_metadata_zarr_json_path() try: - if os.path.exists(zarr_json_path): - with open(zarr_json_path, "r") as f: - zarr_json = json.load(f) - attrs = zarr_json.get("attributes", {}) - if "_squid" in attrs: - attrs["_squid"]["acquisition_complete"] = False - attrs["_squid"]["aborted"] = True - zarr_json["attributes"] = attrs - with open(zarr_json_path, "w") as f: - json.dump(zarr_json, f, indent=2) + self._seal_metadata( + zarr_json_path, + complete=False, + aborted=mark_aborted, + extra_attrs=extra_attrs, + time_increment_s=time_increment_s, + ) except (OSError, json.JSONDecodeError) as e: log.error(f"Failed to update abort metadata at {zarr_json_path}: {e}") finally: diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 9cc0dbba4..421c9f521 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -1,4 +1,5 @@ # set QT_API environment variable +import dataclasses import os import subprocess import sys @@ -46,8 +47,9 @@ class NDViewerMode(Enum): from control.NL5Widget import NL5Widget from control.core.contrast_manager import ContrastManager from control.core.live_controller import LiveController -from control.core.multi_point_controller import MultiPointController +from control.core.multi_point_controller import MultiPointController, NoOpCallbacks from control.core.mosaic_utils import parse_well_id +from control.core.record_zstack_controller import RecordZStackController from control.core.multi_point_utils import ( MultiPointControllerFunctions, AcquisitionParameters, @@ -596,6 +598,64 @@ def _detect_hcs_mode(self, scan_info: ScanCoordinates) -> bool: return len(scan_info.scan_region_names) > 0 +class QtRecordZStackController(RecordZStackController, QObject): + """Qt-aware wrapper for RecordZStackController. + + Emits ``acquisition_finished`` as a thread-safe Qt signal so the widget + can reset its Start button via a queued connection, and ``image_to_display`` + for the throttled acquisition live preview (both phases), regardless of + which thread the worker runs on. + """ + + acquisition_finished = Signal() + image_to_display = Signal(np.ndarray) + + def __init__( + self, + microscope, + live_controller, + laser_autofocus_controller, + objective_store, + scan_coordinates, + ): + display_fps = float(control._def.RECORD_ZSTACK_DISPLAY_FPS) + preview_on = display_fps > 0 + + # Map signal_acquisition_finished onto our Qt signal. signal_new_image + # carries the z-stack phase's per-plane frames (the inherited + # MultiPointWorkerBase capture path invokes it); the recording phase + # uses the plain display_frame_fn instead. Both funnel into + # image_to_display. Everything else stays a no-op. + callbacks = dataclasses.replace( + NoOpCallbacks, + signal_acquisition_finished=self._on_acquisition_finished, + signal_new_image=self._on_new_image if preview_on else NoOpCallbacks.signal_new_image, + ) + RecordZStackController.__init__( + self, + microscope=microscope, + live_controller=live_controller, + laser_autofocus_controller=laser_autofocus_controller, + objective_store=objective_store, + scan_coordinates=scan_coordinates, + callbacks=callbacks, + display_frame_fn=self._emit_display_frame if preview_on else None, + display_fps=display_fps, + ) + QObject.__init__(self) + + def _on_acquisition_finished(self): + self.acquisition_finished.emit() + + def _on_new_image(self, frame, info) -> None: + """Z-stack per-plane preview (signature: CameraFrame, CaptureInfo).""" + self.image_to_display.emit(frame.frame) + + def _emit_display_frame(self, image) -> None: + """Recording-phase preview: plain ndarray from StreamingCapture's tap.""" + self.image_to_display.emit(image) + + class HighContentScreeningGui(QMainWindow): fps_software_trigger = 100 LASER_BASED_FOCUS_TAB_NAME = "Laser-Based Focus" @@ -675,9 +735,10 @@ def __init__( self.well_selector_visible = False # Add this line to track well selector visibility self.multipointController: QtMultiPointController = None + self.recordZStackController: Optional[QtRecordZStackController] = None + self.recordZStackWidget: Optional[widgets.RecordZStackMultiPointWidget] = None self.streamHandler: core.QtStreamHandler = None self.autofocusController: AutoFocusController = None - self.imageSaver: core.ImageSaver = core.ImageSaver() self.imageDisplay: core.ImageDisplay = core.ImageDisplay() self.trackingController: core.TrackingController = None self.navigationViewer: core.NavigationViewer = None @@ -711,7 +772,6 @@ def __init__( self.filterControllerWidget: Optional[widgets.FilterControllerWidget] = None self.squidFilterWidget: Optional[widgets.SquidFilterWidget] = None self.laserEngineWidget: Optional[LaserEngineWidget] = None - self.recordingControlWidget: Optional[widgets.RecordingWidget] = None self.wellplateFormatWidget: Optional[widgets.WellplateFormatWidget] = None self.wellSelectionWidget: Optional[widgets.WellSelectionWidget] = None self.focusMapWidget: Optional[widgets.FocusMapWidget] = None @@ -899,6 +959,14 @@ def scan_coordinate_callback(update: ScanCoordinatesUpdate): scan_coordinates=self.scanCoordinates, laser_autofocus_controller=self.laserAutofocusController, ) + if ENABLE_RECORDING: + self.recordZStackController = QtRecordZStackController( + self.microscope, + self.liveController, + self.laserAutofocusController, + self.objectiveStore, + self.scanCoordinates, + ) def setup_hardware(self): self.camera.add_frame_callback(self.streamHandler.get_frame_callback()) @@ -974,11 +1042,6 @@ def load_widgets(self): if USE_SQUID_LASER_ENGINE and self.microscope.addons.squid_laser_engine is not None: self.laserEngineWidget = LaserEngineWidget(self.microscope.addons.squid_laser_engine) - self.recordingControlWidget = widgets.RecordingWidget( - self.streamHandler, - self.imageSaver, - channel_provider=lambda: self.liveController.currentConfiguration, - ) self.wellplateFormatWidget = widgets.WellplateFormatWidget( self.stage, self.navigationViewer, self.streamHandler, self.liveController ) @@ -1076,6 +1139,20 @@ def load_widgets(self): show_configurations=TRACKING_SHOW_MICROSCOPE_CONFIGURATIONS, ) + if ENABLE_RECORDING: + self.recordZStackWidget = widgets.RecordZStackMultiPointWidget( + self.stage, + self.navigationViewer, + self.recordZStackController, + self.liveController, + self.objectiveStore, + self.scanCoordinates, + well_selection_widget=self.wellSelectionWidget, + tab_widget=self.recordTabWidget, + laser_autofocus_controller=self.laserAutofocusController, + ) + self.recordZStackController.acquisition_finished.connect(self.recordZStackWidget.acquisition_is_finished) + if self.fluidics is not None: self._setup_fluidics_widgets() @@ -1246,7 +1323,7 @@ def setupRecordTabWidget(self): if ENABLE_TRACKING: self.recordTabWidget.addTab(self.trackingControlWidget, "Tracking") if ENABLE_RECORDING: - self.recordTabWidget.addTab(self.recordingControlWidget, "Simple Recording") + self.recordTabWidget.addTab(self.recordZStackWidget, "Record + Z-Stack") self.recordTabWidget.currentChanged.connect(lambda: self.resizeCurrentTab(self.recordTabWidget)) self.resizeCurrentTab(self.recordTabWidget) @@ -1444,7 +1521,6 @@ def setupSingleWindowLayout(self): def make_connections(self): self.streamHandler.signal_new_frame_received.connect(self.liveController.on_new_frame) - self.streamHandler.packet_image_to_write.connect(self.imageSaver.enqueue) self.liveController.signal_warning.connect(self._on_live_controller_warning) if ENABLE_FLEXIBLE_MULTIPOINT: @@ -1465,6 +1541,9 @@ def make_connections(self): ) self.fluidicsProtocolWidget.signal_run_notification.connect(self._handle_fluidics_notification) + if ENABLE_RECORDING: + self.recordZStackWidget.signal_acquisition_started.connect(self.toggleAcquisitionStart) + self.profileWidget.signal_profile_changed.connect(self.liveControlWidget.refresh_mode_list) self.liveControlWidget.signal_newExposureTime.connect(self.cameraSettingWidget.set_exposure_time) @@ -1534,6 +1613,8 @@ def make_connections(self): self.imageDisplay.image_to_display.connect(self.imageDisplayWindow.display_image) self.autofocusController.image_to_display.connect(self.imageDisplayWindow.display_image) self.multipointController.image_to_display.connect(self.imageDisplayWindow.display_image) + if self.recordZStackController is not None: + self.recordZStackController.image_to_display.connect(self.imageDisplayWindow.display_image) self.liveControlWidget.signal_autoLevelSetting.connect(self.imageDisplayWindow.set_autolevel) self.imageDisplayWindow.image_click_coordinates.connect(self.move_from_click_image) self.imageDisplayWindow.signal_z_um_delta.connect(self.move_z_from_scroll) @@ -1553,6 +1634,16 @@ def make_connections(self): self.objectivesWidget.signal_objective_changed.connect( self.wellplateMultiPointWidget.handle_objective_change ) + if ENABLE_RECORDING and self.recordZStackWidget is not None: + # Channel sets are per-objective AND per-profile: repopulate the tab's + # channel combos so stale names don't fall back to a no-illumination + # bare channel (dark acquisition). + self.objectivesWidget.signal_objective_changed.connect(self.recordZStackWidget.refresh_channel_list) + self.profileWidget.signal_profile_changed.connect(self.recordZStackWidget.refresh_channel_list) + # Well clicks rebuild this tab's FOV grid (wellplate's handler + # early-returns when its own tab is not current, so without this the + # selector gives no coverage feedback on the Record + Z-Stack tab). + self.wellSelectionWidget.signal_wellSelected.connect(self.recordZStackWidget.on_well_selection_changed) self.profileWidget.signal_profile_changed.connect( lambda: self.liveControlWidget.select_new_microscope_mode_by_name( @@ -2329,6 +2420,8 @@ def _refresh_channel_lists(self): self.flexibleMultiPointWidget.refresh_channel_list() if self.wellplateMultiPointWidget: self.wellplateMultiPointWidget.refresh_channel_list() + if self.recordZStackWidget is not None: + self.recordZStackWidget.refresh_channel_list() def onTabChanged(self, index): if self.fluidicsProtocolWidget is not None and index == self.recordTabWidget.indexOf( @@ -2345,6 +2438,13 @@ def onTabChanged(self, index): if ENABLE_WELLPLATE_MULTIPOINT else False ) + # Record + Z-Stack selects wells like Wellplate Multipoint, so it needs the + # well selector too (its validation rejects acquisitions with no wells). + is_record_zstack_acquisition = ( + (index == self.recordTabWidget.indexOf(self.recordZStackWidget)) + if self.recordZStackWidget is not None + else False + ) self.scanCoordinates.clear_regions() if is_wellplate_acquisition: @@ -2359,7 +2459,16 @@ def onTabChanged(self, index): # trigger flexible regions update self.flexibleMultiPointWidget.update_fov_positions() - self.toggleWellSelector(is_wellplate_acquisition and self.wellSelectionWidget.format != "glass slide") + if is_record_zstack_acquisition: + # Regions were cleared above; rebuild the FOV grid for the current + # well selection so the navigation viewer shows scan coverage. + # (Public entry point: it no-ops unless this tab is current, which + # it is here — the tab switch is what brought us into this branch.) + self.recordZStackWidget.on_well_selection_changed() + + self.toggleWellSelector( + self._tab_uses_well_selector(index) and self.wellSelectionWidget.format != "glass slide" + ) def resizeCurrentTab(self, tabWidget): current_widget = tabWidget.currentWidget() @@ -2469,6 +2578,23 @@ def connectWellSelectionWidget(self): if ENABLE_WELLPLATE_MULTIPOINT: self.wellSelectionWidget.signal_wellSelected.connect(self.wellplateMultiPointWidget.update_well_coordinates) + def _tab_uses_well_selector(self, index) -> bool: + """True if the record tab at ``index`` drives acquisitions from the well + selector (Wellplate Multipoint and Record + Z-Stack): both onTabChanged + and toggleAcquisitionStart must agree on this, or the selector is hidden + and never restored after an acquisition on one of these tabs.""" + is_wellplate = ( + (index == self.recordTabWidget.indexOf(self.wellplateMultiPointWidget)) + if ENABLE_WELLPLATE_MULTIPOINT + else False + ) + is_record_zstack = ( + (index == self.recordTabWidget.indexOf(self.recordZStackWidget)) + if self.recordZStackWidget is not None + else False + ) + return is_wellplate or is_record_zstack + def toggleWellSelector(self, show, remember_state=True): if show and self.imageDisplayTabs.tabText(self.imageDisplayTabs.currentIndex()) == "Live View": self.dock_wellSelection.setVisible(True) @@ -2515,16 +2641,14 @@ def toggleAcquisitionStart(self, acquisition_started): if acquisition_started: self.liveControlWidget.toggle_autolevel(not acquisition_started) - # hide well selector during acquisition - is_wellplate_acquisition = ( - (current_index == self.recordTabWidget.indexOf(self.wellplateMultiPointWidget)) - if ENABLE_WELLPLATE_MULTIPOINT - else False - ) - if is_wellplate_acquisition and self.wellSelectionWidget.format != "glass slide": + # hide well selector during acquisition; restore it afterwards on tabs + # that drive acquisitions from it. remember_state=False everywhere: + # acquisition start/stop is transient and must not overwrite the user's + # remembered visibility preference. + if self._tab_uses_well_selector(current_index) and self.wellSelectionWidget.format != "glass slide": self.toggleWellSelector(not acquisition_started, remember_state=False) else: - self.toggleWellSelector(False) + self.toggleWellSelector(False, remember_state=False) def _update_ram_monitor_visibility(self): """Update RAM monitor widget visibility based on setting.""" @@ -2813,6 +2937,17 @@ def _cleanup_common(self, for_restart: bool = False): except Exception: self.log.exception(f"Error closing multipoint controller during {context}") + # Clean up record+z-stack controller: aborts any running acquisition and + # seals the open Zarr writers instead of leaving a store half-written. + if self.recordZStackController is not None: + try: + # 30s: the worker's unwind can legitimately take this long on a + # slow disk (RecordingWriter finalize budget + job drain + stage + # restores); a 5s join let teardown race the still-running worker. + self.recordZStackController.close(timeout_s=30.0) + except Exception: + self.log.exception(f"Error closing record z-stack controller during {context}") + # Clean up NDViewer if self.ndviewerTab is not None: try: @@ -2964,7 +3099,6 @@ def _cleanup_common(self, for_restart: bool = False): # Close image display resources try: - self.imageSaver.close() self.imageDisplay.close() except Exception: if for_restart: diff --git a/software/control/widgets.py b/software/control/widgets.py index 1a4d756c9..4cd482759 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -948,13 +948,68 @@ def apply_and_exit(self): self.close() +_ACQUISITION_WIDGET_TYPE_DISPLAY_NAMES = { + "wellplate": "Wellplate Multipoint", + "flexible": "Flexible Multipoint", + "record_zstack": "Record + Z-Stack", +} + + +def _parse_well_name(well_name: str): + """Parse well name like 'C4' to (row, col) indices. Returns (None, None) if unparseable. + + The strict letters-then-digits regex rejects malformed names outright; the + row/column arithmetic is delegated to the canonical parse_well_id. + """ + if not re.match(r"^[A-Za-z]+\d+$", well_name): + return None, None + from control.core.mosaic_utils import parse_well_id + + try: + return parse_well_id(well_name) + except ValueError: # e.g. column 0 ("A0") + return None, None + + +def _load_well_regions(well_selection_widget, regions) -> None: + """Select *regions* (from a dropped acquisition YAML) in *well_selection_widget*. + + Shared by WellplateMultiPointWidget and RecordZStackMultiPointWidget, both of which + receive the same shared well-selection grid instance via gui_hcs.py. No-op when + well_selection_widget is None (glass-slide mode / not yet wired). + """ + if not well_selection_widget: + return + + well_selection_widget.blockSignals(True) + try: + well_selection_widget.clearSelection() + has_selection = False + for region in regions: + well_name = region.get("name", "") + if not well_name: + continue + row, col = _parse_well_name(well_name) + if row is not None and col is not None: + if row < well_selection_widget.rowCount() and col < well_selection_widget.columnCount(): + item = well_selection_widget.item(row, col) + if item: + item.setSelected(True) + has_selection = True + finally: + well_selection_widget.blockSignals(False) + + well_selection_widget.signal_wellSelected.emit(has_selection) + + class AcquisitionYAMLDropMixin: """Mixin class providing drag-and-drop functionality for loading acquisition YAML files. Widgets using this mixin must: 1. Call `self.setAcceptDrops(True)` in __init__ - 2. Have `self._log`, `self.multipointController`, `self.objectiveStore` attributes - 3. Implement `_get_expected_widget_type()` returning "wellplate" or "flexible" + 2. Have `self._log`, `self.objectiveStore` attributes, and override + `_get_camera_for_binning_check()` unless they have `self.multipointController.camera` + 3. Implement `_get_expected_widget_type()` returning "wellplate", "flexible", or "record_zstack" 4. Implement `_apply_yaml_settings(yaml_data)` to apply settings to the widget """ @@ -1022,11 +1077,18 @@ def _get_expected_widget_type(self) -> str: """Return the expected widget_type for this widget. Override in subclass.""" raise NotImplementedError("Subclass must implement _get_expected_widget_type()") - def _get_other_widget_name(self) -> str: - """Return the name of the other widget type for error messages.""" - if self._get_expected_widget_type() == "wellplate": - return "Flexible Multipoint" - return "Wellplate Multipoint" + def _get_other_widget_name(self, actual_widget_type: str) -> str: + """Return the display name of the widget that handles *actual_widget_type* files.""" + return _ACQUISITION_WIDGET_TYPE_DISPLAY_NAMES.get(actual_widget_type, actual_widget_type) + + def _get_camera_for_binning_check(self): + """Return the camera used for the binning-mismatch check on load. + + Default assumes self.multipointController.camera (wellplate/flexible). + Widgets without a multipointController (e.g. RecordZStackMultiPointWidget) + must override this. + """ + return getattr(self.multipointController, "camera", None) def _load_acquisition_yaml(self, file_path: str) -> bool: """Load acquisition settings from YAML file. @@ -1049,14 +1111,14 @@ def _load_acquisition_yaml(self, file_path: str) -> bool: self, "Widget Type Mismatch", f"This YAML is for '{yaml_data.widget_type}' mode.\n" - f"Please drop this file on the {self._get_other_widget_name()} widget instead.", + f"Please drop this file on the {self._get_other_widget_name(yaml_data.widget_type)} widget instead.", ) return False # Validate hardware current_binning = (1, 1) try: - camera = getattr(self.multipointController, "camera", None) + camera = self._get_camera_for_binning_check() if camera and hasattr(camera, "get_binning"): current_binning = tuple(camera.get_binning()) except Exception as e: @@ -1073,8 +1135,18 @@ def _load_acquisition_yaml(self, file_path: str) -> bool: dialog.exec_() return False - # Apply settings with signal blocking - self._apply_yaml_settings(yaml_data) + # Apply settings with signal blocking. Subclasses (currently only + # RecordZStackMultiPointWidget) may validate embedded channel dicts via + # pydantic inside _apply_yaml_settings(); a malformed-but-syntactically-valid + # YAML can raise there. Catch broadly here (mirroring the parse-error + # handling above) so a bad drop/button-load degrades to a warning dialog + # instead of crashing the Qt slot. + try: + self._apply_yaml_settings(yaml_data) + except Exception as e: + self._log.error(f"Failed to apply YAML settings from {file_path}: {e}") + QMessageBox.warning(self, "Load Error", f"Failed to apply YAML settings:\n{e}") + return False self._log.info(f"Loaded acquisition settings from: {file_path}") return True @@ -5303,119 +5375,6 @@ def update_displacement_um_display(self, displacement=None): self.update_slider() -class RecordingWidget(QFrame): - def __init__(self, streamHandler, imageSaver, main=None, channel_provider=None, *args, **kwargs): - super().__init__(*args, **kwargs) - self.imageSaver = imageSaver - self.streamHandler = streamHandler - self._channel_provider = channel_provider - self.add_components() - self.setFrameStyle(QFrame.Panel | QFrame.Raised) - - def add_components(self): - self.btn_setSavingDir = QPushButton("Browse") - self.btn_setSavingDir.setDefault(False) - self.btn_setSavingDir.setIcon(QIcon("icon/folder.png")) - - self.lineEdit_savingDir = QLineEdit() - self.lineEdit_savingDir.setReadOnly(True) - self.lineEdit_savingDir.setText(DEFAULT_SAVING_PATH) - self.imageSaver.set_base_path(DEFAULT_SAVING_PATH) - - self.lineEdit_experimentID = QLineEdit() - - self.entry_saveFPS = QDoubleSpinBox() - self.entry_saveFPS.setKeyboardTracking(False) - self.entry_saveFPS.setMinimum(0.02) - self.entry_saveFPS.setMaximum(1000) - self.entry_saveFPS.setSingleStep(1) - self.entry_saveFPS.setValue(1) - self.streamHandler.set_save_fps(1) - - self.entry_timeLimit = QSpinBox() - self.entry_timeLimit.setKeyboardTracking(False) - self.entry_timeLimit.setMinimum(-1) - self.entry_timeLimit.setMaximum(60 * 60 * 24 * 30) - self.entry_timeLimit.setSingleStep(1) - self.entry_timeLimit.setValue(-1) - - self.btn_record = QPushButton("Record") - self.btn_record.setCheckable(True) - self.btn_record.setChecked(False) - self.btn_record.setDefault(False) - - grid_line1 = QGridLayout() - grid_line1.addWidget(QLabel("Saving Path")) - grid_line1.addWidget(self.lineEdit_savingDir, 0, 1) - grid_line1.addWidget(self.btn_setSavingDir, 0, 2) - - grid_line2 = QGridLayout() - grid_line2.addWidget(QLabel("Experiment ID"), 0, 0) - grid_line2.addWidget(self.lineEdit_experimentID, 0, 1) - - grid_line3 = QGridLayout() - grid_line3.addWidget(QLabel("Saving FPS"), 0, 0) - grid_line3.addWidget(self.entry_saveFPS, 0, 1) - grid_line3.addWidget(QLabel("Time Limit (s)"), 0, 2) - grid_line3.addWidget(self.entry_timeLimit, 0, 3) - - self.grid = QVBoxLayout() - self.grid.addLayout(grid_line1) - self.grid.addLayout(grid_line2) - self.grid.addLayout(grid_line3) - self.grid.addWidget(self.btn_record) - self.setLayout(self.grid) - - # add and display a timer - to be implemented - # self.timer = QTimer() - - # connections - self.btn_setSavingDir.clicked.connect(self.set_saving_dir) - self.btn_record.clicked.connect(self.toggle_recording) - self.entry_saveFPS.valueChanged.connect(self.streamHandler.set_save_fps) - self.entry_timeLimit.valueChanged.connect(self.imageSaver.set_recording_time_limit) - self.imageSaver.stop_recording.connect(self.stop_recording) - - def set_saving_dir(self): - save_dir_base = QFileDialog.getExistingDirectory(None, "Select Folder") - if not save_dir_base: - return - self.imageSaver.set_base_path(save_dir_base) - self.lineEdit_savingDir.setText(save_dir_base) - - def toggle_recording(self, pressed): - if pressed: - self.lineEdit_experimentID.setEnabled(False) - self.btn_setSavingDir.setEnabled(False) - if self._channel_provider is not None: - self.imageSaver.set_channel_provider(self._channel_provider) - try: - self.imageSaver.start_new_experiment(self.lineEdit_experimentID.text()) - except Exception: - # start_new_experiment logged the cause; restore UI so the user can retry. - self.imageSaver.set_channel_provider(None) - self.lineEdit_experimentID.setEnabled(True) - self.btn_setSavingDir.setEnabled(True) - self.btn_record.setChecked(False) - return - self.streamHandler.start_recording() - else: - self.streamHandler.stop_recording() - self.imageSaver.stop_experiment() - self.imageSaver.set_channel_provider(None) - self.lineEdit_experimentID.setEnabled(True) - self.btn_setSavingDir.setEnabled(True) - - # stop_recording can be called by imageSaver - def stop_recording(self): - self.lineEdit_experimentID.setEnabled(True) - self.btn_record.setChecked(False) - self.streamHandler.stop_recording() - self.imageSaver.stop_experiment() - self.imageSaver.set_channel_provider(None) - self.btn_setSavingDir.setEnabled(True) - - class NavigationWidget(QFrame): def __init__( self, @@ -9822,7 +9781,7 @@ def _apply_yaml_settings(self, yaml_data): # Load well regions if present and update XY checkbox state if yaml_data.wellplate_regions: - self._load_well_regions(yaml_data.wellplate_regions) + _load_well_regions(self.well_selection_widget, yaml_data.wellplate_regions) self.checkbox_xy.setChecked(True) else: self.checkbox_xy.setChecked(False) @@ -9842,59 +9801,6 @@ def _apply_yaml_settings(self, yaml_data): self.update_tab_styles() self.update_coordinates() - def _load_well_regions(self, regions): - """Load well regions from YAML and select them in the well selector.""" - if not self.well_selection_widget: - return - - # Block signals during batch selection to prevent multiple updates - self.well_selection_widget.blockSignals(True) - - try: - # Clear current selection - self.well_selection_widget.clearSelection() - - has_selection = False - # Parse well names and select them - for region in regions: - well_name = region.get("name", "") - if not well_name: - continue - - # Parse well name (e.g., "C4" -> row=2, col=3) - row, col = self._parse_well_name(well_name) - if row is not None and col is not None: - # Check bounds - if row < self.well_selection_widget.rowCount() and col < self.well_selection_widget.columnCount(): - item = self.well_selection_widget.item(row, col) - if item: - item.setSelected(True) - has_selection = True - finally: - # Unblock signals - self.well_selection_widget.blockSignals(False) - - # Emit signal once to trigger coordinate update - self.well_selection_widget.signal_wellSelected.emit(has_selection) - - def _parse_well_name(self, well_name: str): - """Parse well name like 'C4' to (row, col) indices.""" - match = re.match(r"^([A-Z]+)(\d+)$", well_name.upper()) - if not match: - return None, None - - row_str, col_str = match.groups() - - # Convert row letters to index (A=0, B=1, ..., AA=26, etc.) - row = 0 - for char in row_str: - row = row * 26 + (ord(char) - ord("A") + 1) - row -= 1 # Convert to 0-based index - - col = int(col_str) - 1 # Convert to 0-based index - - return row, col - class FocusMapWidget(QFrame): """Widget for managing focus map points and surface fitting""" @@ -16063,3 +15969,1620 @@ def _format_display_message(self, message: str) -> str: if len(msg) > 60: msg = msg[:57] + "..." return msg + + +def _set_layout_widgets_visible(layout, visible: bool) -> None: + """Recursively show/hide every widget inside *layout*, including nested + sub-layouts. + + Used to collapse a phase section's body when its checkbox is unchecked, so an + unchecked phase's fields are hidden and take up no vertical space. + """ + for i in range(layout.count()): + item = layout.itemAt(i) + w = item.widget() + if w is not None: + w.setVisible(visible) + continue + sub_layout = item.layout() + if sub_layout is not None: + _set_layout_widgets_visible(sub_layout, visible) + + +def _set_header_not_bold(header: QHeaderView) -> None: + """Un-bold a QHeaderView's section labels (Qt's default header font is bold).""" + font = header.font() + font.setBold(False) + header.setFont(font) + + +def _make_channel_settings_table(rows: int) -> QTableWidget: + """A Channel | Exp (ms) | Gain | Illum (%) | actions table for the Record + Z-Stack tab.""" + table = QTableWidget(rows, 5) + table.setHorizontalHeaderLabels(["Channel", "Exp (ms)", "Gain", "Illum (%)", ""]) + hdr = table.horizontalHeader() + hdr.setSectionResizeMode(0, QHeaderView.Stretch) + for column in range(1, 5): + hdr.setSectionResizeMode(column, QHeaderView.ResizeToContents) + _set_header_not_bold(hdr) + table.setSelectionBehavior(QAbstractItemView.SelectRows) + # Force the 5 columns to fit (Channel truncates via Stretch) instead of + # showing a horizontal scrollbar. No fixed width: the panel's actual + # available width varies with which other tab last drove the main + # window's width, so the table fills whatever space it's given rather + # than gambling on a specific pixel value. + table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + table.setMinimumWidth(300) + return table + + +def _add_channel_settings_spinboxes( + table: QTableWidget, + row: int, + exposure: float, + gain: float, + illumination: float, + max_widths: Tuple[int, int, int], +) -> Tuple[QDoubleSpinBox, QDoubleSpinBox, QDoubleSpinBox]: + """Put the exposure, gain and illumination editors in columns 1-3 of *row* and return them.""" + exp_spin = QDoubleSpinBox() + exp_spin.setDecimals(1) + # 0.1 ms: the smallest value one decimal can show. A smaller minimum rounds to 0.0 at one decimal, + # and a 0 ms exposure is rejected by the camera mid-run (ToupTek: "The parameter is incorrect"). + exp_spin.setRange(0.1, 60000) + exp_spin.setValue(exposure) + exp_spin.setSuffix(" ms") + + gain_spin = QDoubleSpinBox() + gain_spin.setRange(0.0, 100.0) + gain_spin.setValue(gain) + gain_spin.setDecimals(2) + + illum_spin = QDoubleSpinBox() + illum_spin.setRange(0.0, 100.0) + illum_spin.setValue(illumination) + illum_spin.setSuffix(" %") + illum_spin.setDecimals(1) + + spinboxes = (exp_spin, gain_spin, illum_spin) + for column, (spinbox, max_width) in enumerate(zip(spinboxes, max_widths), start=1): + spinbox.setKeyboardTracking(False) + spinbox.setMaximumWidth(max_width) + table.setCellWidget(row, column, spinbox) + return spinboxes + + +_TAB_INACTIVE_STYLE = """ + QFrame { + border: 1px solid palette(mid); + border-radius: 2px; + } +""" + + +def _tab_active_style(border_color: str) -> str: + return f""" + QFrame {{ + border: 1px solid {border_color}; + border-radius: 2px; + }} + """ + + +def _tab_controls_style(background_rgb: str) -> str: + return f""" + QFrame {{ + background-color: rgba({background_rgb}, 0.15); + }} + QFrame QComboBox, QFrame QSpinBox, QFrame QDoubleSpinBox {{ + background-color: white; + color: black; + }} + QFrame QComboBox QAbstractItemView {{ + background-color: white; + color: black; + selection-background-color: palette(highlight); + selection-color: palette(highlighted-text); + }} + QFrame QLabel {{ + background-color: transparent; + }} + """ + + +class RecordZStackMultiPointWidget(AcquisitionYAMLDropMixin, QFrame): + """Single-column 'Record + Z-Stack' acquisition tab. + + Construction pattern mirrors WellplateMultiPointWidget: + - sections stacked vertically in a scroll area + - reads channels via liveController.get_channels(objectiveStore.current_objective) + - base-path / experiment-ID fields identical to the wellplate widget + """ + + signal_acquisition_started = Signal(bool) # True = started, False = finished + + def __init__( + self, + stage, + navigationViewer, + recordZStackController, + liveController, + objectiveStore, + scanCoordinates, + well_selection_widget=None, + tab_widget=None, + laser_autofocus_controller=None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.setAcceptDrops(True) # Enable drag-and-drop for loading acquisition YAML + self._log = squid.logging.get_logger(self.__class__.__name__) + self.stage = stage + self.navigationViewer = navigationViewer + self.recordZStackController = recordZStackController + self.liveController = liveController + self.objectiveStore = objectiveStore + self.scanCoordinates = scanCoordinates + self.well_selection_widget = well_selection_widget + self.tab_widget: Optional[QTabWidget] = tab_widget + self.laser_autofocus_controller = laser_autofocus_controller + + self._add_components() + + @property + def _zstack_channel_names(self) -> List[str]: + """Channel names currently in the Z-Stack table (column 0), in row order. + + Derived from the table itself so there is a single source of truth — + add/remove row operations only touch the table. + """ + table = self.zstack_channel_table + items = (table.item(row, 0) for row in range(table.rowCount())) + return [item.text() for item in items if item is not None] + + # ---------------------------------------------------------------------- build UI + + def _add_components(self) -> None: + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(4, 2, 4, 2) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + # QScrollArea's viewport and its content widget both auto-fill an + # opaque palette-Window background by default, which under Fusion is + # a visibly darker gray than the tab pane behind it — the other + # multipoint tabs don't use a QScrollArea, so they show the pane's + # own (lighter) background instead. Disabling auto-fill lets that + # pane background show through instead (a QSS "background: + # transparent" rule was tried first, but under Fusion it cascades + # into child widgets — buttons/combos/checkboxes lose their chrome + # and render as solid black boxes). + scroll.viewport().setAutoFillBackground(False) + inner = QWidget() + inner.setAutoFillBackground(False) + layout = QVBoxLayout(inner) + layout.setContentsMargins(4, 2, 4, 2) + layout.setSpacing(4) + + def _section_divider() -> QFrame: + # Thin sunken line to mark a section boundary — distinct from + # the boxed/sunken look a whole QGroupBox border gives under the + # Fusion style, which is what these sections used to use. + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Sunken) + return sep + + layout.addWidget(self._build_output_group()) + layout.addWidget(_section_divider()) + layout.addWidget(self._build_wells_fov_group()) + layout.addWidget(_section_divider()) + layout.addWidget(self._build_recording_group()) + layout.addWidget(_section_divider()) + layout.addWidget(self._build_zstack_group()) + layout.addWidget(_section_divider()) + layout.addWidget(self._build_start_group()) + layout.addStretch(1) + + # Gray out Start Acquisition when neither phase is enabled, instead of + # only catching it via validate()'s error dialog after the click. + self.checkbox_recording.toggled.connect(lambda _checked: self._update_start_button_enabled()) + self.checkbox_zstack.toggled.connect(lambda _checked: self._update_start_button_enabled()) + self._update_start_button_enabled() + + scroll.setWidget(inner) + outer_layout.addWidget(scroll) + + def _build_output_group(self) -> QFrame: + # Plain QFrame (no border/title chrome) — matches the rest of the app's + # convention (e.g. WellplateMultiPointWidget's saving-path/experiment-ID + # rows aren't boxed at all); a QGroupBox still renders a visible box + # under the Fusion style even when flat. + grp = QFrame() + vbox = QVBoxLayout(grp) + vbox.setContentsMargins(4, 2, 4, 2) + vbox.setSpacing(6) + + # Fixed-width label column so both input fields start at the same x + # (sized for the wider "Experiment ID:" label). + label_col_w = 112 + + # Row 1: Base path lineedit + Browse button + row1 = QHBoxLayout() + row1.setSpacing(6) + base_label = QLabel("Base path:") + base_label.setFixedWidth(label_col_w) + base_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + row1.addWidget(base_label) + self.lineEdit_savingDir = QLineEdit() + last_path = get_last_used_saving_path() + self.lineEdit_savingDir.setText(last_path) + row1.addWidget(self.lineEdit_savingDir, 1) # stretch=1 → fills available space + self.btn_setSavingDir = QPushButton("Browse") + self.btn_setSavingDir.setIcon(QIcon("icon/folder.png")) + self.btn_setSavingDir.clicked.connect(self._browse_saving_dir) + row1.addWidget(self.btn_setSavingDir) + vbox.addLayout(row1) + + # Row 2: Experiment ID on its own row + row2 = QHBoxLayout() + row2.setSpacing(6) + exp_label = QLabel("Experiment ID:") + exp_label.setFixedWidth(label_col_w) + exp_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + row2.addWidget(exp_label) + self.lineEdit_experimentID = QLineEdit() + self.lineEdit_experimentID.setPlaceholderText("record_zstack") + row2.addWidget(self.lineEdit_experimentID, 1) # stretch=1 → fills to the right edge + vbox.addLayout(row2) + + return grp + + def _build_wells_fov_group(self) -> QFrame: + # Plain QFrame — see _build_output_group. + grp = QFrame() + vbox = QVBoxLayout(grp) + vbox.setContentsMargins(4, 2, 4, 2) + vbox.setSpacing(6) + + # Consistent widths so label+field pairs line up cleanly across rows. + field_w = 70 # spinbox / combo width + pair_gap = 6 # horizontal gap between adjacent label+field pairs + + def _pair_label(text: str) -> QLabel: + lbl = QLabel(text) + lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + return lbl + + # First-column labels share a width so the first field of each row aligns + # (sized for the wider "Overlap:" label). + first_label_w = 52 + + # --- XY / Time tabs row: mirrors WellplateMultiPointWidget's XY/Z/Time + # tabs (checkbox + combo in a frame that highlights when active). Z is + # skipped here since the "Z-Stack phase" group below already covers + # z-stacking for this widget. Laser AF is a standalone toggle, so it's + # placed as a plain checkbox in this row rather than in its own tab + # frame — mirroring how WellplateMultiPointWidget places its own Laser + # AF checkbox as a bare widget outside the tabs. + self.checkbox_xy = QCheckBox("XY") + self.checkbox_xy.setChecked(True) + self.combobox_xy_mode = QComboBox() + self.combobox_xy_mode.addItems(["Current Position", "Select Wells"]) + self.combobox_xy_mode.setMaximumWidth(130) + # Select Wells (tile a grid over the selected wells) is the default so + # existing tiling behavior is unchanged out of the box; Current + # Position is a single FOV at the live stage position, bypassing + # well selection entirely. + self.combobox_xy_mode.setCurrentText("Select Wells") + self.xy_frame = QFrame() + xy_layout = QHBoxLayout(self.xy_frame) + xy_layout.setContentsMargins(8, 4, 8, 4) + xy_layout.addWidget(self.checkbox_xy) + xy_layout.addWidget(self.combobox_xy_mode) + + self.checkbox_time = QCheckBox("Time") + self.checkbox_time.setChecked(False) + self.time_frame = QFrame() + time_frame_layout = QHBoxLayout(self.time_frame) + time_frame_layout.setContentsMargins(8, 4, 8, 4) + time_frame_layout.addWidget(self.checkbox_time) + time_frame_layout.addStretch() + + self.checkbox_laser_af = QCheckBox("Laser AF") + self.checkbox_laser_af.setChecked(False) + + tabs_row = QHBoxLayout() + tabs_row.setSpacing(4) + tabs_row.addWidget(self.xy_frame, 2) + tabs_row.addWidget(self.time_frame, 1) + tabs_row.addWidget(self.checkbox_laser_af) + vbox.addLayout(tabs_row) + + # Row 1: FOV overlap + Region shape + Region size (hidden when XY unchecked) + self.xy_controls_frame = QFrame() + row1 = QHBoxLayout(self.xy_controls_frame) + row1.setContentsMargins(0, 0, 0, 0) + row1.setSpacing(4) + + overlap_label = _pair_label("Overlap:") + overlap_label.setFixedWidth(first_label_w) + row1.addWidget(overlap_label) + self.entry_overlap = QDoubleSpinBox() + self.entry_overlap.setRange(-1000, 99) + self.entry_overlap.setValue(10) + self.entry_overlap.setSuffix(" %") + self.entry_overlap.setKeyboardTracking(False) + self.entry_overlap.setFixedWidth(field_w) + row1.addWidget(self.entry_overlap) + + row1.addSpacing(pair_gap) + row1.addWidget(_pair_label("Shape:")) + self.combobox_shape = QComboBox() + self.combobox_shape.addItems(["Square", "Circle", "Rectangle"]) + # Wider than field_w: word options ("Rectangle") need more room than + # the numeric+suffix spinboxes elsewhere in this row. + self.combobox_shape.setFixedWidth(90) + row1.addWidget(self.combobox_shape) + + row1.addSpacing(pair_gap) + row1.addWidget(_pair_label("Size:")) + self.entry_scan_size = QDoubleSpinBox() + self.entry_scan_size.setRange(0.1, 100) + self.entry_scan_size.setValue(0.1) + self.entry_scan_size.setSuffix(" mm") + self.entry_scan_size.setDecimals(2) + self.entry_scan_size.setKeyboardTracking(False) + self.entry_scan_size.setFixedWidth(field_w) + row1.addWidget(self.entry_scan_size) + + row1.addStretch(1) + vbox.addWidget(self.xy_controls_frame) + + # Row 2: Nt + dt (hidden when Time unchecked) + self.time_controls_frame = QFrame() + row2 = QHBoxLayout(self.time_controls_frame) + row2.setContentsMargins(0, 0, 0, 0) + row2.setSpacing(4) + + nt_label = _pair_label("Nt:") + nt_label.setFixedWidth(first_label_w) + row2.addWidget(nt_label) + self.entry_Nt = QSpinBox() + self.entry_Nt.setMinimum(1) + self.entry_Nt.setMaximum(5000) + self.entry_Nt.setValue(1) + self.entry_Nt.setFixedWidth(field_w) + row2.addWidget(self.entry_Nt) + + row2.addSpacing(pair_gap) + row2.addWidget(_pair_label("dt:")) + self.entry_dt = QDoubleSpinBox() + self.entry_dt.setRange(0, 24 * 3600) + self.entry_dt.setValue(0) + self.entry_dt.setSuffix(" s") + self.entry_dt.setKeyboardTracking(False) + self.entry_dt.setFixedWidth(field_w) + row2.addWidget(self.entry_dt) + + row2.addStretch(1) + vbox.addWidget(self.time_controls_frame) + + # Wire FOV-grid signals + self.entry_overlap.valueChanged.connect(self._update_scan_regions) + self.entry_scan_size.valueChanged.connect(self._update_scan_regions) + self.combobox_shape.currentIndexChanged.connect(self._update_scan_regions) + + # Wire XY/Time toggle behavior and initialize their visibility/styling. + self._stored_time_params = None + self._stored_xy_mode = None + self.checkbox_xy.toggled.connect(self._on_xy_toggled) + self.combobox_xy_mode.currentTextChanged.connect(self._on_xy_mode_changed) + self.checkbox_time.toggled.connect(self._on_time_toggled) + self._on_xy_toggled(self.checkbox_xy.isChecked()) + self._on_time_toggled(self.checkbox_time.isChecked()) + + return grp + + def _on_xy_toggled(self, checked: bool) -> None: + """Enable/disable the mode combo, forcing Current Position (single + stage-position FOV) when unchecked and restoring the previously + selected mode when re-checked — mirrors WellplateMultiPointWidget's + on_xy_toggled. + """ + self.combobox_xy_mode.setEnabled(checked) + old_mode = self.combobox_xy_mode.currentText() + if checked: + if self._stored_xy_mode is not None: + self.combobox_xy_mode.setCurrentText(self._stored_xy_mode) + else: + self._stored_xy_mode = self.combobox_xy_mode.currentText() + self.combobox_xy_mode.setCurrentText("Current Position") + self._update_tab_styles() + if self.combobox_xy_mode.currentText() == old_mode: + # currentTextChanged didn't fire (mode unchanged), so + # _on_xy_mode_changed never rebuilt the region — do it here. + self._update_scan_regions() + + def _on_xy_mode_changed(self, mode: str) -> None: + """Show the FOV-tiling controls only for Select Wells; Current + Position always uses a fixed single FOV at the live stage position. + """ + self.xy_controls_frame.setVisible(self.checkbox_xy.isChecked() and mode == "Select Wells") + self._update_scan_regions() + + def _on_time_toggled(self, checked: bool) -> None: + """Show/hide the Nt/dt controls, resetting to a single timepoint when + unchecked and restoring the previous Nt/dt when re-checked (mirrors + WellplateMultiPointWidget's store/restore of Time parameters). + """ + self.time_controls_frame.setVisible(checked) + if checked: + if self._stored_time_params is not None: + nt, dt = self._stored_time_params + self.entry_Nt.setValue(nt) + self.entry_dt.setValue(dt) + else: + self._stored_time_params = (self.entry_Nt.value(), self.entry_dt.value()) + self.entry_Nt.setValue(1) + self.entry_dt.setValue(0) + self._update_tab_styles() + + def _update_tab_styles(self) -> None: + """Border/background styling for the XY/Time tabs (colors mirror + WellplateMultiPointWidget.update_tab_styles: orange for XY, green for Time). + """ + xy_on = self.checkbox_xy.isChecked() + self.xy_frame.setStyleSheet(_tab_active_style("#FF8C00") if xy_on else _TAB_INACTIVE_STYLE) + self.xy_controls_frame.setStyleSheet(_tab_controls_style("255, 140, 0") if xy_on else "") + time_on = self.checkbox_time.isChecked() + self.time_frame.setStyleSheet(_tab_active_style("#00A000") if time_on else _TAB_INACTIVE_STYLE) + self.time_controls_frame.setStyleSheet(_tab_controls_style("0, 160, 0") if time_on else "") + + def _build_recording_group(self) -> QFrame: + # Plain QFrame (no border/title chrome), matching the rest of the app's + # convention (e.g. WellplateMultiPointWidget) rather than a QGroupBox — + # a checkable QGroupBox still renders a visible box under the Fusion + # style even when flat. The checkbox is a standalone header widget so + # collapsing its content doesn't require any QGroupBox-specific behavior. + grp = QFrame() + outer_vbox = QVBoxLayout(grp) + outer_vbox.setContentsMargins(4, 2, 4, 2) + outer_vbox.setSpacing(4) + + self.checkbox_recording = QCheckBox("Recording phase") + self.checkbox_recording.setChecked(True) + header_font = self.checkbox_recording.font() + header_font.setBold(True) + self.checkbox_recording.setFont(header_font) + outer_vbox.addWidget(self.checkbox_recording) + + # No margins here — outer_vbox already applies the frame-level + # (4, 2, 4, 2) margin; adding another would double-indent this + # content relative to the checkbox header above it. + vbox = QVBoxLayout() + vbox.setContentsMargins(0, 0, 0, 0) + vbox.setSpacing(4) + outer_vbox.addLayout(vbox) + self._recording_content_vbox = vbox + + # Row 0: single-row channel table (Channel | Exp (ms) | Gain | Illum (%) | ↻) + self.recording_channel_table = _make_channel_settings_table(rows=1) + self.recording_channel_table.verticalHeader().setVisible(False) + + # Col 0: channel combo. Let it shrink within the Stretch column instead of + # demanding its full text width (otherwise the long channel name forces the + # column wide and pushes Exp/Gain/Illum behind a horizontal scrollbar). + self._recording_ch_combo = QComboBox() + self._populate_channel_combo(self._recording_ch_combo) + self._recording_ch_combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) + self._recording_ch_combo.setMinimumContentsLength(6) + self._recording_ch_combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + self.recording_channel_table.setCellWidget(0, 0, self._recording_ch_combo) + + # Cols 1-3: exposure, gain, illumination (widths capped so the Channel column keeps + # room for full names) + self._recording_exp_spin, self._recording_gain_spin, self._recording_illum_spin = ( + _add_channel_settings_spinboxes(self.recording_channel_table, 0, 50.0, 0.0, 50.0, (68, 48, 60)) + ) + + # Col 4: ↻ refresh-from-channel-config button + self.btn_copy_from_live = QPushButton("↻") + self.btn_copy_from_live.setToolTip("Refresh exposure/gain/illumination from this channel's current settings") + self.btn_copy_from_live.setMaximumWidth(28) + self.btn_copy_from_live.clicked.connect(self._copy_recording_from_live) + self.recording_channel_table.setCellWidget(0, 4, self.btn_copy_from_live) + + # Seed the row from the selected channel's own configured settings + # (not the hardcoded defaults above) whenever the selection changes, + # including the initial population. + self._recording_ch_combo.currentIndexChanged.connect(self._on_recording_channel_changed) + self._on_recording_channel_changed(self._recording_ch_combo.currentIndex()) + + # Size the table to exactly fit the header + single row, now that the + # cell widgets (which are taller than plain text) are in place — doing + # this before the widgets were added left a fixed height too tall, + # showing empty space below the row. The padding is the table's own + # top+bottom frame border (not a guessed constant), so it stays + # correct if the frame style changes. + self.recording_channel_table.resizeRowsToContents() + self.recording_channel_table.setFixedHeight( + self.recording_channel_table.horizontalHeader().height() + + self.recording_channel_table.rowHeight(0) + + 2 * self.recording_channel_table.frameWidth() + ) + + vbox.addWidget(self.recording_channel_table) + + # Row 1 (fixed): FPS + Duration. + # Nz/dz/offset reflow dynamically: they append to row 1 (after + # Duration) whenever at most 2 of them are visible, but move down to + # their own row 2 together when all 3 are visible at once (Nz>1 AND + # Laser AF on) — that's too many fields for one row. See + # _rebuild_recording_rows, called from _update_recording_planes_ui + # whenever Nz or Laser AF change. + # FPS/Dur always use their own compact widths (fit the actual text, + # not shared with anything). Nz/dz normally use their OWN, smaller + # compact widths too — they only borrow FPS's/Dur's (wider) widths + # when _rebuild_recording_rows puts them on row 2 for alignment; + # forcing the wider shared width unconditionally (as an earlier pass + # here did) left visible dead space after "Nz:"/"dz:" even in the + # common single-row case, where nothing needs to align with them. + self._recording_col1_label_w = 30 # fits "FPS:" + self._recording_col1_entry_w = 50 # fits FPS's spinbox + self._recording_col2_label_w = 28 # fits "Dur:" + self._recording_col2_entry_w = 64 # fits Duration's spinbox + self._recording_nz_label_w = 24 # fits "Nz:" on its own + self._recording_nz_entry_w = 44 # fits Nz's spinbox on its own + self._recording_dz_label_w = 22 # fits "dz:" on its own + self._recording_dz_entry_w = 78 # fits dz's spinbox+suffix on its own + + self.fps_row = QHBoxLayout() + self.fps_row.setSpacing(4) + + label_fps = QLabel("FPS:") + label_fps.setFixedWidth(self._recording_col1_label_w) + self.fps_row.addWidget(label_fps) + self.entry_fps = QDoubleSpinBox() + self.entry_fps.setRange(1, 1000) + self.entry_fps.setDecimals(0) + self.entry_fps.setValue(10.0) + self.entry_fps.setKeyboardTracking(False) + self.entry_fps.setFixedWidth(self._recording_col1_entry_w) + self.fps_row.addWidget(self.entry_fps) + + label_dur = QLabel("Dur:") + label_dur.setFixedWidth(self._recording_col2_label_w) + self.fps_row.addWidget(label_dur) + self.entry_duration = QDoubleSpinBox() + self.entry_duration.setRange(1, 3600) + self.entry_duration.setDecimals(0) + self.entry_duration.setValue(1.0) + self.entry_duration.setSuffix(" s") + self.entry_duration.setKeyboardTracking(False) + self.entry_duration.setFixedWidth(self._recording_col2_entry_w) + self.fps_row.addWidget(self.entry_duration) + + # Fixed prefix length (FPS label/spin, spacing, Dur label/spin) that + # _rebuild_recording_rows must never remove. + self._fps_row_fixed_count = self.fps_row.count() + vbox.addLayout(self.fps_row) + + self.z_row = QHBoxLayout() + self.z_row.setSpacing(4) + vbox.addLayout(self.z_row) + + # Movable widgets — not added to either row here; _rebuild_recording_rows + # (called once at the end of this method, and again on every Nz/Laser-AF + # change) places them AND sets their widths (compact normally, matched + # to FPS/Dur only when on row 2). + self.label_recording_Nz = QLabel("Nz:") + self.entry_recording_Nz = QSpinBox() + self.entry_recording_Nz.setRange(1, 100) + self.entry_recording_Nz.setValue(1) + self.entry_recording_Nz.setKeyboardTracking(False) + self.entry_recording_Nz.setToolTip("Number of recording planes per FOV") + + self.label_recording_dz = QLabel("dz:") + self.entry_recording_dz = QDoubleSpinBox() + self.entry_recording_dz.setRange(0.05, 100.0) + self.entry_recording_dz.setDecimals(1) + self.entry_recording_dz.setSingleStep(0.5) + self.entry_recording_dz.setValue(1.0) + self.entry_recording_dz.setSuffix(" µm") + self.entry_recording_dz.setKeyboardTracking(False) + self.entry_recording_dz.setToolTip("Plane spacing (shown when Nz > 1)") + + self.label_recording_bottom_z = QLabel("Z offset:") + self.entry_recording_bottom_z = QDoubleSpinBox() + self.entry_recording_bottom_z.setRange(-500.0, 500.0) + self.entry_recording_bottom_z.setDecimals(1) + self.entry_recording_bottom_z.setSingleStep(0.5) + self.entry_recording_bottom_z.setValue(0.0) + self.entry_recording_bottom_z.setSuffix(" µm") + self.entry_recording_bottom_z.setKeyboardTracking(False) + self.entry_recording_bottom_z.setMaximumWidth(85) + self.entry_recording_bottom_z.setToolTip("Bottom plane offset relative to the Z reference") + + # Wire up dz visibility (Nz), Z-offset visibility (Laser AF — the + # offset is relative to the AF reference plane), the offset caption + # (Nz), and the row reflow (both). + self.entry_recording_Nz.valueChanged.connect(self._update_recording_planes_ui) + self.checkbox_laser_af.toggled.connect(self._update_recording_planes_ui) + self._update_recording_planes_ui() + + # Collapse the whole phase's fields when unchecked (Qt's checkable + # QGroupBox only grays them out; a plain QCheckBox has no built-in + # equivalent, so this is done explicitly). + self.checkbox_recording.toggled.connect(lambda checked: _set_layout_widgets_visible(vbox, checked)) + _set_layout_widgets_visible(vbox, self.checkbox_recording.isChecked()) + + return grp + + def _build_zstack_group(self) -> QFrame: + # Plain QFrame + standalone checkbox header — see _build_recording_group. + grp = QFrame() + outer_vbox = QVBoxLayout(grp) + outer_vbox.setContentsMargins(4, 2, 4, 2) + outer_vbox.setSpacing(4) + + self.checkbox_zstack = QCheckBox("Z-Stack phase") + self.checkbox_zstack.setChecked(False) + header_font = self.checkbox_zstack.font() + header_font.setBold(True) + self.checkbox_zstack.setFont(header_font) + outer_vbox.addWidget(self.checkbox_zstack) + + # No margins here — see the matching comment in _build_recording_group. + layout = QGridLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + outer_vbox.addLayout(layout) + self._zstack_content_layout = layout + + # Row 0: Z-min | Z-max | Step | Computed planes on one row + self.entry_zmin = QDoubleSpinBox() + self.entry_zmin.setRange(-5000, 5000) + self.entry_zmin.setValue(-3.0) + self.entry_zmin.setSuffix(" μm") + self.entry_zmin.setDecimals(1) + self.entry_zmin.setSingleStep(0.5) + self.entry_zmin.setKeyboardTracking(False) + self.entry_zmin.setMaximumWidth(68) # a hair more than zmax: room for the "-" sign + layout.addWidget(QLabel("Z-min:"), 0, 0) + layout.addWidget(self.entry_zmin, 0, 1) + + self.entry_zmax = QDoubleSpinBox() + self.entry_zmax.setRange(-5000, 5000) + self.entry_zmax.setValue(3.0) + self.entry_zmax.setSuffix(" μm") + self.entry_zmax.setDecimals(1) + self.entry_zmax.setSingleStep(0.5) + self.entry_zmax.setKeyboardTracking(False) + self.entry_zmax.setMaximumWidth(62) + layout.addWidget(QLabel("Z-max:"), 0, 2) + layout.addWidget(self.entry_zmax, 0, 3) + + self.entry_step = QDoubleSpinBox() + self.entry_step.setRange(0.001, 1000) + self.entry_step.setValue(1.0) + self.entry_step.setSuffix(" μm") + self.entry_step.setDecimals(2) + self.entry_step.setSingleStep(0.1) + self.entry_step.setKeyboardTracking(False) + # Slightly wider than zmin/zmax: 2 decimals ("1.00 μm") need ~1 extra char. + self.entry_step.setMaximumWidth(68) + layout.addWidget(QLabel("Step:"), 0, 4) + layout.addWidget(self.entry_step, 0, 5) + + self.label_zstack_planes = QLabel("-- planes") + layout.addWidget(self.label_zstack_planes, 0, 6) + # Stretch column so the row left-packs + layout.setColumnStretch(7, 1) + + # Row 1: Z-stack channel table (rows added via _add_zstack_channel_row) + # Columns: Channel | Exposure (ms) | Gain | Illumination (%) | Actions + self.zstack_channel_table = _make_channel_settings_table(rows=0) + self.zstack_channel_table.setMinimumHeight(80) + self.zstack_channel_table.setMaximumHeight(200) + self.zstack_channel_table.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + # Span into the stretch column (7) too, so the table fills the full + # group width instead of stopping at the Z-min/Z-max/Step row's + # narrower natural width. + layout.addWidget(self.zstack_channel_table, 1, 0, 1, 8) + + # Row 2: Add channel dropdown + button (capped combo with "+ Add" right after, + # then a trailing stretch so the row doesn't span the full panel width) + add_row = QHBoxLayout() + add_row.setContentsMargins(0, 0, 0, 0) + add_row.setSpacing(4) + self.combobox_zstack_add_channel = QComboBox() + self._populate_channel_combo(self.combobox_zstack_add_channel) + self.combobox_zstack_add_channel.setMaximumWidth(170) + add_row.addWidget(self.combobox_zstack_add_channel) + self.btn_zstack_add_channel = QPushButton("+ Add") + self.btn_zstack_add_channel.setMaximumWidth(60) + self.btn_zstack_add_channel.clicked.connect(self._on_zstack_add_channel_clicked) + add_row.addWidget(self.btn_zstack_add_channel) + add_row.addStretch(1) + layout.addLayout(add_row, 2, 0, 1, 7) + + # Wire up live plane count update + self.entry_zmin.valueChanged.connect(self._update_zstack_planes_label) + self.entry_zmax.valueChanged.connect(self._update_zstack_planes_label) + self.entry_step.valueChanged.connect(self._update_zstack_planes_label) + self._update_zstack_planes_label() + + # Collapse the whole phase's fields when unchecked (see _build_recording_group). + self.checkbox_zstack.toggled.connect(lambda checked: _set_layout_widgets_visible(layout, checked)) + _set_layout_widgets_visible(layout, self.checkbox_zstack.isChecked()) + + return grp + + def _build_start_group(self) -> QFrame: + # Plain QFrame — see _build_output_group. + grp = QFrame() + layout = QVBoxLayout(grp) + layout.setContentsMargins(4, 2, 4, 2) + layout.setSpacing(4) + + # Save/Load full settings (reusable across acquisitions, unlike the + # per-run acquisition_channels.yaml audit snapshot). Hidden by + # default — drag-and-drop of an acquisition YAML onto the widget + # covers the same functionality without permanently using UI space. + self.settings_buttons_frame = QFrame() + settings_row = QHBoxLayout(self.settings_buttons_frame) + settings_row.setContentsMargins(0, 0, 0, 0) + settings_row.setSpacing(6) + self.btn_saveSettings = QPushButton("Save Settings…") + self.btn_saveSettings.clicked.connect(self._on_save_settings_clicked) + settings_row.addWidget(self.btn_saveSettings) + self.btn_loadSettings = QPushButton("Load Settings…") + self.btn_loadSettings.clicked.connect(self._on_load_settings_clicked) + settings_row.addWidget(self.btn_loadSettings) + settings_row.addStretch(1) + self.settings_buttons_frame.setVisible(False) + layout.addWidget(self.settings_buttons_frame) + + self.btn_startAcquisition = QPushButton("Start Acquisition") + self.btn_startAcquisition.setStyleSheet("background-color: #C2C2FF") + self.btn_startAcquisition.setCheckable(True) + self.btn_startAcquisition.setChecked(False) + layout.addWidget(self.btn_startAcquisition) + self.btn_startAcquisition.clicked.connect(self.toggle_acquisition) + return grp + + def _update_start_button_enabled(self) -> None: + """Gray out Start Acquisition when neither phase is enabled — mirrors + validate()'s "At least one phase (Recording or Z-Stack) must be + enabled" check, so this common case doesn't need a click + error dialog.""" + self.btn_startAcquisition.setEnabled(self.checkbox_recording.isChecked() or self.checkbox_zstack.isChecked()) + + # ---------------------------------------------------------------------- helpers + + def _populate_channel_combo(self, combo: QComboBox, names: Optional[List[str]] = None) -> None: + """Populate a channel QComboBox from liveController (or a pre-fetched list). + + ``names`` lets refresh_channel_list share this path with its fetch-first + bail-out semantics instead of duplicating the population code. + """ + combo.clear() + if names is not None: + combo.addItems(names) + return + try: + channels = self.liveController.get_channels(self.objectiveStore.current_objective) + for ch in channels: + combo.addItem(ch.name) + except Exception as exc: + self._log.warning(f"Could not populate channel combo: {exc}") + + def _find_channel(self, name: str): + """Look up channel *name* in liveController.get_channels() for the + current objective. Returns None if not found or unavailable. + + Shared by _channel_settings (used to seed new rows) and + build_parameters._make_channel_base (used to build the acquisition + channel), so both see the same channel for the same name. + """ + try: + channels = self.liveController.get_channels(self.objectiveStore.current_objective) + for ch in channels: + if ch.name == name: + return ch + except Exception as exc: + self._log.warning(f"_find_channel: could not fetch channel {name!r}: {exc}") + return None + + def _channel_settings(self, name: str) -> Tuple[float, float, float]: + """Return (exposure, gain, illumination) configured for channel *name*. + + Falls back to (50.0, 0.0, 50.0) if the channel can't be found, so newly + added rows are seeded from the channel's own settings (as known by + liveController) instead of an arbitrary hardcoded default. + """ + ch = self._find_channel(name) + if ch is not None: + return ch.exposure_time, ch.analog_gain, ch.illumination_intensity + return 50.0, 0.0, 50.0 + + def _on_recording_channel_changed(self, index: int) -> None: + """Reseed the recording row's exposure/gain/illum from the newly selected channel.""" + name = self._recording_ch_combo.currentText() + if not name: + return + self._set_recording_row_values(*self._channel_settings(name)) + + def _set_recording_row_values(self, exposure: float, gain: float, illumination: float) -> None: + self._recording_exp_spin.setValue(exposure) + self._recording_gain_spin.setValue(gain) + self._recording_illum_spin.setValue(illumination) + + # ---------------------------------------------------------------------- recording table accessors + + def _recording_channel_name(self) -> Optional[str]: + """Return the selected channel name from the recording row, or None if empty.""" + if self._recording_ch_combo.count() == 0: + return None + return self._recording_ch_combo.currentText() or None + + def _camera_fps_limit_note(self, requested_fps: float, duration_s: float) -> Optional[str]: + """Text for the confirmation dialog when the camera cannot reach requested_fps at the + recording exposure in its current binning / ROI, else None. Unknown (no camera, or a + camera that cannot estimate) means no note.""" + from control.core.record_zstack_controller import frame_count, resolve_effective_fps + + camera = self.liveController.camera + exposure_ms = self._recording_exposure() + # Same resolution the controller applies before the run starts. + achievable = resolve_effective_fps(camera, requested_fps, exposure_ms) + if requested_fps <= achievable * 1.01: + return None + frames = max(1, frame_count(achievable, duration_s)) + return ( + f"Camera limit: at {exposure_ms:g} ms exposure in the current binning/ROI the camera can " + f"deliver at most {achievable:.1f} fps, not {requested_fps:g}.\n" + f"The recording will be made at {achievable:.1f} fps ({frames} frames for {duration_s:g} s) " + f"and the file metadata will record that rate." + ) + + def _recording_exposure(self) -> float: + return self._recording_exp_spin.value() + + def _recording_gain(self) -> float: + return self._recording_gain_spin.value() + + def _recording_illumination(self) -> float: + return self._recording_illum_spin.value() + + def _browse_saving_dir(self) -> None: + path = QFileDialog.getExistingDirectory(self, "Select Saving Directory", self.lineEdit_savingDir.text()) + if path: + self.lineEdit_savingDir.setText(path) + # Remember across restarts, like the other multipoint widgets' set_saving_dir. + save_last_used_saving_path(path) + + def _on_save_settings_clicked(self) -> None: + """Save full current settings to a user-chosen YAML file (no acquisition run required).""" + from control.core.record_zstack_controller import _build_objective_info, _save_record_zstack_yaml + + path, _ = QFileDialog.getSaveFileName( + self, "Save Record/Z-Stack Settings", "acquisition.yaml", "YAML Files (*.yaml *.yml)" + ) + if not path: + return + self._update_scan_regions() + params = self.build_parameters() + objective_info = _build_objective_info(self.objectiveStore, self.liveController.camera) + try: + _save_record_zstack_yaml(params, path, self.scanCoordinates, objective_info) + self._log.info(f"Settings saved to {path}") + except Exception as exc: + self._log.error(f"Failed to save settings: {exc}", exc_info=True) + QMessageBox.warning(self, "Save Error", f"Failed to save settings:\n{exc}") + + def _on_load_settings_clicked(self) -> None: + """Load full settings from a user-chosen YAML file via the same path as drag-and-drop.""" + path, _ = QFileDialog.getOpenFileName(self, "Load Record/Z-Stack Settings", "", "YAML Files (*.yaml *.yml)") + if path: + self._load_acquisition_yaml(path) + + def _update_zstack_planes_label(self) -> None: + from control.core.record_zstack_controller import zstack_plane_count + + try: + n = zstack_plane_count(self.entry_zmin.value(), self.entry_zmax.value(), self.entry_step.value()) + self.label_zstack_planes.setText(f"{n} planes") + except Exception: + self.label_zstack_planes.setText("-- planes") + + def _update_recording_planes_ui(self) -> None: + """Toggle dz visibility (hidden entirely when Nz == 1 — user requirement), + show the Z-offset field only when Laser AF is enabled (the offset is + relative to the AF reference plane; without AF the user positions Z + directly), switch the offset caption between single- and multi-plane + wording, and reflow Nz/dz/offset between row 1 and row 2. + + While the Recording phase is unchecked its section is collapsed and every + field in it stays hidden; expanding the section shows the fields placed in + its rows here.""" + expanded = self.checkbox_recording.isChecked() + multi = self.entry_recording_Nz.value() > 1 + self.label_recording_dz.setVisible(multi and expanded) + self.entry_recording_dz.setVisible(multi and expanded) + use_af = self.checkbox_laser_af.isChecked() + self.label_recording_bottom_z.setVisible(use_af and expanded) + self.entry_recording_bottom_z.setVisible(use_af and expanded) + self.label_recording_bottom_z.setText("Bottom Z offset:" if multi else "Z offset:") + self.entry_recording_bottom_z.setToolTip( + "Bottom plane offset relative to the Z reference" if multi else "Offset relative to the Z reference" + ) + self._rebuild_recording_rows(multi, use_af) + + def _rebuild_recording_rows(self, multi: bool, use_af: bool) -> None: + """Reflow Nz/dz/offset between fps_row (FPS/Duration's row) and their + own z_row. + + At most 2 of {Nz, dz, offset} are ever shown together unless both + Nz > 1 and Laser AF are on — in that 3-item case they move to z_row + as a group instead of overcrowding fps_row. Widgets are detached and + re-added (not just hidden) so an inactive row takes no vertical + space at all, rather than an empty-but-present row. + """ + + def _clear(layout: QHBoxLayout) -> None: + while layout.count(): + item = layout.takeAt(0) + if item.widget() is None: # spacing/stretch item, not a widget + del item + + while self.fps_row.count() > self._fps_row_fixed_count: + item = self.fps_row.takeAt(self.fps_row.count() - 1) + if item.widget() is None: + del item + _clear(self.z_row) + + use_second_row = multi and use_af + target = self.z_row if use_second_row else self.fps_row + + # Compact widths normally; only widen to match FPS/Dur when Nz/dz + # actually sit below them on row 2, so the common single-row case + # never carries dead space sized for an alignment it isn't doing. + self.label_recording_Nz.setFixedWidth( + self._recording_col1_label_w if use_second_row else self._recording_nz_label_w + ) + self.entry_recording_Nz.setFixedWidth( + self._recording_col1_entry_w if use_second_row else self._recording_nz_entry_w + ) + self.label_recording_dz.setFixedWidth( + self._recording_col2_label_w if use_second_row else self._recording_dz_label_w + ) + self.entry_recording_dz.setFixedWidth( + self._recording_col2_entry_w if use_second_row else self._recording_dz_entry_w + ) + + target.addWidget(self.label_recording_Nz) + target.addWidget(self.entry_recording_Nz) + + if multi: + target.addWidget(self.label_recording_dz) + target.addWidget(self.entry_recording_dz) + + if use_af: + target.addWidget(self.label_recording_bottom_z) + target.addWidget(self.entry_recording_bottom_z) + + target.addStretch(1) + if use_second_row: + # fps_row didn't receive the movable widgets in this mode, but it + # still needs its own trailing stretch — otherwise Qt has nothing + # to absorb the row's leftover width and stretches FPS/Duration's + # label-to-spinbox gap apart instead. + self.fps_row.addStretch(1) + + def _add_zstack_channel_row( + self, name: str, exposure: float = 50.0, gain: float = 0.0, illumination: float = 50.0 + ) -> None: + """Add a channel row to the Z-Stack channel table. + + Each row contains: channel name (read-only) | exposure spinbox | gain spinbox | + illumination spinbox | ⟳ Live button + ✕ remove button. + Skips silently if ``name`` is already present (dedup guard). + """ + if name in self._zstack_channel_names: + return + row = self.zstack_channel_table.rowCount() + self.zstack_channel_table.insertRow(row) + + # Col 0: channel name (read-only). Tooltip shows the full name since + # the Stretch column truncates long names visually. + item = QTableWidgetItem(name) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + item.setToolTip(name) + self.zstack_channel_table.setItem(row, 0, item) + + # Cols 1-3: exposure, gain, illumination + _add_channel_settings_spinboxes(self.zstack_channel_table, row, exposure, gain, illumination, (85, 60, 76)) + + # Col 4: action buttons (⟳ Live + ✕) + btn_container = QWidget() + btn_layout = QHBoxLayout(btn_container) + btn_layout.setContentsMargins(1, 1, 1, 1) + btn_layout.setSpacing(2) + + btn_live = QPushButton("⟳") + btn_live.setToolTip(f"Refresh exposure/gain/illumination from '{name}'s current settings") + btn_live.setMaximumWidth(28) + btn_live.clicked.connect(lambda checked=False, n=name: self._copy_zstack_row_from_live(n)) + btn_layout.addWidget(btn_live) + + btn_remove = QPushButton("✕") + btn_remove.setToolTip(f"Remove '{name}' from z-stack channels") + btn_remove.setMaximumWidth(28) + btn_remove.clicked.connect(lambda checked=False, n=name: self._remove_zstack_channel_row(n)) + btn_layout.addWidget(btn_remove) + + self.zstack_channel_table.setCellWidget(row, 4, btn_container) + + def _find_zstack_row(self, name: str) -> Optional[int]: + """Row index of channel *name* in the Z-Stack table, or None if it is not there.""" + for row in range(self.zstack_channel_table.rowCount()): + item = self.zstack_channel_table.item(row, 0) + if item is not None and item.text() == name: + return row + return None + + def _zstack_row_spinboxes(self, row: int): + """The (exposure, gain, illumination) editors of *row* (see _add_zstack_channel_row).""" + return tuple(self.zstack_channel_table.cellWidget(row, column) for column in (1, 2, 3)) + + def _remove_zstack_channel_row(self, name: str) -> None: + """Remove the row for *name* from the table. No-op if *name* is not present.""" + row = self._find_zstack_row(name) + if row is not None: + self.zstack_channel_table.removeRow(row) + + def _set_zstack_row_values(self, name: str, exposure: float, gain: float, illumination: float) -> None: + """Set inline editor values for the z-stack row identified by *name*. + + Used by ⟳ Live button and by tests. No-op if *name* is not present. + """ + row = self._find_zstack_row(name) + if row is None: + return + for spinbox, value in zip(self._zstack_row_spinboxes(row), (exposure, gain, illumination)): + spinbox.setValue(value) + + def _get_zstack_row_values(self, name: str): + """Return (exposure, gain, illumination) for the z-stack row identified by *name*. + + Returns (50.0, 0.0, 50.0) as defaults if the row is not found (logs a warning). + """ + row = self._find_zstack_row(name) + if row is None: + self._log.warning(f"_get_zstack_row_values: row '{name}' not found; returning defaults") + return 50.0, 0.0, 50.0 + return tuple(spinbox.value() for spinbox in self._zstack_row_spinboxes(row)) + + def _copy_recording_from_live(self) -> None: + """Refresh the recording row's exposure/gain/illumination from its own + selected channel's current settings. + + Was previously pulling from liveController.currentConfiguration (whatever + channel happens to be active in the Live tab), which silently switched the + row's channel selection whenever it differed from the row's own channel. + + Uses _find_channel() rather than _channel_settings() so a lookup miss + (e.g. the objective changed elsewhere and this channel no longer + exists) leaves the row untouched instead of silently resetting it to + _channel_settings()'s hardcoded (50, 0, 50) fallback. + """ + name = self._recording_ch_combo.currentText() + if not name: + return + ch = self._find_channel(name) + if ch is None: + self._log.warning(f"Copy-from-Live: channel {name!r} not found; leaving recording row unchanged") + return + self._set_recording_row_values(ch.exposure_time, ch.analog_gain, ch.illumination_intensity) + + def _copy_zstack_row_from_live(self, name: str) -> None: + """Refresh z-stack row *name*'s exposure/gain/illumination from *name*'s + own current settings (see _copy_recording_from_live for why this uses + _find_channel() instead of _channel_settings()).""" + ch = self._find_channel(name) + if ch is None: + self._log.warning(f"Copy-from-Live for z-stack row {name!r}: channel not found; leaving row unchanged") + return + self._set_zstack_row_values(name, ch.exposure_time, ch.analog_gain, ch.illumination_intensity) + + def _on_zstack_add_channel_clicked(self) -> None: + """Add the currently selected channel in the add-channel combo to the z-stack table.""" + name = self.combobox_zstack_add_channel.currentText() + if name: + exposure, gain, illum = self._channel_settings(name) + self._add_zstack_channel_row(name, exposure, gain, illum) + + def _get_selected_well_count(self) -> int: + """Return the number of currently selected wells. + + Resolves lazily via ``self.scanCoordinates`` so that plate-format + changes (which replace ``gui_hcs.wellSelectionWidget`` and call + ``scanCoordinates.add_well_selector()``) are always reflected + without needing to update a cached widget reference. + + Returns 0 when a wellplate format is active but no wells are + selected. Returns 1 for glass-slide (current-position imaging) + and when no scanCoordinates is attached. + """ + if self.scanCoordinates is not None: + selected = self.scanCoordinates.get_selected_wells() + if selected is None: + # glass-slide: imaging at current position — count as 1 + return 1 + return len(selected) + # No scanCoordinates attached: treat as single-position (glass-slide-like). + return 1 + + def _update_scan_regions(self, selection_only: bool = False) -> None: + """Update the FOV grid from the current XY mode. + + Mirrors WellplateMultiPointWidget.update_coordinates. Called whenever + entry_overlap, entry_scan_size, or combobox_shape changes, when the XY + checkbox/mode changes, and also at the start of toggle_acquisition to + ensure the grid is current. + + Select Wells mode tiles a grid (per overlap/shape/size) over the + currently selected wells. Current Position mode (also forced when XY + is unchecked) ignores well selection entirely and uses a single small + FOV at the live stage position, mirroring + WellplateMultiPointWidget.set_coordinates_to_current_position. + + selection_only: only the well selection changed, not the grid geometry. In + Select Wells mode the wells already tiled are then kept (set_well_coordinates + drops the deselected ones and tiles the new ones, as + WellplateMultiPointWidget.update_well_coordinates does) instead of tiling + every selected well again on each step of a drag-select. + """ + if self.scanCoordinates is None: + return + select_wells = self.combobox_xy_mode.currentText() == "Select Wells" + try: + # Clear first: set_well_coordinates only adds wells not already present, + # so without clearing, already-selected wells keep their old tile geometry + # and the new size/overlap/shape would be silently ignored. + if not (selection_only and select_wells) and self.scanCoordinates.has_regions(): + self.scanCoordinates.clear_regions() + if select_wells: + self.scanCoordinates.set_well_coordinates( + self.entry_scan_size.value(), self.entry_overlap.value(), self.combobox_shape.currentText() + ) + else: # "Current Position" + pos = self.stage.get_pos() + self.scanCoordinates.add_region("current", pos.x_mm, pos.y_mm, 0.01, 0, "Square") + except Exception as exc: + self._log.warning(f"_update_scan_regions: failed: {exc}") + + # ---------------------------------------------------------------------- AcquisitionYAMLDropMixin + + def _get_expected_widget_type(self) -> str: + return "record_zstack" + + def _get_camera_for_binning_check(self): + """RecordZStackMultiPointWidget has no multipointController; use liveController's camera.""" + return self.liveController.camera + + def _apply_yaml_settings(self, yaml_data) -> None: + """Apply parsed RecordZStackYAMLData to widget controls.""" + from control.models.acquisition_config import AcquisitionChannel + + widgets_to_block = [ + self.entry_Nt, + self.entry_dt, + self.checkbox_time, + self.checkbox_laser_af, + self.checkbox_recording, + self._recording_ch_combo, + self._recording_exp_spin, + self._recording_gain_spin, + self._recording_illum_spin, + self.entry_fps, + self.entry_duration, + self.entry_recording_Nz, + self.entry_recording_bottom_z, + self.entry_recording_dz, + self.checkbox_zstack, + self.entry_zmin, + self.entry_zmax, + self.entry_step, + self.combobox_xy_mode, + self.checkbox_xy, + self.entry_overlap, + self.entry_scan_size, + ] + for widget in widgets_to_block: + widget.blockSignals(True) + + try: + self.checkbox_time.setChecked(yaml_data.nt > 1) + self.entry_Nt.setValue(yaml_data.nt) + self.entry_dt.setValue(yaml_data.delta_t_s) + self.checkbox_laser_af.setChecked(yaml_data.laser_af) + + self.checkbox_recording.setChecked(yaml_data.recording_enabled) + if yaml_data.recording_channel: + ch = AcquisitionChannel.model_validate(yaml_data.recording_channel) + idx = self._recording_ch_combo.findText(ch.name) + if idx >= 0: + self._recording_ch_combo.setCurrentIndex(idx) + self._recording_exp_spin.setValue(ch.exposure_time) + self._recording_gain_spin.setValue(ch.analog_gain) + self._recording_illum_spin.setValue(ch.illumination_intensity) + else: + self._log.warning( + f"_apply_yaml_settings: recording channel {ch.name!r} not found in current " + "objective's channels; keeping existing recording row settings" + ) + self.entry_fps.setValue(yaml_data.fps) + self.entry_duration.setValue(yaml_data.duration_s) + self.entry_recording_Nz.setValue(yaml_data.recording_nz) + self.entry_recording_dz.setValue(yaml_data.recording_dz_um) + self.entry_recording_bottom_z.setValue(yaml_data.recording_bottom_z_offset_um) + + self.checkbox_zstack.setChecked(yaml_data.zstack_enabled) + for name in list(self._zstack_channel_names): + self._remove_zstack_channel_row(name) + for ch_data in yaml_data.zstack_channels: + ch = AcquisitionChannel.model_validate(ch_data) + self._add_zstack_channel_row(ch.name, ch.exposure_time, ch.analog_gain, ch.illumination_intensity) + self.entry_zmin.setValue(yaml_data.z_min_um) + self.entry_zmax.setValue(yaml_data.z_max_um) + self.entry_step.setValue(yaml_data.z_step_um) + + if yaml_data.xy_mode in ("Current Position", "Select Wells"): + self.combobox_xy_mode.setCurrentText(yaml_data.xy_mode) + if yaml_data.scan_size_mm is not None: + self.entry_scan_size.setValue(yaml_data.scan_size_mm) + self.entry_overlap.setValue(yaml_data.overlap_percent) + + if yaml_data.wellplate_regions: + _load_well_regions(self.well_selection_widget, yaml_data.wellplate_regions) + self.checkbox_xy.setChecked(True) + finally: + for widget in widgets_to_block: + widget.blockSignals(False) + # checkbox_time's toggled signal was blocked above, so the normal + # _on_time_toggled-driven visibility refresh didn't fire. Set the + # frame's visibility directly rather than calling _on_time_toggled + # itself, since that method also stores/restores Nt/dt via + # _stored_time_params — invoking it here could clobber the Nt/dt + # values just loaded from the YAML. + self.time_controls_frame.setVisible(self.checkbox_time.isChecked()) + # checkbox_xy's toggled signal and combobox_xy_mode's currentTextChanged + # signal were both blocked above too, so the normal + # _on_xy_toggled/_on_xy_mode_changed-driven visibility refresh didn't + # fire either. Mirror _on_xy_mode_changed's condition directly (rather + # than calling it) for the same reason as checkbox_time above: calling + # the handlers could re-trigger stored-mode restore logic that would + # clobber the xy_mode just loaded from the YAML. + self.combobox_xy_mode.setEnabled(self.checkbox_xy.isChecked()) + self.xy_controls_frame.setVisible( + self.checkbox_xy.isChecked() and self.combobox_xy_mode.currentText() == "Select Wells" + ) + # checkbox_recording's/checkbox_zstack's toggled signals were blocked + # above too, so the collapse-when-unchecked visibility sync in + # _build_recording_group/_build_zstack_group didn't fire either. + _set_layout_widgets_visible(self._recording_content_vbox, self.checkbox_recording.isChecked()) + _set_layout_widgets_visible(self._zstack_content_layout, self.checkbox_zstack.isChecked()) + self._update_start_button_enabled() + # _update_tab_styles() only refreshes stylesheets on + # xy_frame/xy_controls_frame/time_frame/time_controls_frame based on + # the current checkbox states — it has no interaction with Nt/dt or + # _stored_time_params, so (unlike _on_time_toggled) it's safe to call + # directly here. Without it, the Time tab's border/background stays + # in its stale "inactive" styling even after the checkbox/frame + # visibility above are updated to reflect the loaded YAML. + self._update_tab_styles() + # entry_recording_Nz's valueChanged and checkbox_laser_af's toggled + # signals were both blocked above too, so the normal + # _update_recording_planes_ui-driven dz/bottom-Z visibility refresh + # didn't fire. Unlike _on_time_toggled, _update_recording_planes_ui + # has no stored-state side effects, so it's safe to call directly. + self._update_recording_planes_ui() + self._update_zstack_planes_label() + self._update_scan_regions() + + def _laser_af_has_reference(self) -> bool: + """Return True if the laser autofocus controller has a captured reference.""" + ctrl = self.laser_autofocus_controller + if ctrl is None: + return False + return bool(getattr(getattr(ctrl, "laser_af_properties", None), "has_reference", False)) + + # ---------------------------------------------------------------------- public API + + def validate(self) -> Optional[str]: + """Return an error string if the current widget state is invalid, or None. + + Delegates to the pure helper validate_record_zstack_params (in + record_zstack_controller) so the rules can be tested without Qt. + + Current Position mode doesn't use well selection at all (it acquires + at the live stage position), so it's treated as always having its one + implicit "well" satisfied. + """ + from control.core.record_zstack_controller import validate_record_zstack_params + + if self.combobox_xy_mode.currentText() == "Current Position": + selected_well_count = 1 + else: + selected_well_count = self._get_selected_well_count() + return validate_record_zstack_params( + base_path=self.lineEdit_savingDir.text().strip(), + selected_well_count=selected_well_count, + recording_enabled=self.checkbox_recording.isChecked(), + fps=self.entry_fps.value(), + duration_s=self.entry_duration.value(), + recording_channel_name=self._recording_channel_name(), + zstack_enabled=self.checkbox_zstack.isChecked(), + z_min=self.entry_zmin.value(), + z_max=self.entry_zmax.value(), + step=self.entry_step.value(), + zstack_channel_names=list(self._zstack_channel_names), + use_laser_af=self.checkbox_laser_af.isChecked(), + laser_af_has_reference=self._laser_af_has_reference(), + recording_nz=self.entry_recording_Nz.value(), + recording_dz_um=self.entry_recording_dz.value(), + recording_exposure_ms=self._recording_exposure(), + zstack_exposures_ms={name: self._get_zstack_row_values(name)[0] for name in self._zstack_channel_names}, + ) + + def build_parameters(self): + """Read widget fields and return a RecordZStackAcquisitionParameters instance. + + Transient AcquisitionChannel objects are constructed from the inline + channel editors so that the caller receives the exact settings the user + entered, not the defaults from the channel config. + """ + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters + from control.models.acquisition_config import AcquisitionChannel, CameraSettings, IlluminationSettings + + def _make_channel_base(name: str) -> AcquisitionChannel: + """Return a copy of the named channel from liveController, or a bare-bones fallback.""" + ch = self._find_channel(name) + if ch is not None: + # Return a copy so inline-editor mutations don't corrupt the source + return ch.model_copy(deep=True) + # The fallback has no illumination-source mapping, so the acquisition + # would run dark — warn loudly so the cause is diagnosable. + self._log.warning( + f"channel {name!r} not found for objective " + f"{self.objectiveStore.current_objective!r}; " + f"using a bare fallback with no illumination mapping (images may be dark)" + ) + return AcquisitionChannel( + name=name, + camera_settings=CameraSettings(exposure_time_ms=50.0, gain_mode=0.0), + illumination_settings=IlluminationSettings(intensity=50.0), + ) + + # Build recording channel from the single-row recording table + recording_channel = None + if self.checkbox_recording.isChecked(): + rec_name = self._recording_channel_name() + if rec_name: + ch = _make_channel_base(rec_name) + ch.exposure_time = self._recording_exposure() + ch.analog_gain = self._recording_gain() + ch.illumination_intensity = self._recording_illumination() + recording_channel = ch + + # Build z-stack channels from per-row inline editors + zstack_channels = [] + if self.checkbox_zstack.isChecked(): + for name in self._zstack_channel_names: + ch = _make_channel_base(name) + exposure, gain, illum = self._get_zstack_row_values(name) + ch.exposure_time = exposure + ch.analog_gain = gain + ch.illumination_intensity = illum + zstack_channels.append(ch) + + return RecordZStackAcquisitionParameters( + base_path=self.lineEdit_savingDir.text().strip(), + experiment_id=self.lineEdit_experimentID.text().strip() or "record_zstack", + Nt=self.entry_Nt.value(), + dt_s=self.entry_dt.value(), + use_laser_af=self.checkbox_laser_af.isChecked(), + recording_enabled=self.checkbox_recording.isChecked(), + recording_channel=recording_channel, + fps=self.entry_fps.value(), + duration_s=self.entry_duration.value(), + # The Z-offset field is hidden without Laser AF (no AF reference + # plane to offset from) — a hidden value must not silently apply. + recording_bottom_z_offset_um=( + self.entry_recording_bottom_z.value() if self.checkbox_laser_af.isChecked() else 0.0 + ), + recording_Nz=self.entry_recording_Nz.value(), + recording_dz_um=self.entry_recording_dz.value(), + zstack_enabled=self.checkbox_zstack.isChecked(), + zstack_channels=zstack_channels, + z_min_um=self.entry_zmin.value(), + z_max_um=self.entry_zmax.value(), + z_step_um=self.entry_step.value(), + xy_mode=self.combobox_xy_mode.currentText(), + scan_size_mm=self.entry_scan_size.value(), + overlap_percent=self.entry_overlap.value(), + ) + + def toggle_acquisition(self, pressed: bool) -> None: + """Handle Start/Stop button press. + + On start (pressed=True): + - validate(); show QMessageBox.warning and un-check button on failure. + - if Recording is enabled, show a QMessageBox.question confirming the + plane-count/Z-range/per-FOV-duration summary; un-check button and + abort if the user answers No. + - emit signal_acquisition_started(True) so gui_hcs can lock down the UI. + - call recordZStackController.run_acquisition(self.build_parameters()). + + On stop (pressed=False): + - call recordZStackController.request_abort(). + + Ordering note: signal_acquisition_started(True) is emitted BEFORE + run_acquisition() spawns the worker thread. Otherwise a fast/one-frame + acquisition could finish (firing acquisition_is_finished -> emit(False)) + before this method reaches the emit(True) line, leaving the GUI to process + False then True and permanently locking all tabs. This mirrors + WellplateMultiPointWidget, which emits True (via _set_ui_acquisition_running) + before calling run_acquisition(). + """ + self._log.debug(f"RecordZStackMultiPointWidget.toggle_acquisition, {pressed=}") + if pressed: + if self.recordZStackController.acquisition_in_progress(): + self._log.warning("Acquisition already in progress, cannot start another.") + self.btn_startAcquisition.setChecked(False) + return + + error = self.validate() + if error is not None: + self.btn_startAcquisition.setChecked(False) + QMessageBox.warning(self, "Invalid Parameters", error) + return + + params = self.build_parameters() + + # One last look at the recording plane summary before starting. + if params.recording_enabled: + from control.core.record_zstack_controller import recording_plane_offsets_um + + nz = params.recording_Nz + per_fov_s = nz * params.duration_s + # Derive the summary from the same helper the worker records with, + # so the confirmed range can never diverge from the planes visited. + offsets = recording_plane_offsets_um(params.recording_bottom_z_offset_um, nz, params.recording_dz_um) + if nz > 1: + summary = ( + f"{nz} planes: {offsets[0]:+.1f} … {offsets[-1]:+.1f} µm rel. reference" + f" — {per_fov_s:.1f} s/FOV" + ) + else: + summary = f"1 plane @ {offsets[0]:+.1f} µm — {per_fov_s:.1f} s/FOV" + # Tell the user up front when the camera cannot deliver the requested + # fps for the recording exposure in the current binning / ROI: the + # worker records at the achievable rate and stamps that rate into the + # store's metadata (time_increment_s, effective_fps), so the number + # they typed is not the number they get. + limit_note = self._camera_fps_limit_note(params.fps, params.duration_s) + text = f"Recording: {summary}" + if limit_note: + text += f"\n\n{limit_note}" + self._log.warning(limit_note.replace("\n", " ")) + reply = QMessageBox.question( + self, + "Confirm Recording", + f"{text}\n\nStart acquisition?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if reply != QMessageBox.Yes: + self.btn_startAcquisition.setChecked(False) + return + + # Refresh the per-well FOV grid before starting so the scan regions + # reflect the current overlap/shape/region-size settings. + self._update_scan_regions() + + # Lock the UI before the worker thread can possibly finish (see docstring). + self.signal_acquisition_started.emit(True) + try: + self.recordZStackController.run_acquisition(params) + except Exception as e: + self._log.error(f"Failed to start acquisition: {e}", exc_info=True) + self.btn_startAcquisition.setChecked(False) + # Unlock the UI: the worker never started, so acquisition_is_finished + # will not fire to emit(False) for us. + self.signal_acquisition_started.emit(False) + else: + self.recordZStackController.request_abort() + + def acquisition_is_finished(self): + """Called (thread-safe via Qt signal) when the acquisition worker finishes. + + Connected in gui_hcs to recordZStackController.acquisition_finished, so this + is the single place that emits signal_acquisition_started(False) on normal + completion AND on the stop-button/abort path (request_abort eventually drives + the worker to completion, which fires acquisition_finished -> here). The only + other emit(False) is the failure-to-start path in toggle_acquisition, where the + worker thread never launched so acquisition_finished will not fire. + """ + self.btn_startAcquisition.setChecked(False) + self.signal_acquisition_started.emit(False) + + def on_well_selection_changed(self) -> None: + """Rebuild the FOV grid when the well selection changes on this tab. + + Connected in gui_hcs to wellSelectionWidget.signal_wellSelected — + WellplateMultiPointWidget's equivalent handler early-returns when its + own tab is not current, so this widget needs its own. No-op when + another record tab is current. + """ + if self.tab_widget is not None and self.tab_widget.currentWidget() is not self: + return + self._update_scan_regions(selection_only=True) + + def refresh_channel_list(self) -> None: + """Repopulate the channel combos from liveController. + + Channel sets are per-objective (and per-profile): after an objective or + profile change, stale names would silently fall back to a bare-bones + channel with no illumination source and acquire dark images. Mirrors + WellplateMultiPointWidget.refresh_channel_list. The previous recording + selection is kept when it still exists; z-stack rows whose channel no + longer exists are removed. + """ + # Fetch FIRST and bail out on failure or an empty result: clearing the + # combos before a failed fetch would leave them empty and the stale-row + # pruning below would then wipe every configured z-stack row (with the + # user's per-row exposure/gain/illumination edits) on a transient error. + try: + channels = self.liveController.get_channels(self.objectiveStore.current_objective) + except Exception as exc: + self._log.warning(f"refresh_channel_list: get_channels failed; keeping existing lists: {exc}") + return + if not channels: + self._log.warning( + "refresh_channel_list: no channels for the current objective/profile; keeping existing lists" + ) + return + names = [ch.name for ch in channels] + + # Repopulating transiently selects other channels (clear() then + # addItems() auto-selects index 0), firing currentIndexChanged along + # the way and reseeding the exposure/gain/illum spinboxes from their + # base config. Block signals so only a genuine selection change (the + # previous channel no longer being available) triggers a reseed — + # otherwise the user's manually edited values would be silently lost + # even though the selected channel is unchanged from their perspective. + prev_recording = self._recording_channel_name() + self._recording_ch_combo.blockSignals(True) + self._populate_channel_combo(self._recording_ch_combo, names=names) + channel_changed = True + if prev_recording and prev_recording in names: + self._recording_ch_combo.setCurrentIndex(names.index(prev_recording)) + channel_changed = False + elif prev_recording: + self._log.warning( + f"recording channel {prev_recording!r} is not available for the current " + f"objective/profile; selection changed to {names[0]!r} — review the recording " + f"exposure/gain/illumination before starting an acquisition" + ) + self._recording_ch_combo.blockSignals(False) + if channel_changed: + self._on_recording_channel_changed(self._recording_ch_combo.currentIndex()) + self._populate_channel_combo(self.combobox_zstack_add_channel, names=names) + for name in [n for n in self._zstack_channel_names if n not in names]: + self._log.info(f"removing z-stack channel row {name!r}: not available for the current objective") + self._remove_zstack_channel_row(name) diff --git a/software/squid/abc.py b/software/squid/abc.py index 6fd9dec2d..905bccffb 100644 --- a/software/squid/abc.py +++ b/software/squid/abc.py @@ -565,6 +565,33 @@ def get_total_frame_time(self) -> float: """ return self.get_exposure_time() + self.get_strobe_time() + def get_max_frame_rate(self, exposure_time_ms: Optional[float] = None) -> float: + """Highest continuous/free-run frame rate the camera can deliver, in fps, without + changing any setting. + + Args: + exposure_time_ms: exposure to assume; None means the current exposure. + + The base estimate is the sequential frame time (exposure + strobe/readout), which + matches what set_frame_rate() reports on a camera without a hardware rate hint. + Drivers whose sensor pipelines exposure with readout override this. Used by the + GUI to warn before an acquisition whose requested rate the camera cannot reach. + """ + exposure = self.get_exposure_time() if exposure_time_ms is None else float(exposure_time_ms) + return 1000.0 / (exposure + self.get_strobe_time()) + + def set_frame_rate(self, fps: float) -> float: + """Best-effort hint to run continuous/free-run acquisition near `fps`. + + Base default cannot change hardware timing, so it returns the + exposure-limited max the camera will actually deliver. Subclasses that + support an internal frame-rate (e.g. ToupTek PRECISE_FRAMERATE) override + this. Callers must still downsample to their target rate. + """ + max_fps = self.get_max_frame_rate() + self._log.debug(f"set_frame_rate({fps}) no-op on base camera; max≈{max_fps:.2f} fps") + return max_fps + @abc.abstractmethod def set_frame_format(self, frame_format: CameraFrameFormat): """ diff --git a/software/squid/camera/utils.py b/software/squid/camera/utils.py index 196de07f7..bf76bfa20 100644 --- a/software/squid/camera/utils.py +++ b/software/squid/camera/utils.py @@ -175,6 +175,7 @@ def __init__(self, *args, **kwargs): self._continue_streaming = False self._streaming_thread: Optional[threading.Thread] = None self._last_trigger_timestamp = 0 + self._target_frame_period_s: Optional[float] = None # This is for the migration to AbstractCamera. It helps us find methods/properties that # some cameras had in the pre-AbstractCamera days. @@ -213,6 +214,15 @@ def set_exposure_time(self, exposure_time_ms: float): def get_exposure_time(self) -> float: return self._exposure_time_ms + @debug_log + def set_frame_rate(self, fps: float) -> float: + max_fps = self.get_max_frame_rate() + if fps is None or fps <= 0: + self._target_frame_period_s = None + return max_fps + self._target_frame_period_s = 1.0 / fps + return min(fps, max_fps) + @debug_log def get_strobe_time(self): return 3 # Just some arbitrary non-zero number so we test code that relies on this. @@ -296,12 +306,11 @@ def stream_fn(): self._log.info("Starting streaming thread...") last_frame_time = time.time() while self._continue_streaming: + period_s = self._exposure_time_ms / 1000.0 + if self._target_frame_period_s is not None: + period_s = max(period_s, self._target_frame_period_s) time_since = time.time() - last_frame_time - # use self._exposure_time and _acquisition_mode so as not to spam the logs, - # but this could case issues if subclassed for testing. - if ( - self._exposure_time_ms / 1000.0 - ) - time_since <= 0 and self._acquisition_mode == CameraAcquisitionMode.CONTINUOUS: + if time_since >= period_s and self._acquisition_mode == CameraAcquisitionMode.CONTINUOUS: self._next_frame() last_frame_time = time.time() time.sleep(0.001) @@ -338,7 +347,20 @@ def get_is_streaming(self): return self._streaming_thread and self._streaming_thread.is_alive() @debug_log - def read_camera_frame(self) -> CameraFrame: + def read_camera_frame(self) -> Optional[CameraFrame]: + # Match the real camera / AbstractCamera contract: wait for a frame rather + # than returning whatever _current_frame happens to be right now. In + # CONTINUOUS mode _current_frame is None until the streaming thread produces + # the first frame, so an immediate return would hand callers None (e.g. the + # recording frame-shape probe, which then can't size its dataset). The + # software-trigger path is unaffected: send_trigger() sets _current_frame + # synchronously before the read, so this returns immediately. Bounded by a + # timeout so a stopped/idle stream returns (the last frame or None) instead + # of blocking forever. + timeout_s = (self._exposure_time_ms / 1000.0) * 1.02 + 4 + deadline = time.time() + timeout_s + while self._current_frame is None and time.time() < deadline: + time.sleep(0.001) return self._current_frame @debug_log diff --git a/software/tests/conftest.py b/software/tests/conftest.py index 7424900ed..c7abfa74e 100644 --- a/software/tests/conftest.py +++ b/software/tests/conftest.py @@ -74,6 +74,14 @@ def _close_quietly(obj, label): logger.exception(f"Failed to close {label} in test cleanup") +@pytest.fixture(autouse=True) +def _watchdog_state_to_tmp(tmp_path_factory, monkeypatch): + # Keep acquisition breadcrumbs (run.json) out of the real user state dir: every acquisition + # controller writes one, under tests/control and tests/core alike. A sibling of tmp_path, + # not inside it: tests enumerate tmp_path for the one experiment directory they made. + monkeypatch.setenv("SQUID_WATCHDOG_STATE_DIR", str(tmp_path_factory.mktemp("watchdog"))) + + @pytest.fixture(autouse=True) def cleanup_leaked_hardware(monkeypatch): """ diff --git a/software/tests/control/conftest.py b/software/tests/control/conftest.py index e2d7bd5f1..ea9dfb6ad 100644 --- a/software/tests/control/conftest.py +++ b/software/tests/control/conftest.py @@ -44,12 +44,6 @@ def firmware_sim_nonstrict(): sim.close() -@pytest.fixture(autouse=True) -def _watchdog_state_to_tmp(tmp_path, monkeypatch): - # Keep acquisition breadcrumbs out of the real user state dir during tests. - monkeypatch.setenv("SQUID_WATCHDOG_STATE_DIR", str(tmp_path / "watchdog")) - - @pytest.fixture(scope="session") def fluidics_config_path(tmp_path_factory) -> str: """A simulated-instrument FluidicsConfig written once per session.""" diff --git a/software/tests/control/core/test_backpressure.py b/software/tests/control/core/test_backpressure.py index aea57d5db..168d02df7 100644 --- a/software/tests/control/core/test_backpressure.py +++ b/software/tests/control/core/test_backpressure.py @@ -713,8 +713,7 @@ def _create_mock_controller(): controller.multiPointWorker = None controller.thread = None controller._memory_monitor = None - controller._prewarmed_job_runner = None - controller._prewarmed_bp_values = None + controller._prewarm = MagicMock() # PrewarmedJobRunnerSlot stand-in controller._log = MagicMock() controller._PROCESS_TERMINATE_TIMEOUT_S = 1.0 return controller diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index 2c82e713d..b48f8531b 100644 --- a/software/tests/control/test_HighContentScreeningGui.py +++ b/software/tests/control/test_HighContentScreeningGui.py @@ -34,33 +34,6 @@ def test_create_simulated_hcs_with_or_without_piezo(qtbot, confirm_exit_yes): qtbot.add_widget(without_piezo) -def test_tab_change_to_simple_recording_does_not_raise(qtbot, monkeypatch, confirm_exit_yes): - """Regression: onTabChanged used to call emit_selected_channels() on every record - tab and toggleAcquisitionStart called display_progress_bar() on the current tab, - but RecordingWidget (Simple Recording) has neither method, so selecting the tab - raised AttributeError on machines with ENABLE_RECORDING on.""" - - # gui_hcs star-imports _def, so patch its module-level binding before construction - # to get the "Simple Recording" tab added. - monkeypatch.setattr(control.gui_hcs, "ENABLE_RECORDING", True) - - scope = control.microscope.Microscope.build_from_global_config(True) - win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) - qtbot.add_widget(win) - - recording_index = win.recordTabWidget.indexOf(win.recordingControlWidget) - assert recording_index >= 0, "Simple Recording tab was not added despite ENABLE_RECORDING" - - # Selecting the tab fires currentChanged -> onTabChanged; must not raise. - win.recordTabWidget.setCurrentIndex(recording_index) - win.onTabChanged(recording_index) - - # The same widget must also survive an acquisition start/stop notification - # (workflow/TCP acquisitions can start while a non-multipoint tab is current). - win.toggleAcquisitionStart(True) - win.toggleAcquisitionStart(False) - - def test_acquisition_start_emits_selected_channels(qtbot, confirm_exit_yes): """The napari multichannel viewer is initialized from signal_acquisition_channels. That signal must be emitted when the acquisition starts (alongside @@ -113,6 +86,106 @@ def test_image_display_signals_connected_once(qtbot, monkeypatch, confirm_exit_y assert len(click_calls) == 1, f"image_click_coordinates wired {len(click_calls)} times, expected 1" +def test_create_simulated_hcs_with_dragonfly(qtbot, monkeypatch, confirm_exit_yes): + """Regression: with a Dragonfly as the spinning disk unit, GUI construction wired the + X-Light-only iris signals onto DragonflyConfocalWidget and raised AttributeError.""" + import control.widgets + from tests.control.spinning_disk_test_utils import enable_spinning_disk + + enable_spinning_disk(monkeypatch, control.gui_hcs, dragonfly=True) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + assert isinstance(win.spinningDiskConfocalWidget, control.widgets.DragonflyConfocalWidget) + assert win.cameraTabWidget.indexOf(win.spinningDiskConfocalWidget) >= 0 + + # The widget's toggle drives the live controller's confocal mode, as the X-Light one does. + assert scope.live_controller.is_confocal_mode() is False + win.spinningDiskConfocalWidget.btn_toggle_confocal.click() + assert scope.addons.dragonfly.get_modality() == "CONFOCAL" + assert scope.live_controller.is_confocal_mode() is True + + +def test_gui_cleanup_closes_the_dragonfly(qtbot, monkeypatch, confirm_exit_yes): + """closeEvent/restart go through _cleanup_common, which closes devices itself and never + calls Microscope.close(); the Dragonfly serial port must be released on that path too.""" + import control.serial_peripherals + from tests.control.spinning_disk_test_utils import enable_spinning_disk + + enable_spinning_disk(monkeypatch, control.gui_hcs, dragonfly=True) + + scope = control.microscope.Microscope.build_from_global_config(True) + closed = [] + monkeypatch.setattr(scope.addons.dragonfly, "close", lambda: closed.append("dragonfly")) + gui = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(gui) + gui.closeEvent = lambda event: event.accept() # keep teardown from re-running cleanup + + gui._cleanup_common(for_restart=True) + + assert closed == ["dragonfly"] + + +def test_record_zstack_tab_keeps_well_selector_visible(qtbot, monkeypatch): + """Switching to the Record + Z-Stack tab must not hide the well selector dock. + + The tab's own validation requires selected wells, so onTabChanged has to + treat it like Wellplate Multipoint when deciding well-selector visibility. + Also exercises the tab switch end-to-end, which duck-calls + emit_selected_channels() on the widget. + """ + + def confirm_exit(parent, title, text, *args, **kwargs): + if title == "Confirm Exit": + return QMessageBox.Yes + raise RuntimeError(f"Unexpected QMessageBox: {title} - {text}") + + monkeypatch.setattr(QMessageBox, "question", confirm_exit) + # The tab is gated by ENABLE_RECORDING; force it on regardless of the local INI. + monkeypatch.setattr(control.gui_hcs, "ENABLE_RECORDING", True) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + tabw = win.recordTabWidget + labels = [tabw.tabText(i) for i in range(tabw.count())] + assert "Record + Z-Stack" in labels + + tabw.setCurrentIndex(labels.index("Record + Z-Stack")) + + assert not win.dock_wellSelection.isHidden(), "well selector dock must stay available on the Record + Z-Stack tab" + + +def test_record_zstack_acquisition_finish_keeps_well_selector(qtbot, monkeypatch): + """R6: toggleAcquisitionStart(False) after a Record + Z-Stack acquisition + must not hide the well selector dock (only the wellplate branch kept it).""" + + def confirm_exit(parent, title, text, *args, **kwargs): + if title == "Confirm Exit": + return QMessageBox.Yes + raise RuntimeError(f"Unexpected QMessageBox: {title} - {text}") + + monkeypatch.setattr(QMessageBox, "question", confirm_exit) + monkeypatch.setattr(control.gui_hcs, "ENABLE_RECORDING", True) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + tabw = win.recordTabWidget + labels = [tabw.tabText(i) for i in range(tabw.count())] + tabw.setCurrentIndex(labels.index("Record + Z-Stack")) + assert not win.dock_wellSelection.isHidden() + + win.toggleAcquisitionStart(True) # acquisition starts: selector hides + assert win.dock_wellSelection.isHidden() + win.toggleAcquisitionStart(False) # acquisition ends: selector must return + assert not win.dock_wellSelection.isHidden(), "well selector not restored after Record+Z-Stack acquisition" + + def test_cleanup_closes_stage_before_microcontroller(qtbot, monkeypatch, confirm_exit_yes): """The stage may own its own transport (e.g. the PI C-414 serial handle), so cleanup must call stage.close() — before the microcontroller, mirroring Microscope.close().""" @@ -141,43 +214,51 @@ def recording_micro_close(): assert calls == ["stage", "microcontroller"] -def test_create_simulated_hcs_with_dragonfly(qtbot, monkeypatch, confirm_exit_yes): - """Regression: with a Dragonfly as the spinning disk unit, GUI construction wired the - X-Light-only iris signals onto DragonflyConfocalWidget and raised AttributeError.""" - import control.widgets - from tests.control.spinning_disk_test_utils import enable_spinning_disk +def test_record_zstack_preview_signal(qtbot, monkeypatch, confirm_exit_yes): + """Both preview paths funnel into QtRecordZStackController.image_to_display: + the z-stack signal_new_image callback and the recording display callable.""" + from types import SimpleNamespace + import numpy as np - enable_spinning_disk(monkeypatch, control.gui_hcs, dragonfly=True) + monkeypatch.setattr(control.gui_hcs, "ENABLE_RECORDING", True) + monkeypatch.setattr(control._def, "RECORD_ZSTACK_DISPLAY_FPS", 3) scope = control.microscope.Microscope.build_from_global_config(True) win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) qtbot.add_widget(win) - assert isinstance(win.spinningDiskConfocalWidget, control.widgets.DragonflyConfocalWidget) - assert win.cameraTabWidget.indexOf(win.spinningDiskConfocalWidget) >= 0 + ctrl = win.recordZStackController + assert ctrl is not None - # The widget's toggle drives the live controller's confocal mode, as the X-Light one does. - assert scope.live_controller.is_confocal_mode() is False - win.spinningDiskConfocalWidget.btn_toggle_confocal.click() - assert scope.addons.dragonfly.get_modality() == "CONFOCAL" - assert scope.live_controller.is_confocal_mode() is True + frames = [] + ctrl.image_to_display.connect(frames.append) + # Z-stack path: the per-plane callback emits the frame array. + fake = SimpleNamespace(frame=np.zeros((4, 4), np.uint16)) + ctrl._callbacks.signal_new_image(fake, None) + assert len(frames) == 1 -def test_gui_cleanup_closes_the_dragonfly(qtbot, monkeypatch, confirm_exit_yes): - """closeEvent/restart go through _cleanup_common, which closes devices itself and never - calls Microscope.close(); the Dragonfly serial port must be released on that path too.""" - import control.serial_peripherals - from tests.control.spinning_disk_test_utils import enable_spinning_disk + # Recording path: the plain callable handed to the worker emits too. + assert ctrl._display_frame_fn is not None + assert ctrl._display_fps == 3 + ctrl._display_frame_fn(np.ones((4, 4), np.uint16)) + assert len(frames) == 2 - enable_spinning_disk(monkeypatch, control.gui_hcs, dragonfly=True) - scope = control.microscope.Microscope.build_from_global_config(True) - closed = [] - monkeypatch.setattr(scope.addons.dragonfly, "close", lambda: closed.append("dragonfly")) - gui = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) - qtbot.add_widget(gui) - gui.closeEvent = lambda event: event.accept() # keep teardown from re-running cleanup +def test_record_zstack_preview_disabled_at_zero_fps(qtbot, monkeypatch, confirm_exit_yes): + from types import SimpleNamespace + import numpy as np - gui._cleanup_common(for_restart=True) + monkeypatch.setattr(control.gui_hcs, "ENABLE_RECORDING", True) + monkeypatch.setattr(control._def, "RECORD_ZSTACK_DISPLAY_FPS", 0) - assert closed == ["dragonfly"] + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + ctrl = win.recordZStackController + frames = [] + ctrl.image_to_display.connect(frames.append) + ctrl._callbacks.signal_new_image(SimpleNamespace(frame=np.zeros((4, 4), np.uint16)), None) + assert frames == [] # z-stack handler stays a no-op at 0 fps + assert ctrl._display_frame_fn is None # recording tap absent diff --git a/software/tests/control/test_MultiPointController.py b/software/tests/control/test_MultiPointController.py index e926cdc7c..51267e15d 100644 --- a/software/tests/control/test_MultiPointController.py +++ b/software/tests/control/test_MultiPointController.py @@ -726,17 +726,10 @@ def test_run_acquisition_continues_when_stop_live_times_out_but_mcu_recovers(): def _zarr_jsons_with_squid(mpc): - """The _squid attributes of every OME group zarr.json of the run just finished (array zarr.json files carry none).""" - import json + """The _squid attributes of every OME group zarr.json of the run just finished.""" from pathlib import Path - out = [] - for p in (Path(mpc.base_path) / mpc.experiment_ID).rglob("zarr.json"): - with open(p) as f: - attrs = json.load(f).get("attributes", {}).get("_squid") - if attrs is not None: - out.append(attrs) - return out + return ts.zarr_squid_attrs(Path(mpc.base_path) / mpc.experiment_ID) def _zarr_v3_controller(monkeypatch): @@ -752,24 +745,6 @@ def _zarr_v3_controller(monkeypatch): return mpc, tt -def _make_zarr_writes_fail(monkeypatch, message="disk full"): - """Every SaveZarrJob opens its store as usual (so there is a zarr.json to inspect) but its write fails.""" - import concurrent.futures - from control.core.job_processing import SaveZarrJob - - real_submit = SaveZarrJob.submit - - def failing_submit(self): - future, result = real_submit(self) - if future is not None: - future.result() - failed = concurrent.futures.Future() - failed.set_exception(RuntimeError(message)) - return failed, result - - monkeypatch.setattr(SaveZarrJob, "submit", failing_submit) - - def test_zarr_v3_multipoint_saves_in_process_and_seals(monkeypatch): """A Zarr v3 multipoint run saves through the in-process runner and ends with acquisition_complete=True.""" from control.core.in_process_zarr_runner import InProcessZarrRunner @@ -817,14 +792,14 @@ def test_zarr_v3_run_leaves_the_warm_subprocess_for_tiff(monkeypatch): from control.core import job_processing mpc, tt = _zarr_v3_controller(monkeypatch) - warm_before = mpc._prewarmed_job_runner + warm_before = mpc._prewarm._runner assert warm_before is not None started = [] # only subprocesses started from here on count; the controller's own pre-warm is above monkeypatch.setattr(job_processing.JobRunner, "start", lambda self: started.append(self)) mpc.run_acquisition() assert tt.finished_event.wait(120) assert started == [], "a Zarr v3 run must not start a save subprocess" - assert mpc._prewarmed_job_runner is warm_before + assert mpc._prewarm._runner is warm_before def test_three_zarr_runs_in_one_session_all_sealed(monkeypatch): @@ -845,7 +820,7 @@ def test_zarr_write_error_ends_the_run_as_error(monkeypatch, caplog): import logging mpc, tt = _zarr_v3_controller(monkeypatch) - _make_zarr_writes_fail(monkeypatch) + ts.make_zarr_writes_fail(monkeypatch) with caplog.at_level(logging.ERROR): mpc.run_acquisition() assert tt.finished_event.wait(120) @@ -890,8 +865,7 @@ def test_close_shuts_down_in_process_runner_instead_of_terminating_it(): mpc = MultiPointController.__new__(MultiPointController) # close() only needs the fields set below mpc._log = logging.getLogger("squid.MultiPointController") - mpc._prewarmed_job_runner = None - mpc._prewarmed_bp_values = None + mpc._prewarm = MagicMock() # PrewarmedJobRunnerSlot stand-in mpc._memory_monitor = None mpc.thread = None runner = MagicMock(spec=InProcessZarrRunner) @@ -922,7 +896,7 @@ def final_drain_only(self, drain_all=False): return real_summarize(self, drain_all=drain_all) mpc, tt = _zarr_v3_controller(monkeypatch) - _make_zarr_writes_fail(monkeypatch) + ts.make_zarr_writes_fail(monkeypatch) monkeypatch.setattr(MultiPointWorker, "_summarize_runner_outputs", final_drain_only) with caplog.at_level(logging.ERROR): mpc.run_acquisition() diff --git a/software/tests/control/test_acquisition_yaml_loader.py b/software/tests/control/test_acquisition_yaml_loader.py index ac676cbe6..d3d387385 100644 --- a/software/tests/control/test_acquisition_yaml_loader.py +++ b/software/tests/control/test_acquisition_yaml_loader.py @@ -270,6 +270,109 @@ def test_parse_channels_with_missing_names(self, tmp_path): assert result.channel_names == ["Valid Channel", "Another Valid"] +class TestParseRecordZstackYAML: + """Tests for parse_acquisition_yaml with widget_type: record_zstack.""" + + def test_parse_record_zstack_yaml_select_wells(self, tmp_path): + yaml_content = """ +acquisition: + widget_type: record_zstack + xy_mode: Select Wells +objective: + name: 20x + camera_binning: + - 1 + - 1 +time_series: + nt: 3 + delta_t_s: 5.0 +autofocus: + laser_af: true +recording: + enabled: true + channel: + name: BF LED matrix full + fps: 15.0 + duration_s: 2.0 + bottom_z_offset_um: 1.5 + nz: 3 + dz_um: 0.8 +z_stack: + enabled: true + channels: + - name: Fluorescence 488 nm Ex + z_min_um: -2.0 + z_max_um: 2.0 + z_step_um: 0.5 +wellplate_scan: + scan_size_mm: 1.2 + overlap_percent: 12.0 + regions: + - name: A1 + center_mm: [1.0, 2.0, 0.1] + shape: Square +""" + yaml_file = tmp_path / "test_record.yaml" + yaml_file.write_text(yaml_content) + + result = parse_acquisition_yaml(str(yaml_file)) + + assert result.widget_type == "record_zstack" + assert result.xy_mode == "Select Wells" + assert result.objective_name == "20x" + assert result.camera_binning == (1, 1) + assert result.nt == 3 + assert result.delta_t_s == 5.0 + assert result.laser_af is True + assert result.recording_enabled is True + assert result.recording_channel == {"name": "BF LED matrix full"} + assert result.fps == 15.0 + assert result.duration_s == 2.0 + assert result.recording_bottom_z_offset_um == 1.5 + assert result.recording_nz == 3 + assert result.recording_dz_um == 0.8 + assert result.zstack_enabled is True + assert result.zstack_channels == [{"name": "Fluorescence 488 nm Ex"}] + assert result.z_min_um == -2.0 + assert result.z_max_um == 2.0 + assert result.z_step_um == 0.5 + assert result.scan_size_mm == 1.2 + assert result.overlap_percent == 12.0 + assert result.wellplate_regions == [{"name": "A1", "center_mm": [1.0, 2.0, 0.1], "shape": "Square"}] + + def test_parse_record_zstack_yaml_minimal_defaults(self, tmp_path): + yaml_content = """ +acquisition: + widget_type: record_zstack +""" + yaml_file = tmp_path / "test_minimal_record.yaml" + yaml_file.write_text(yaml_content) + + result = parse_acquisition_yaml(str(yaml_file)) + + assert result.widget_type == "record_zstack" + assert result.recording_enabled is False + assert result.recording_channel is None + assert result.zstack_channels == [] + assert result.wellplate_regions is None + + def test_validate_hardware_accepts_record_zstack_yaml_data(self, tmp_path): + """validate_hardware() duck-types .objective_name/.camera_binning; must work unchanged.""" + yaml_content = """ +acquisition: + widget_type: record_zstack +objective: + name: 20x + camera_binning: [1, 1] +""" + yaml_file = tmp_path / "test_record_hw.yaml" + yaml_file.write_text(yaml_content) + result = parse_acquisition_yaml(str(yaml_file)) + + validation = validate_hardware(result, current_objective="20x", current_binning=(1, 1)) + assert validation.is_valid is True + + class TestValidateHardware: """Tests for validate_hardware function.""" diff --git a/software/tests/control/test_stubs.py b/software/tests/control/test_stubs.py index 8435c39fc..201ee8d99 100644 --- a/software/tests/control/test_stubs.py +++ b/software/tests/control/test_stubs.py @@ -1,7 +1,11 @@ +import json import os import shutil import tempfile +import threading import warnings +from pathlib import Path +from typing import Optional from control._def import OBJECTIVES, DEFAULT_OBJECTIVE from control.core.auto_focus_controller import AutoFocusController @@ -83,6 +87,62 @@ def get_test_scan_coordinates( return ScanCoordinates(objectiveStore=objective_store, stage=stage, camera=camera) +def zarr_squid_attrs(root) -> list[dict]: + """The _squid attributes of every OME group zarr.json under root (array zarr.json files carry none).""" + out = [] + for p in Path(root).rglob("zarr.json"): + with open(p) as f: + attrs = json.load(f).get("attributes", {}).get("_squid") + if attrs is not None: + out.append(attrs) + return out + + +class GatedFuture: + """A write future that completes once `release` is set, raising `error` then if given.""" + + def __init__(self, release: threading.Event, error: Optional[BaseException] = None): + self._release = release + self._error = error + + def done(self): + return self._release.is_set() + + def result(self): + self._release.wait() + if self._error is not None: + raise self._error + + +def stub_zarr_writer_io(monkeypatch, submit_frame): + """A ZarrWriter that touches no disk: open/seal are no-ops and `submit_frame(self, image, t, c, z, fov=None)` + stands in for the write (return a future with done()/result()).""" + from control.core.zarr_writer import ZarrWriter + + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + monkeypatch.setattr(ZarrWriter, "submit_frame", submit_frame) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: None) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: None) + + +def make_zarr_writes_fail(monkeypatch, message="disk full"): + """Every SaveZarrJob opens its store as usual (so there is a zarr.json to inspect) but its write fails.""" + import concurrent.futures + from control.core.job_processing import SaveZarrJob + + real_submit = SaveZarrJob.submit + + def failing_submit(self): + future, result = real_submit(self) + if future is not None: + future.result() + failed = concurrent.futures.Future() + failed.set_exception(RuntimeError(message)) + return failed, result + + monkeypatch.setattr(SaveZarrJob, "submit", failing_submit) + + def get_test_objective_store(): return ObjectiveStore(objectives_dict=OBJECTIVES, default_objective=DEFAULT_OBJECTIVE) diff --git a/software/tests/control/test_widgets.py b/software/tests/control/test_widgets.py index fb53b3f69..a19831a2e 100644 --- a/software/tests/control/test_widgets.py +++ b/software/tests/control/test_widgets.py @@ -20,7 +20,6 @@ check_ram_available_with_error_dialog, FocusMapWidget, NDViewerTab, - RecordingWidget, SurfacePlotWidget, ) from squid.abc import CameraFrame, CameraFrameFormat @@ -2373,231 +2372,6 @@ def test_expand_button_shows_only_dropped_when_single_message(self, warning_widg assert widget.btn_expand.isVisible() -# ============================================================================ -# RecordingWidget Tests -# ============================================================================ - - -@pytest.fixture -def recording_widget(qtbot, tmp_path, monkeypatch): - # `widgets.py` imports DEFAULT_SAVING_PATH via `from control._def import *`, which binds a - # separate name in the widgets module — both bindings must be patched. - monkeypatch.setattr(control._def, "DEFAULT_SAVING_PATH", str(tmp_path)) - monkeypatch.setattr(control.widgets, "DEFAULT_SAVING_PATH", str(tmp_path)) - - stream_handler = core_module.QtStreamHandler(accept_new_frame_fn=lambda: True) - image_saver = core_module.ImageSaver() - stream_handler.packet_image_to_write.connect(image_saver.enqueue) - - widget = RecordingWidget(stream_handler, image_saver) - qtbot.addWidget(widget) - try: - yield widget, stream_handler, image_saver - finally: - image_saver.close() - - -class TestRecordingWidget: - """Regression tests for the legacy single-camera RecordingWidget.""" - - def test_default_saving_path_applied_on_construction(self, recording_widget, tmp_path): - widget, _, image_saver = recording_widget - assert widget.lineEdit_savingDir.text() == str(tmp_path) - assert image_saver.base_path == str(tmp_path) - - def test_channel_change_mid_recording_reflected_in_filenames(self, qtbot, recording_widget): - widget, _, image_saver = recording_widget - - class _Stub: - def __init__(self, name): - self.name = name - self.exposure_time = 10.0 - self.analog_gain = 1.0 - self.illumination_intensity = 50.0 - - state = {"channel": _Stub("BF LED matrix full")} - image_saver.set_channel_provider(lambda: state["channel"]) - - widget.lineEdit_experimentID.setText("exp") - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - - img = np.zeros((8, 8), dtype=np.uint8) - image_saver.enqueue(img, 0, 0.0) - qtbot.waitUntil(lambda: image_saver.counter == 1, timeout=2000) - - state["channel"] = _Stub("488 nm") - image_saver.enqueue(img, 1, 1.0) - qtbot.waitUntil(lambda: image_saver.counter == 2, timeout=2000) - - exp_dir = Path(image_saver.base_path) / image_saver.experiment_ID - names = sorted(p.name for p in exp_dir.rglob("*") if p.suffix in (".tiff", ".bmp")) - assert any("BF_LED_matrix_full" in n for n in names), f"No BF file: {names}" - assert any("488_nm" in n for n in names), f"No 488 file: {names}" - - def test_widget_constructed_with_channel_provider(self, qtbot, tmp_path, monkeypatch): - """RecordingWidget accepts channel_provider kwarg and registers it on Record-press.""" - monkeypatch.setattr(control._def, "DEFAULT_SAVING_PATH", str(tmp_path)) - monkeypatch.setattr(control.widgets, "DEFAULT_SAVING_PATH", str(tmp_path)) - - stream_handler = core_module.QtStreamHandler(accept_new_frame_fn=lambda: True) - image_saver = core_module.ImageSaver() - stream_handler.packet_image_to_write.connect(image_saver.enqueue) - - sentinel = object() - provider = lambda: sentinel # noqa: E731 - - widget = RecordingWidget(stream_handler, image_saver, channel_provider=provider) - qtbot.addWidget(widget) - try: - widget.lineEdit_experimentID.setText("exp") - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - assert image_saver._channel_provider is provider - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - assert image_saver._channel_provider is None - finally: - image_saver.close() - - def test_close_does_not_hang_when_write_raises(self, qtbot, recording_widget): - """A write exception must still mark the queue item done; otherwise close() hangs on join().""" - widget, _, image_saver = recording_widget - widget.lineEdit_experimentID.setText("exp") - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - - with patch("cv2.imwrite", side_effect=RuntimeError("boom")): - image_saver.enqueue(np.zeros((4, 4), dtype=np.uint8), 0, 0.0) - qtbot.waitUntil(lambda: image_saver.queue.empty(), timeout=2000) - - def test_recording_lifecycle_finalizes_csv_and_starts_fresh(self, qtbot, recording_widget, caplog): - """Stopping a recording closes frames.csv, logs the summary, and a subsequent - recording opens a brand-new CSV (no file-descriptor leak across sessions).""" - widget, _, image_saver = recording_widget - - widget.lineEdit_experimentID.setText("first") - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - first_csv = image_saver._csv_file - assert first_csv is not None and not first_csv.closed - - image_saver.enqueue(np.zeros((4, 4), dtype=np.uint8), 0, 0.0) - qtbot.waitUntil(lambda: image_saver.counter == 1, timeout=2000) - - with caplog.at_level(logging.INFO): - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - - assert first_csv.closed, "Stopping should close frames.csv" - assert image_saver._csv_file is None - assert any("Recording stopped" in rec.message and "frames_saved=1" in rec.message for rec in caplog.records) - - widget.lineEdit_experimentID.setText("second") - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - second_csv = image_saver._csv_file - assert second_csv is not None and second_csv is not first_csv - assert not second_csv.closed - - def test_directory_creation_failure_raises_and_logs(self, qtbot, recording_widget, caplog): - _, _, image_saver = recording_widget - with patch("control.utils.ensure_directory_exists", side_effect=OSError("mock disk error")): - with caplog.at_level(logging.ERROR): - with pytest.raises(OSError): - image_saver.start_new_experiment("doomed") - assert any("Failed to create experiment directory" in rec.message for rec in caplog.records) - - def test_queue_full_logs_warning_and_increments_dropped_count(self, qtbot, recording_widget, caplog): - widget, _, image_saver = recording_widget - widget.lineEdit_experimentID.setText("exp") - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - - # Block the saver thread by holding image_lock so the queue can't drain. - img = np.zeros((8, 8), dtype=np.uint8) - with image_saver.image_lock: - with caplog.at_level(logging.WARNING): - for frame_id in range(40): - image_saver.enqueue(img, frame_id, 0.0) - - assert image_saver._dropped_count > 0 - assert any("queue full" in rec.message.lower() for rec in caplog.records) - - def test_frames_csv_written_with_metadata(self, qtbot, recording_widget): - widget, _, image_saver = recording_widget - - class _Stub: - name = "488 nm" - exposure_time = 25.0 - analog_gain = 1.5 - illumination_intensity = 80.0 - - image_saver.set_channel_provider(lambda: _Stub()) - - widget.lineEdit_experimentID.setText("exp") - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - exp_id = image_saver.experiment_ID # stop_experiment will clear it - - # Enqueue directly so streamHandler's save-FPS throttle doesn't drop frames. - img = np.zeros((16, 16), dtype=np.uint8) - for i in range(3): - image_saver.enqueue(img, i * 30, 1000.0 + i) - - qtbot.waitUntil(lambda: image_saver.counter == 3, timeout=2000) - # Stop the recording so frames.csv is flushed and closed. - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - - csv_path = Path(image_saver.base_path) / exp_id / "frames.csv" - assert csv_path.is_file() - - import csv as _csv - - with csv_path.open() as f: - rows = list(_csv.DictReader(f)) - assert len(rows) == 3 - assert [r["frame_id"] for r in rows] == ["0", "30", "60"] - assert rows[0]["channel"] == "488 nm" - assert float(rows[0]["exposure_ms"]) == 25.0 - assert float(rows[0]["gain"]) == 1.5 - assert float(rows[0]["illumination_intensity"]) == 80.0 - assert rows[0]["file"].endswith(".tiff") or rows[0]["file"].endswith(".bmp") - - def test_record_without_browse_starts_recording(self, qtbot, recording_widget): - widget, _, image_saver = recording_widget - widget.lineEdit_experimentID.setText("exp") - - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - - assert widget.btn_record.isChecked() - assert image_saver.experiment_ID.startswith("exp_") - assert not widget.lineEdit_experimentID.isEnabled() - assert not widget.btn_setSavingDir.isEnabled() - - def test_browse_cancel_preserves_existing_path(self, recording_widget, tmp_path): - widget, _, image_saver = recording_widget - with patch("qtpy.QtWidgets.QFileDialog.getExistingDirectory", return_value=""): - widget.set_saving_dir() - assert image_saver.base_path == str(tmp_path) - assert widget.lineEdit_savingDir.text() == str(tmp_path) - - def test_frame_is_persisted_to_disk(self, qtbot, recording_widget): - widget, stream_handler, image_saver = recording_widget - widget.lineEdit_experimentID.setText("exp") - widget.entry_saveFPS.setValue(1000) - qtbot.mouseClick(widget.btn_record, Qt.LeftButton) - - frame = CameraFrame( - frame_id=0, - timestamp=0.0, - frame=np.zeros((32, 32), dtype=np.uint8), - frame_format=CameraFrameFormat.RAW, - frame_pixel_format=CameraPixelFormat.MONO8, - ) - stream_handler.get_frame_callback()(frame) - - qtbot.waitUntil(lambda: image_saver.counter > 0, timeout=2000) - - exp_dir = Path(image_saver.base_path) / image_saver.experiment_ID - files = [p for p in exp_dir.rglob("*") if p.is_file()] - assert files, f"No image saved under {exp_dir}" - # No channel provider was set, so save_image gets the default "live" sentinel. - # Filename comes from utils_acquisition.get_image_filepath: _. - assert any("live" in p.stem for p in files), f"Expected 'live' in filename; got {[p.name for p in files]}" - - class TestWarningErrorWidgetErrorExemptionWithDroppedCount: """Tests for error exemption interacting with dropped count.""" diff --git a/software/tests/control/test_worker_reason.py b/software/tests/control/test_worker_reason.py index 61ed5cd70..6c8998b12 100644 --- a/software/tests/control/test_worker_reason.py +++ b/software/tests/control/test_worker_reason.py @@ -5,7 +5,6 @@ def _make_worker(): # Bypass __init__; we only exercise _compute_end_reason()'s pure logic. w = MultiPointWorker.__new__(MultiPointWorker) - w._run_state_fatal = False w._abort_cause = None w._acquisition_error_count = 0 w.abort_requested_fn = lambda: False @@ -29,9 +28,13 @@ def test_reason_error_on_timeout_abort(): assert w._compute_end_reason() == "error" -def test_reason_error_on_fatal_exception(): +def test_reason_error_after_abort_due_to_error(): + """The crash path and the failed-job path both go through _abort_due_to_error().""" w = _make_worker() - w._run_state_fatal = True + requested = [] + w.request_abort_fn = lambda: requested.append(True) + w.abort_requested_fn = lambda: bool(requested) + w._abort_due_to_error() assert w._compute_end_reason() == "error" diff --git a/software/tests/core/__init__.py b/software/tests/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/software/tests/core/test_record_zstack_worker.py b/software/tests/core/test_record_zstack_worker.py new file mode 100644 index 000000000..b8e7212c7 --- /dev/null +++ b/software/tests/core/test_record_zstack_worker.py @@ -0,0 +1,1849 @@ +import os +from pathlib import Path + +import pytest + +from control.core.record_zstack_controller import frame_count, zstack_plane_count, zstack_offsets_um + + +def test_frame_count(): + assert frame_count(10.0, 30.0) == 300 + assert frame_count(7.5, 2.0) == 15 + + +def test_zstack_plane_count_and_offsets(): + assert zstack_plane_count(-3.0, 3.0, 1.0) == 7 + assert zstack_offsets_um(-3.0, 3.0, 1.0) == [-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0] + assert zstack_plane_count(0.0, 5.0, 2.0) == 3 # 0,2,4 + assert zstack_offsets_um(0.0, 5.0, 2.0) == [0.0, 2.0, 4.0] + + +def test_zstack_plane_count_validation(): + with pytest.raises(ValueError): + zstack_plane_count(3.0, -3.0, 1.0) # z_max < z_min + with pytest.raises(ValueError): + zstack_plane_count(0.0, 3.0, 0.0) # step == 0 + with pytest.raises(ValueError): + zstack_plane_count(0.0, 3.0, -1.0) # step < 0 + + +# --------------------------------------------------------------------------- +# Task D2: RecordZStackWorker smoke test (simulated microscope, in-process Zarr runner) +# +# 2 wells x 2 FOV x 2 time points, both phases (recording + z-stack) enabled. +# Asserts: +# - one recording .ome.zarr per (t, well, fov) with shape (T,1,1,Y,X) +# - the z-stack per-FOV zarr datasets exist +# --------------------------------------------------------------------------- + + +def _build_simulated_microscope(crop_w: int, crop_h: int): + """Build a simulated microscope and shrink the camera frame for a fast test. + + The simulated camera reports its resolution as crop_width/crop_height + divided by binning, so pin binning to 1x1 — some machine configs default + the camera model to 2x2 binning, which would silently halve every frame — + and give this camera its own copy of the config so each captured/recorded + frame is exactly (crop_h, crop_w). The camera is built with the module-level + CameraConfig; writing the crop into that object would shrink the frames of + every microscope built later in the same pytest process. + """ + import control.microscope + + scope = control.microscope.Microscope.build_from_global_config(True) + scope.camera._config = scope.camera._config.model_copy(update={"crop_width": crop_w, "crop_height": crop_h}) + scope.camera.set_binning(1, 1) + return scope + + +def test_build_simulated_microscope_leaves_the_global_camera_config_alone(): + """Only this camera is 64x48; the shared CameraConfig that every later microscope is built from is untouched.""" + import squid.config + + before = squid.config.get_camera_config().model_copy() + scope = _build_simulated_microscope(64, 48) + assert scope.camera.get_resolution() == (64, 48) + assert squid.config.get_camera_config() == before + + +def test_record_zstack_worker_smoke(tmp_path): + pytest.importorskip("tensorstore") # optional dep; the worker writes real Zarr + import control._def + import tests.control.test_stubs as ts + from control.core.multi_point_controller import NoOpCallbacks + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters, frame_count + from control.core.record_zstack_worker import RecordZStackWorker + + # Use real Zarr v3 saving for the z-stack phase (the path under test). + control._def.FILE_SAVING_OPTION = control._def.FileSavingOption.ZARR_V3 + + crop_w, crop_h = 64, 48 + scope = _build_simulated_microscope(crop_w, crop_h) + live_controller = ts.get_test_live_controller(scope, scope.objective_store.default_objective) + laser_af = ts.get_test_laser_autofocus_controller(scope) + + channels = live_controller.get_channels(scope.objective_store.default_objective) + assert len(channels) >= 2 + recording_channel = channels[0] + zstack_channels = channels[:2] + + # Keep exposure short so capture/recording is fast. + scope.camera.set_exposure_time(1) + + # Move the stage to a safe mid-Z so z_ref +/- offsets stay in range. + z_cfg = scope.stage.get_config().Z_AXIS + z_mid = (z_cfg.MAX_POSITION + z_cfg.MIN_POSITION) / 2.0 + scope.stage.move_z_to(z_mid) + scope.low_level_drivers.microcontroller.wait_till_operation_is_completed() + + x_cfg = scope.stage.get_config().X_AXIS + y_cfg = scope.stage.get_config().Y_AXIS + x0 = x_cfg.MIN_POSITION + 1.0 + y0 = y_cfg.MIN_POSITION + 1.0 + # 2 wells x 2 FOV + scan_region_fov_coords = { + "A1": [(x0, y0), (x0 + 0.5, y0)], + "A2": [(x0 + 1.0, y0), (x0 + 1.5, y0)], + } + n_wells = len(scan_region_fov_coords) + n_fov = 2 + Nt = 2 + + fps = 10.0 + duration_s = 0.3 + T = frame_count(fps, duration_s) + assert T >= 1 + + params = RecordZStackAcquisitionParameters( + base_path=str(tmp_path), + experiment_id="rec_zstack_smoke", + Nt=Nt, + dt_s=0.0, + use_laser_af=False, # no reference set -> would fall back anyway + recording_enabled=True, + recording_channel=recording_channel, + fps=fps, + duration_s=duration_s, + recording_bottom_z_offset_um=0.0, + zstack_enabled=True, + zstack_channels=zstack_channels, + z_min_um=-1.0, + z_max_um=1.0, + z_step_um=1.0, # 3 planes + ) + + aborted = {"v": False} + + worker = RecordZStackWorker( + scope=scope, + live_controller=live_controller, + laser_auto_focus_controller=laser_af, + objective_store=scope.objective_store, + params=params, + callbacks=NoOpCallbacks, + abort_requested_fn=lambda: aborted["v"], + request_abort_fn=lambda: aborted.__setitem__("v", True), + scan_region_fov_coords=scan_region_fov_coords, + ) + + worker.run() + + assert not aborted["v"], "worker requested abort during the smoke run" + + base = Path(params.base_path) / params.experiment_id + + # Recording: one dataset per (t, well, fov). + rec = sorted((base / "recording").rglob("*.ome.zarr")) + assert len(rec) == Nt * n_wells * n_fov, f"expected {Nt * n_wells * n_fov} recordings, got {len(rec)}: {rec}" + + # Each recording dataset has shape (T, 1, 1, Y, X). + import tensorstore as tstore + + for path in rec: + ds = tstore.open({"driver": "zarr3", "kvstore": {"driver": "file", "path": str(path)}}).result() + assert tuple(ds.shape) == (T, 1, 1, crop_h, crop_w), f"bad recording shape {ds.shape} for {path}" + + # Z-stack: per-FOV zarr datasets exist under {experiment}/zarr (non-HCS per-FOV layout). + zstack_zarrs = list((base / "zarr").rglob("fov_*.ome.zarr")) + assert zstack_zarrs, f"no z-stack zarr datasets found under {base / 'zarr'}" + # One per (well, fov) — written across all time points/z/channels. + assert len(zstack_zarrs) == n_wells * n_fov, f"expected {n_wells * n_fov} z-stack fovs, got {len(zstack_zarrs)}" + + # Verify z-stack shape (T, C, Z, Y, X). + n_z = len(zstack_offsets_um(params.z_min_um, params.z_max_um, params.z_step_um)) + for path in zstack_zarrs: + ds = tstore.open({"driver": "zarr3", "kvstore": {"driver": "file", "path": str(path / "0")}}).result() + assert tuple(ds.shape) == (Nt, len(zstack_channels), n_z, crop_h, crop_w), f"bad z-stack shape {ds.shape}" + + +# --------------------------------------------------------------------------- +# Task D3: RecordZStackController smoke test +# +# Same 2-well x 2-FOV geometry as the D2 test but driven through +# RecordZStackController.run_acquisition() + join(). +# Also exercises Nt=2 with dt_s=0.0 (continuous: no grid pacing, so both +# time-points run even though the per-timepoint work exceeds any short dt — +# with dt>0 the worker now SKIPS missed slots, mirroring MultiPointWorker). +# --------------------------------------------------------------------------- + + +def test_record_zstack_controller_smoke(tmp_path, monkeypatch): + pytest.importorskip("tensorstore") # optional dep; the worker writes real Zarr + import control._def + import tests.control.test_stubs as ts + from control.core.multi_point_controller import NoOpCallbacks + from control.core.record_zstack_controller import ( + RecordZStackAcquisitionParameters, + RecordZStackController, + frame_count, + ) + from control.core.scan_coordinates import ScanCoordinates + + control._def.FILE_SAVING_OPTION = control._def.FileSavingOption.ZARR_V3 + + crop_w, crop_h = 64, 48 + scope = _build_simulated_microscope(crop_w, crop_h) + live_controller = ts.get_test_live_controller(scope, scope.objective_store.default_objective) + laser_af = ts.get_test_laser_autofocus_controller(scope) + + channels = live_controller.get_channels(scope.objective_store.default_objective) + assert len(channels) >= 2 + recording_channel = channels[0] + zstack_channels = channels[:2] + + scope.camera.set_exposure_time(1) + + z_cfg = scope.stage.get_config().Z_AXIS + z_mid = (z_cfg.MAX_POSITION + z_cfg.MIN_POSITION) / 2.0 + scope.stage.move_z_to(z_mid) + scope.low_level_drivers.microcontroller.wait_till_operation_is_completed() + + x_cfg = scope.stage.get_config().X_AXIS + y_cfg = scope.stage.get_config().Y_AXIS + x0 = x_cfg.MIN_POSITION + 1.0 + y0 = y_cfg.MIN_POSITION + 1.0 + + # Build a minimal ScanCoordinates with two regions, two FOVs each. + scan_coords = ScanCoordinates( + objectiveStore=scope.objective_store, + stage=scope.stage, + camera=scope.camera, + ) + scan_coords.region_fov_coordinates = { + "A1": [(x0, y0), (x0 + 0.5, y0)], + "A2": [(x0 + 1.0, y0), (x0 + 1.5, y0)], + } + n_wells = 2 + n_fov = 2 + Nt = 2 + + fps = 10.0 + duration_s = 0.3 + T = frame_count(fps, duration_s) + assert T >= 1 + + controller = RecordZStackController( + microscope=scope, + live_controller=live_controller, + laser_autofocus_controller=laser_af, + objective_store=scope.objective_store, + scan_coordinates=scan_coords, + callbacks=NoOpCallbacks, + ) + + # Build params directly and pass to run_acquisition (same path as the widget). + params = RecordZStackAcquisitionParameters( + base_path=str(tmp_path), + experiment_id="ctrl_smoke", + Nt=Nt, + dt_s=0.0, # continuous: dt>0 would skip slots missed while working + use_laser_af=False, + recording_enabled=True, + recording_channel=recording_channel, + fps=fps, + duration_s=duration_s, + recording_bottom_z_offset_um=0.0, + zstack_enabled=True, + zstack_channels=zstack_channels, + z_min_um=-1.0, + z_max_um=1.0, + z_step_um=1.0, # 3 planes + ) + + controller.run_acquisition(params) + # Wait up to 120 s for the worker thread to finish. + controller.join(timeout=120) + assert not controller.acquisition_in_progress(), "worker thread did not finish in time" + + # Determine the resolved experiment_id (has a timestamp appended). + subdirs = [d for d in Path(tmp_path).iterdir() if d.is_dir()] + assert len(subdirs) == 1, f"expected exactly one experiment dir, got {subdirs}" + base = subdirs[0] + + # Recording: one dataset per (t, well, fov). + rec = sorted((base / "recording").rglob("*.ome.zarr")) + assert len(rec) == Nt * n_wells * n_fov, f"expected {Nt * n_wells * n_fov} recordings, got {len(rec)}: {rec}" + + import tensorstore as tstore + + for path in rec: + ds = tstore.open({"driver": "zarr3", "kvstore": {"driver": "file", "path": str(path)}}).result() + assert tuple(ds.shape) == (T, 1, 1, crop_h, crop_w), f"bad recording shape {ds.shape} for {path}" + + # Z-stack: per-FOV zarr datasets exist. + zstack_zarrs = list((base / "zarr").rglob("fov_*.ome.zarr")) + assert zstack_zarrs, f"no z-stack zarr datasets found under {base / 'zarr'}" + assert len(zstack_zarrs) == n_wells * n_fov, f"expected {n_wells * n_fov} z-stack fovs, got {len(zstack_zarrs)}" + + n_z = len(zstack_offsets_um(-1.0, 1.0, 1.0)) + for path in zstack_zarrs: + ds = tstore.open({"driver": "zarr3", "kvstore": {"driver": "file", "path": str(path / "0")}}).result() + assert tuple(ds.shape) == (Nt, len(zstack_channels), n_z, crop_h, crop_w), f"bad z-stack shape {ds.shape}" + + controller.close() + + +def _probe_frame_shape_with(camera): + """RecordZStackWorker._probe_frame_shape on a worker shell that has only this camera and a logger.""" + import squid.logging + from control.core.record_zstack_worker import RecordZStackWorker + + worker = RecordZStackWorker.__new__(RecordZStackWorker) + worker.camera = camera + worker._log = squid.logging.get_logger("RecordZStackWorker") + return worker._probe_frame_shape() + + +def test_probe_frame_shape_uses_processed_frame_not_resolution(): + """The recording dataset must be sized from a delivered (processed) frame: + real cameras crop/rotate frames in _process_raw_frame, so get_resolution() + (sensor/binned size) is the wrong source and yields blank recordings.""" + from types import SimpleNamespace + + import numpy as np + + from control.core.record_zstack_worker import RecordZStackWorker + + processed = np.zeros((50, 60), dtype=np.uint8) # crop/rotation already applied + + class _FakeCamera: + def get_resolution(self): + return (100, 80) # (width, height) sensor size — differs from frames + + def get_pixel_format(self): + raise NotImplementedError # force the dtype to come from the frame + + def set_acquisition_mode(self, mode): + pass + + def start_streaming(self): + pass + + def stop_streaming(self): + pass + + def send_trigger(self): + pass + + def read_camera_frame(self): + return SimpleNamespace(frame=processed) + + fake_self = SimpleNamespace(camera=_FakeCamera()) + y, x, dtype = _probe_frame_shape_with(fake_self.camera) + + assert (y, x) == (50, 60), f"expected processed-frame shape, got ({y}, {x})" + assert dtype == np.uint8 + + +# --------------------------------------------------------------------------- +# Review-fix tests: post-run hardware state restore (F8, F9, F10) +# --------------------------------------------------------------------------- + + +def _build_simulated_setup(): + """A 64x48 simulated scope at mid-Z with its live and laser-AF controllers, its channels, and one valid FOV.""" + import control._def + import tests.control.test_stubs as ts + + control._def.FILE_SAVING_OPTION = control._def.FileSavingOption.ZARR_V3 + scope = _build_simulated_microscope(64, 48) + live_controller = ts.get_test_live_controller(scope, scope.objective_store.default_objective) + laser_af = ts.get_test_laser_autofocus_controller(scope) + channels = live_controller.get_channels(scope.objective_store.default_objective) + scope.camera.set_exposure_time(1) + + z_cfg = scope.stage.get_config().Z_AXIS + scope.stage.move_z_to((z_cfg.MAX_POSITION + z_cfg.MIN_POSITION) / 2.0) + scope.low_level_drivers.microcontroller.wait_till_operation_is_completed() + x0 = scope.stage.get_config().X_AXIS.MIN_POSITION + 1.0 + y0 = scope.stage.get_config().Y_AXIS.MIN_POSITION + 1.0 + return scope, live_controller, laser_af, channels, (x0, y0) + + +def _params(base_path, channels, **overrides): + """Tiny Record + Z-Stack parameters: one timepoint, a 0.2 s recording of channels[0], a 2-plane z-stack + of channels[1]; overrides replace any field.""" + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters + + values = dict( + base_path=str(base_path), + experiment_id="state_restore", + Nt=1, + dt_s=0.0, + use_laser_af=False, + recording_enabled=True, + recording_channel=channels[0], + fps=10.0, + duration_s=0.2, + recording_bottom_z_offset_um=0.0, + zstack_enabled=True, + zstack_channels=list(channels[1:2]), + z_min_um=0.0, + z_max_um=1.0, + z_step_um=1.0, + ) + values.update(overrides) + return RecordZStackAcquisitionParameters(**values) + + +def _build_controller_harness(): + """A RecordZStackController on the simulated setup with one FOV; returns (scope, live_controller, channels, controller).""" + import tests.control.test_stubs as ts + from control.core.multi_point_controller import NoOpCallbacks + from control.core.record_zstack_controller import RecordZStackController + + scope, live_controller, laser_af, channels, fov = _build_simulated_setup() + scan_coords = ts.get_test_scan_coordinates(scope.objective_store, scope.stage, scope.camera) + scan_coords.region_fov_coordinates = {"A1": [fov]} + controller = RecordZStackController( + microscope=scope, + live_controller=live_controller, + laser_autofocus_controller=laser_af, + objective_store=scope.objective_store, + scan_coordinates=scan_coords, + callbacks=NoOpCallbacks, + ) + return scope, live_controller, channels, controller + + +def _build_worker_harness( + tmp_path, + recording_enabled, + zstack_enabled, + zstack_channel_slice=slice(1, 2), + callbacks=None, + display_frame_fn=None, + display_fps=0.0, + **param_overrides, +): + """A 1-FOV worker on the simulated setup; param_overrides replace fields of _params().""" + from control.core.multi_point_controller import NoOpCallbacks + from control.core.record_zstack_worker import RecordZStackWorker + + scope, live_controller, laser_af, channels, fov = _build_simulated_setup() + params = _params( + tmp_path, + channels, + recording_enabled=recording_enabled, + zstack_enabled=zstack_enabled, + zstack_channels=list(channels[zstack_channel_slice]) if zstack_enabled else [], + **param_overrides, + ) + aborted = {"v": False} + worker = RecordZStackWorker( + scope=scope, + live_controller=live_controller, + laser_auto_focus_controller=laser_af, + objective_store=scope.objective_store, + params=params, + callbacks=NoOpCallbacks if callbacks is None else callbacks, + abort_requested_fn=lambda: aborted["v"], + request_abort_fn=lambda: aborted.__setitem__("v", True), + scan_region_fov_coords={"A1": [fov]}, + display_frame_fn=display_frame_fn, + display_fps=display_fps, + ) + return scope, live_controller, channels, worker, aborted + + +def test_record_only_restores_trigger_mode(tmp_path): + """F8: a record-only run must not leave the camera in SOFTWARE_TRIGGER when + the LiveController was in CONTINUOUS (or HARDWARE) before the acquisition.""" + pytest.importorskip("tensorstore") + from control._def import TriggerMode + from squid.abc import CameraAcquisitionMode + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + live_controller.set_microscope_mode(channels[0]) + live_controller.set_trigger_mode(TriggerMode.CONTINUOUS) + + worker.run() + + assert not aborted["v"] + assert live_controller.trigger_mode == TriggerMode.CONTINUOUS + assert ( + scope.camera.get_acquisition_mode() == CameraAcquisitionMode.CONTINUOUS + ), "camera left out of sync with LiveController trigger mode after record-only run" + + +def test_run_restores_pre_acquisition_channel_config(tmp_path): + """F10: after the acquisition, the hardware must be back on the channel the + user was viewing, not the last z-stack channel.""" + pytest.importorskip("tensorstore") + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=False, zstack_enabled=True, zstack_channel_slice=slice(1, 2) + ) + live_controller.set_microscope_mode(channels[0]) # user was viewing channel 0 + + worker.run() + + assert not aborted["v"] + current = live_controller.currentConfiguration + assert current is not None and current.name == channels[0].name, ( + f"expected channel config restored to {channels[0].name!r}, " f"got {current.name if current else None!r}" + ) + + +def test_zstack_trigger_fallback_keeps_livecontroller_in_sync(tmp_path, monkeypatch): + """F9: if set_trigger_mode(SOFTWARE) fails once, the fallback must keep + liveController.trigger_mode in sync with the camera — otherwise + acquire_camera_image takes neither illumination branch and the whole + z-stack is captured dark.""" + pytest.importorskip("tensorstore") + from control._def import TriggerMode + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=False, zstack_enabled=True + ) + live_controller.set_microscope_mode(channels[0]) + live_controller.set_trigger_mode(TriggerMode.CONTINUOUS) + + orig_set_trigger_mode = live_controller.set_trigger_mode + calls = {"n": 0} + + def flaky(mode): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("simulated MCU ACK timeout") + return orig_set_trigger_mode(mode) + + monkeypatch.setattr(live_controller, "set_trigger_mode", flaky) + + seen_modes = [] + orig_acquire = worker.acquire_camera_image + + def spy(*args, **kwargs): + seen_modes.append(live_controller.trigger_mode) + return orig_acquire(*args, **kwargs) + + monkeypatch.setattr(worker, "acquire_camera_image", spy) + + worker.run() + + assert seen_modes, "no z-stack captures happened" + assert all(m == TriggerMode.SOFTWARE for m in seen_modes), ( + f"z-stack captured with stale liveController.trigger_mode {seen_modes[0]} " + f"— illumination branch skipped (dark images)" + ) + + +def test_recording_uses_achievable_fps_when_camera_clamps(tmp_path): + """F6: when the camera clamps below the requested fps (exposure-limited), + the dataset size and time metadata must reflect the achievable rate — + otherwise the run stalls to the timeout and trailing planes are blank.""" + pytest.importorskip("tensorstore") + import json + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + # record() applies the recording channel (exposure included) before probing + # the frame rate, so the long exposure must live on the channel itself. + channels[0].exposure_time = 200.0 # ms → achievable ≈ a few fps, well below 10 + scope.camera.set_exposure_time(200) + achievable = scope.camera.set_frame_rate(10.0) + assert achievable < 10.0, "precondition: camera must clamp below the requested rate" + + worker.run() + assert not aborted["v"] + + rec = sorted((Path(tmp_path) / "state_restore" / "recording").rglob("*.ome.zarr")) + assert len(rec) == 1 + meta = json.load(open(rec[0] / "zarr.json")) + squid_attrs = meta["attributes"]["_squid"] + expected_T = max(1, frame_count(achievable, 0.2)) + assert squid_attrs["shape"][0] == expected_T, ( + f"dataset sized for the requested fps (T={squid_attrs['shape'][0]}), " + f"expected achievable-rate T={expected_T}" + ) + assert squid_attrs["requested_fps"] == 10.0 + assert abs(squid_attrs["effective_fps"] - achievable) < 1e-6 + assert abs(squid_attrs["time_increment_s"] - 1.0 / achievable) < 1e-6, ( + f"time_increment_s={squid_attrs['time_increment_s']} does not match the " + f"achievable rate (1/{achievable:.2f})" + ) + + +def test_move_xy_honors_z_component(): + """F11: (x, y, z) scan coordinates carry a stored per-FOV focus plane + (flexible regions, update_fov_z); dropping z means recording/z-stacking at + the previous FOV's focus on tilted samples.""" + from types import SimpleNamespace + from unittest.mock import MagicMock + + from control.core.record_zstack_worker import RecordZStackWorker + + stage = MagicMock() + fake_self = SimpleNamespace(stage=stage, _sleep=lambda s: None, wait_till_operation_is_completed=lambda: None) + RecordZStackWorker._move_xy(fake_self, (1.0, 2.0, 3.5)) + stage.move_x_to.assert_called_once_with(1.0) + stage.move_y_to.assert_called_once_with(2.0) + stage.move_z_to.assert_called_once_with(3.5) + + +def test_move_xy_two_tuple_does_not_move_z(): + from types import SimpleNamespace + from unittest.mock import MagicMock + + from control.core.record_zstack_worker import RecordZStackWorker + + stage = MagicMock() + fake_self = SimpleNamespace(stage=stage, _sleep=lambda s: None, wait_till_operation_is_completed=lambda: None) + RecordZStackWorker._move_xy(fake_self, (1.0, 2.0)) + stage.move_z_to.assert_not_called() + + +def test_wait_for_dt_paces_from_acquisition_start(tmp_path): + """F12: dt is the interval between timepoint STARTS (t0 + k*dt, matching + MultiPointWorker and the recorded time_increment_s metadata), not a wait + appended after each timepoint's work.""" + import time as _time + + pytest.importorskip("tensorstore") + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + worker.params.dt_s = 2.0 + worker._acq_start_time = _time.monotonic() - 100.0 # the work already overran the interval + + t0 = _time.monotonic() + ok = worker._wait_for_dt(worker._acq_start_time + 1 * worker.params.dt_s) + took = _time.monotonic() - t0 + + assert ok + assert took < 1.0, f"_wait_for_dt slept {took:.1f}s though t=1's start time is long past" + + +def test_controller_writes_config_snapshot_and_done_file(tmp_path): + """F15: every experiment dir must carry the settings snapshot + (acquisition_channels.yaml) and completion marker (.done) that every + multipoint acquisition produces — downstream watchers depend on both.""" + pytest.importorskip("tensorstore") + import control._def + import tests.control.test_stubs as ts + from control.core.multi_point_controller import NoOpCallbacks + from control.core.record_zstack_controller import ( + RecordZStackAcquisitionParameters, + RecordZStackController, + ) + + control._def.FILE_SAVING_OPTION = control._def.FileSavingOption.ZARR_V3 + scope = _build_simulated_microscope(64, 48) + live_controller = ts.get_test_live_controller(scope, scope.objective_store.default_objective) + laser_af = ts.get_test_laser_autofocus_controller(scope) + channels = live_controller.get_channels(scope.objective_store.default_objective) + scope.camera.set_exposure_time(1) + + controller = RecordZStackController( + microscope=scope, + live_controller=live_controller, + laser_autofocus_controller=laser_af, + objective_store=scope.objective_store, + scan_coordinates=None, # no FOVs — completion bookkeeping is what's under test + callbacks=NoOpCallbacks, + ) + params = RecordZStackAcquisitionParameters( + base_path=str(tmp_path), + experiment_id="bookkeeping", + Nt=1, + recording_enabled=True, + recording_channel=channels[0], + fps=10.0, + duration_s=0.2, + ) + try: + controller.run_acquisition(params) + controller.join(timeout=60) + finally: + controller.close() + + exp = Path(params.base_path) / params.experiment_id + assert exp.is_dir() + assert (exp / "acquisition_channels.yaml").exists(), "settings snapshot missing from experiment dir" + assert (exp / ".done").exists(), "completion marker missing from experiment dir" + + +def test_pace_timepoint_skips_missed_slots(tmp_path): + """Round-2 parity: MultiPointWorker skips timepoints whose slot already + passed (grid-preserving); running them late back-to-back silently breaks + the recorded time_increment_s for the rest of the run.""" + import time as _time + + pytest.importorskip("tensorstore") + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + worker.params.dt_s = 2.0 + + worker._acq_start_time = _time.monotonic() - 100.0 + assert worker._pace_timepoint(1) == "skip" + + worker._acq_start_time = _time.monotonic() + assert worker._pace_timepoint(0) == "run" + + aborted["v"] = True + assert worker._pace_timepoint(1) in ("abort", "skip") # never 'run' while aborted + + +def test_probe_frame_shape_rejects_color_frames(): + """Round-2: a color (Y,X,3) probe frame silently produced a 2-D dataset + that every write then failed against; reject it with a clear error.""" + from types import SimpleNamespace + + import numpy as np + + from control.core.record_zstack_worker import RecordZStackWorker + + color = np.zeros((50, 60, 3), dtype=np.uint8) + + class _FakeColorCamera: + def get_resolution(self): + return (60, 50) + + def set_acquisition_mode(self, mode): + pass + + def start_streaming(self): + pass + + def stop_streaming(self): + pass + + def send_trigger(self): + pass + + def read_camera_frame(self): + return SimpleNamespace(frame=color) + + fake_self = SimpleNamespace(camera=_FakeColorCamera()) + with pytest.raises(ValueError, match="monochrome"): + _probe_frame_shape_with(fake_self.camera) + + +def test_probe_frame_shape_uses_continuous_and_never_triggers(): + """Regression: starting a recording with Live in SOFTWARE-trigger mode left + an outstanding trigger, so the old probe's send_trigger() was rejected + ("trigger too early") and it fell back to the wrong get_resolution() size, + failing every write on cropped cameras. The probe must run in CONTINUOUS + mode (what recording uses) and never call send_trigger, so trigger state + can't block it, and must size from the delivered (cropped) frame.""" + from types import SimpleNamespace + + import numpy as np + + from control.core.record_zstack_worker import RecordZStackWorker + from squid.abc import CameraAcquisitionMode + + processed = np.zeros((2084, 2084), dtype=np.uint16) # cropped delivered shape + modes = [] + + class _FakeCamera: + def get_resolution(self): + return (3104, 2084) # raw binned sensor (width, height) — must NOT be used + + def set_acquisition_mode(self, mode): + modes.append(mode) + + def start_streaming(self): + pass + + def stop_streaming(self): + pass + + def send_trigger(self, *args, **kwargs): + raise AssertionError("probe must not send a trigger in CONTINUOUS mode") + + def read_camera_frame(self): + return SimpleNamespace(frame=processed) + + fake_self = SimpleNamespace(camera=_FakeCamera()) + y, x, dtype = _probe_frame_shape_with(fake_self.camera) + + assert (y, x) == (2084, 2084), f"expected cropped delivered shape, got ({y}, {x})" + assert dtype == np.uint16 + assert CameraAcquisitionMode.CONTINUOUS in modes, "probe must set CONTINUOUS mode" + + +def test_probe_frame_shape_aborts_when_no_frame_delivered(): + """If the camera delivers no probe frame, the probe must raise (aborting the + run with a clear error) rather than sizing from get_resolution() — the raw + sensor size, which makes every write fail on cropped cameras (a blank run).""" + from types import SimpleNamespace + + from control.core.record_zstack_worker import RecordZStackWorker + + class _FakeCamera: + def get_resolution(self): + return (3104, 2084) + + def set_acquisition_mode(self, mode): + pass + + def start_streaming(self): + pass + + def stop_streaming(self): + pass + + def read_camera_frame(self): + return None + + fake_self = SimpleNamespace(camera=_FakeCamera()) + with pytest.raises(RuntimeError, match="probe"): + _probe_frame_shape_with(fake_self.camera) + + +def test_record_fails_fast_on_dropped_frames(tmp_path, monkeypatch): + """R2: sustained backpressure drops are the systematic slow-disk condition + the fail-fast was written for — record() must abort the acquisition, not + grind through hours of half-blank FOVs.""" + pytest.importorskip("tensorstore") + import control.core.record_zstack_worker as worker_mod + + class _DroppyWriter: + """Stand-in RecordingWriter that reports backpressure drops.""" + + def __init__(self, cfg, **kwargs): + self.dropped_count = 5 + self.write_error_count = 0 + self.finalize_wedged = False + + def start(self): + pass + + def enqueue(self, frame, t, c, z): + pass + + def mark_incomplete(self, captured, expected): + pass + + def finalize(self, timeout_s=30.0): + pass + + def abort(self): + pass + + monkeypatch.setattr(worker_mod, "RecordingWriter", _DroppyWriter) + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + worker.run() + + assert aborted["v"] is True, "acquisition continued despite dropped recording frames" + + +def test_config_snapshot_dedupes_channels_by_name(tmp_path): + """R10: recording the same channel that is also z-stacked must not produce + two same-name entries in acquisition_channels.yaml (channels are identified + by name; duplicate names make the snapshot ambiguous).""" + pytest.importorskip("tensorstore") + import yaml + + import control._def + import tests.control.test_stubs as ts + from control.core.multi_point_controller import NoOpCallbacks + from control.core.record_zstack_controller import ( + RecordZStackAcquisitionParameters, + RecordZStackController, + ) + + control._def.FILE_SAVING_OPTION = control._def.FileSavingOption.ZARR_V3 + scope = _build_simulated_microscope(64, 48) + live_controller = ts.get_test_live_controller(scope, scope.objective_store.default_objective) + laser_af = ts.get_test_laser_autofocus_controller(scope) + channels = live_controller.get_channels(scope.objective_store.default_objective) + scope.camera.set_exposure_time(1) + + controller = RecordZStackController( + microscope=scope, + live_controller=live_controller, + laser_autofocus_controller=laser_af, + objective_store=scope.objective_store, + scan_coordinates=None, + callbacks=NoOpCallbacks, + ) + params = RecordZStackAcquisitionParameters( + base_path=str(tmp_path), + experiment_id="dedupe", + Nt=1, + recording_enabled=True, + recording_channel=channels[0], + fps=10.0, + duration_s=0.2, + zstack_enabled=True, + zstack_channels=[channels[0], channels[1]], # channel 0 in BOTH phases + z_min_um=0.0, + z_max_um=1.0, + z_step_um=1.0, + ) + try: + controller.run_acquisition(params) + controller.join(timeout=60) + finally: + controller.close() + + snapshot = yaml.safe_load(open(Path(tmp_path) / params.experiment_id / "acquisition_channels.yaml")) + names = [c["name"] for c in snapshot["channels"]] + assert len(names) == len(set(names)), f"duplicate channel names in snapshot: {names}" + + +def test_recording_plane_offsets(): + from control.core.record_zstack_controller import recording_plane_offsets_um + + assert recording_plane_offsets_um(-2.0, 3, 4.0) == [-2.0, 2.0, 6.0] + assert recording_plane_offsets_um(5.0, 1, 1.0) == [5.0] # Nz=1: dz irrelevant + + +def test_recording_plane_offsets_validation(): + from control.core.record_zstack_controller import recording_plane_offsets_um + + with pytest.raises(ValueError): + recording_plane_offsets_um(0.0, 0, 1.0) # Nz < 1 + with pytest.raises(ValueError): + recording_plane_offsets_um(0.0, 2, 0.0) # dz <= 0 with Nz > 1 + + +def test_recording_path_plane_naming(): + from types import SimpleNamespace + + from control.core.record_zstack_worker import RecordZStackWorker + + fake_self = SimpleNamespace(experiment_path="/exp") + single = RecordZStackWorker._recording_path(fake_self, 0, "B2", 1).replace(os.sep, "/") + multi = RecordZStackWorker._recording_path(fake_self, 0, "B2", 1, plane_idx=2, n_planes=3).replace(os.sep, "/") + assert single.endswith("recording/t0/B2/fov_1.ome.zarr") # Nz=1 keeps today's name + assert multi.endswith("recording/t0/B2/fov_1_z2.ome.zarr") + + +def test_record_multi_plane_writes_one_zarr_per_plane(tmp_path): + """Nz=3 → three per-plane recordings per FOV with correct names, shapes, + and plane metadata; Nz=1 filename compatibility is covered by the smoke test.""" + pytest.importorskip("tensorstore") + import json + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + worker.params.recording_Nz = 3 + worker.params.recording_dz_um = 2.0 + worker.params.recording_bottom_z_offset_um = -2.0 + + worker.run() + assert not aborted["v"] + + rec_dir = Path(tmp_path) / "state_restore" / "recording" / "t0" / "A1" + names = sorted(p.name for p in rec_dir.glob("*.ome.zarr")) + assert names == ["fov_0_z0.ome.zarr", "fov_0_z1.ome.zarr", "fov_0_z2.ome.zarr"] + + for j, name in enumerate(names): + meta = json.load(open(rec_dir / name / "zarr.json")) + squid_attrs = meta["attributes"]["_squid"] + assert squid_attrs["plane_index"] == j + assert squid_attrs["n_planes"] == 3 + assert abs(squid_attrs["plane_z_offset_um"] - (-2.0 + j * 2.0)) < 1e-9 + assert squid_attrs["acquisition_complete"] is True + assert squid_attrs["shape"][1:3] == [1, 1] # (T,1,1,Y,X) per plane + + +def test_record_multi_plane_abort_between_planes(tmp_path): + """Abort after the first plane completes → exactly one plane file, no error, + run() still finishes (state restore + done marker).""" + pytest.importorskip("tensorstore") + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + worker.params.recording_Nz = 3 + worker.params.recording_dz_um = 1.0 + + orig = worker._record_one_plane + + def record_then_abort(*args, **kwargs): + emitted = orig(*args, **kwargs) + aborted["v"] = True # user presses Stop right after plane 0 finishes + return emitted + + worker._record_one_plane = record_then_abort + worker.run() + + rec_dir = Path(tmp_path) / "state_restore" / "recording" / "t0" / "A1" + names = sorted(p.name for p in rec_dir.glob("*.ome.zarr")) + assert names == ["fov_0_z0.ome.zarr"], f"expected only plane 0 after abort, got {names}" + assert (Path(tmp_path) / "state_restore" / ".done").exists() + + +# --------------------------------------------------------------------------- +# Final-review-fix tests: illumination timing + fail-fast restore +# --------------------------------------------------------------------------- + + +def test_record_moves_to_first_plane_before_illumination(tmp_path): + """Finding 1: the first plane's Z move must happen BEFORE illumination + turns on (pre-branch parity — the sample must not be illuminated during + the Z move + settle). Later planes move while illumination is already on + (it is not toggled between planes), and it turns off only after the last + plane.""" + pytest.importorskip("tensorstore") + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + worker.params.recording_Nz = 3 + worker.params.recording_dz_um = 1.0 + + events = [] + call_count = {"n": 0} + + def fake_record_one_plane(*args, **kwargs): + call_count["n"] += 1 + return 0 + + worker._record_one_plane = fake_record_one_plane + + orig_move = worker._move_z_to_offset + + def move_spy(z_ref, offset): + events.append(("move", offset)) + return orig_move(z_ref, offset) + + worker._move_z_to_offset = move_spy + + orig_on = live_controller.turn_on_illumination + + def on_spy(): + events.append(("illum_on",)) + return orig_on() + + live_controller.turn_on_illumination = on_spy + + orig_off = live_controller.turn_off_illumination + + def off_spy(): + events.append(("illum_off",)) + return orig_off() + + live_controller.turn_off_illumination = off_spy + + z_ref = scope.stage.get_pos().z_mm + total = worker.record(0, "A1", 0, z_ref) + + assert call_count["n"] == 3, "all three planes must still be recorded" + assert total == 0 + assert events == [ + ("move", 0.0), + ("illum_on",), + ("move", 1.0), + ("move", 2.0), + ("illum_off",), + ], f"unexpected event order: {events}" + + +def test_record_restores_camera_mode_and_stage_z_when_plane_raises(tmp_path): + """Finding 2: when _record_one_plane raises (fail-fast), the post-loop + camera-mode restore + stage-Z restore + settle must still run (the stage + must not be left at a plane offset), illumination must still turn off, and + the RuntimeError must still propagate.""" + pytest.importorskip("tensorstore") + from squid.abc import CameraAcquisitionMode + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + worker.params.recording_Nz = 3 + worker.params.recording_dz_um = 1.0 + + events = [] + call_count = {"n": 0} + + def fake_record_one_plane(*args, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 2: + raise RuntimeError("simulated fail-fast") + return 0 + + worker._record_one_plane = fake_record_one_plane + + orig_off = live_controller.turn_off_illumination + + def off_spy(): + events.append("illum_off") + return orig_off() + + live_controller.turn_off_illumination = off_spy + + orig_move_z_to = scope.stage.move_z_to + restore_calls = [] + + def move_z_to_spy(z_mm): + restore_calls.append(z_mm) + return orig_move_z_to(z_mm) + + scope.stage.move_z_to = move_z_to_spy + + z_ref = scope.stage.get_pos().z_mm + + with pytest.raises(RuntimeError, match="simulated fail-fast"): + worker.record(0, "A1", 0, z_ref) + + assert events == ["illum_off"], "illumination must still be turned off on the raising path" + assert restore_calls and restore_calls[-1] == pytest.approx( + z_ref + ), "stage-Z restore must still run after _record_one_plane raises" + assert ( + scope.camera.get_acquisition_mode() == CameraAcquisitionMode.SOFTWARE_TRIGGER + ), "camera acquisition mode must be restored to SOFTWARE_TRIGGER after a raising plane" + + +def test_build_objective_info_reads_objective_and_camera(): + from unittest.mock import MagicMock + from control.core.record_zstack_controller import _build_objective_info + + objective_store = MagicMock() + objective_store.current_objective = "20x" + objective_store.objectives_dict = {"20x": {"magnification": 20.0}} + objective_store.get_pixel_size_factor.return_value = 1.0 + + camera = MagicMock() + camera.get_binning.return_value = (2, 2) + camera.get_pixel_size_binned_um.return_value = 0.5 + + info = _build_objective_info(objective_store, camera) + + assert info["name"] == "20x" + assert info["magnification"] == 20.0 + assert info["camera_binning"] == [2, 2] + assert info["pixel_size_um"] == pytest.approx(0.5) + + +def test_build_objective_info_handles_missing_camera(): + from unittest.mock import MagicMock + from control.core.record_zstack_controller import _build_objective_info + + objective_store = MagicMock() + objective_store.current_objective = "10x" + objective_store.objectives_dict = {} + + info = _build_objective_info(objective_store, None) + + assert info["name"] == "10x" + assert info["camera_binning"] is None + assert info["pixel_size_um"] is None + + +def test_save_record_zstack_yaml_writes_full_schema(tmp_path): + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters, _save_record_zstack_yaml + from control.models.acquisition_config import AcquisitionChannel, CameraSettings, IlluminationSettings + + channel = AcquisitionChannel( + name="BF LED matrix full", + camera_settings=CameraSettings(exposure_time_ms=50.0, gain_mode=0.0), + illumination_settings=IlluminationSettings(intensity=50.0), + ) + params = RecordZStackAcquisitionParameters( + base_path=str(tmp_path), + experiment_id="my_exp", + Nt=3, + dt_s=5.0, + use_laser_af=True, + recording_enabled=True, + recording_channel=channel, + fps=15.0, + duration_s=2.0, + recording_bottom_z_offset_um=1.5, + recording_Nz=3, + recording_dz_um=0.8, + zstack_enabled=True, + zstack_channels=[channel], + z_min_um=-2.0, + z_max_um=2.0, + z_step_um=0.5, + xy_mode="Select Wells", + scan_size_mm=1.2, + overlap_percent=10.0, + ) + + scan_coordinates = type( + "FakeScanCoordinates", (), {"region_centers": {"A1": [1.0, 2.0, 0.1]}, "region_shapes": {"A1": "Square"}} + )() + + yaml_path = tmp_path / "acquisition.yaml" + _save_record_zstack_yaml(params, str(yaml_path), scan_coordinates, {"name": "20x"}) + + import yaml as pyyaml + + data = pyyaml.safe_load(yaml_path.read_text()) + + assert data["acquisition"]["widget_type"] == "record_zstack" + assert data["acquisition"]["xy_mode"] == "Select Wells" + assert data["objective"]["name"] == "20x" + assert data["time_series"] == {"nt": 3, "delta_t_s": 5.0} + assert data["autofocus"] == {"laser_af": True} + assert data["recording"]["enabled"] is True + assert data["recording"]["channel"]["name"] == "BF LED matrix full" + assert data["recording"]["fps"] == 15.0 + assert data["recording"]["duration_s"] == 2.0 + assert data["recording"]["bottom_z_offset_um"] == 1.5 + assert data["recording"]["nz"] == 3 + assert data["recording"]["dz_um"] == 0.8 + assert data["z_stack"]["enabled"] is True + assert len(data["z_stack"]["channels"]) == 1 + assert data["z_stack"]["z_min_um"] == -2.0 + assert data["z_stack"]["z_max_um"] == 2.0 + assert data["z_stack"]["z_step_um"] == 0.5 + assert data["wellplate_scan"]["scan_size_mm"] == 1.2 + assert data["wellplate_scan"]["regions"] == [{"name": "A1", "center_mm": [1.0, 2.0, 0.1], "shape": "Square"}] + + +def test_save_record_zstack_yaml_omits_wellplate_scan_for_current_position(tmp_path): + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters, _save_record_zstack_yaml + + params = RecordZStackAcquisitionParameters( + base_path=str(tmp_path), experiment_id="my_exp", xy_mode="Current Position" + ) + yaml_path = tmp_path / "acquisition.yaml" + _save_record_zstack_yaml(params, str(yaml_path)) + + import yaml as pyyaml + + data = pyyaml.safe_load(yaml_path.read_text()) + assert "wellplate_scan" not in data + + +def test_save_record_zstack_yaml_raises_on_write_failure(tmp_path): + """Final-review Finding 3: _save_record_zstack_yaml() must let a real write + failure (e.g. target directory doesn't exist) propagate rather than swallowing + it internally. The two real call sites (run_acquisition()'s snapshot and the + Save Settings button handler) each already have their own appropriate + try/except one level up; swallowing here just made the button handler's + warning dialog unreachable.""" + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters, _save_record_zstack_yaml + + params = RecordZStackAcquisitionParameters(base_path=str(tmp_path), experiment_id="my_exp") + # Parent directory doesn't exist -> open() raises FileNotFoundError (an OSError). + yaml_path = tmp_path / "does_not_exist" / "acquisition.yaml" + + with pytest.raises(OSError): + _save_record_zstack_yaml(params, str(yaml_path)) + + +def test_run_acquisition_survives_snapshot_write_failure(tmp_path, monkeypatch): + """Final-review Finding 3: run_acquisition()'s own try/except around + _save_record_zstack_yaml (a real acquisition's settings snapshot) must still + degrade gracefully -- i.e. NOT raise out of run_acquisition() -- now that the + inner swallow inside _save_record_zstack_yaml itself has been removed.""" + from unittest.mock import MagicMock + import control.core.record_zstack_controller as rzc + + class DummyWorker: + def __init__(self, **kwargs): + pass + + def run(self): + pass + + monkeypatch.setattr("control.core.record_zstack_worker.RecordZStackWorker", DummyWorker) + + def _raise_on_save(*args, **kwargs): + raise OSError("simulated disk failure") + + monkeypatch.setattr(rzc, "_save_record_zstack_yaml", _raise_on_save) + + def _write_channels_yaml(output_dir, **kw): + (Path(output_dir) / "acquisition_channels.yaml").write_text("channels: []\n") + + microscope = MagicMock() + microscope.config_repo.save_acquisition_output.side_effect = _write_channels_yaml + microscope.camera.get_binning.return_value = (1, 1) + microscope.camera.get_pixel_size_binned_um.return_value = 0.5 + objective_store = MagicMock() + objective_store.current_objective = "20x" + objective_store.objectives_dict = {} + objective_store.get_pixel_size_factor.return_value = 1.0 + callbacks = MagicMock() + + controller = rzc.RecordZStackController( + microscope=microscope, + live_controller=MagicMock(), + laser_autofocus_controller=None, + objective_store=objective_store, + scan_coordinates=None, + callbacks=callbacks, + ) + params = rzc.RecordZStackAcquisitionParameters(base_path=str(tmp_path), experiment_id="my_exp") + + # Must not raise, even though _save_record_zstack_yaml raises internally. + controller.run_acquisition(params) + controller.join(timeout=5.0) + + experiment_dir = next(tmp_path.iterdir()) + assert (experiment_dir / "acquisition_channels.yaml").exists() + # The failed snapshot must not have produced a (possibly partial) acquisition.yaml. + assert not (experiment_dir / "acquisition.yaml").exists() + + +def test_run_acquisition_writes_both_yaml_files(tmp_path, monkeypatch): + """acquisition_channels.yaml (existing) and acquisition.yaml (new) both land in the experiment dir. + + Stubs RecordZStackWorker entirely (a no-op .run()) so this test only exercises the + synchronous setup code in run_acquisition() — the YAML-writing calls happen before the + worker/thread are created. Exercising the real worker with mocked hardware would be slow + and flaky; that's covered by this module's existing worker-level tests instead. + """ + from unittest.mock import MagicMock + import control.core.record_zstack_controller as rzc + + class DummyWorker: + def __init__(self, **kwargs): + pass + + def run(self): + pass + + # run_acquisition() does `from control.core.record_zstack_worker import RecordZStackWorker` + # as a local import at call time, so patching the source module's attribute (not + # record_zstack_controller's namespace) is what actually intercepts it. + monkeypatch.setattr("control.core.record_zstack_worker.RecordZStackWorker", DummyWorker) + + def _write_channels_yaml(output_dir, **kw): + # microscope is a full MagicMock, so save_acquisition_output's real disk write + # (which the existing acquisition_channels.yaml tests exercise for real) doesn't + # happen here; write a stand-in so the "both files exist" assertion reflects the + # real run_acquisition() call order rather than the (irrelevant) file contents. + (Path(output_dir) / "acquisition_channels.yaml").write_text("channels: []\n") + + microscope = MagicMock() + microscope.config_repo.save_acquisition_output.side_effect = _write_channels_yaml + microscope.camera.get_binning.return_value = (1, 1) + microscope.camera.get_pixel_size_binned_um.return_value = 0.5 + objective_store = MagicMock() + objective_store.current_objective = "20x" + objective_store.objectives_dict = {} + objective_store.get_pixel_size_factor.return_value = 1.0 + callbacks = MagicMock() + + controller = rzc.RecordZStackController( + microscope=microscope, + live_controller=MagicMock(), + laser_autofocus_controller=None, + objective_store=objective_store, + scan_coordinates=None, + callbacks=callbacks, + ) + params = rzc.RecordZStackAcquisitionParameters(base_path=str(tmp_path), experiment_id="my_exp") + controller.run_acquisition(params) + controller.join(timeout=5.0) + + experiment_dir = next(tmp_path.iterdir()) + assert (experiment_dir / "acquisition_channels.yaml").exists() + assert (experiment_dir / "acquisition.yaml").exists() + + +def test_record_routes_sequentially_when_camera_paces_and_downsamples_when_it_cannot(tmp_path, monkeypatch): + """The router regime follows what set_frame_rate reports with the stream running: achievable <= + requested (the camera paces itself, or the request is its maximum) -> every frame is wanted and + slots are sequential; achievable > requested (no hardware pacing) -> downsample by arrival.""" + pytest.importorskip("tensorstore") + import control.core.record_zstack_worker as rzw + + captured = {} + + class _SpyRouter(rzw.RecordingRouter): + def __init__(self, fps, paced=False, **kw): + captured["fps"] = fps + super().__init__(fps, paced=paced, **kw) + + def set_paced_from_rate(self, running_fps): + super().set_paced_from_rate(running_fps) + captured["paced"] = self.paced + + monkeypatch.setattr(rzw, "RecordingRouter", _SpyRouter) + + # 1) camera honours the hint (simulated camera clamps to the request): paced. + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path / "paced", recording_enabled=True, zstack_enabled=False + ) + worker.run() + assert not aborted["v"] + assert captured["paced"] is True and captured["fps"] == 10.0 + + # 2) camera reports it free-runs faster than requested: downsample regime. + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path / "free", recording_enabled=True, zstack_enabled=False + ) + monkeypatch.setattr(type(scope.camera), "set_frame_rate", lambda self, fps: 100.0) + worker.run() + assert not aborted["v"] + assert captured["paced"] is False and captured["fps"] == 10.0 + + # 3) camera is the limit (reports a lower achievable rate): paced at that rate. + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path / "limited", recording_enabled=True, zstack_enabled=False + ) + monkeypatch.setattr(type(scope.camera), "set_frame_rate", lambda self, fps: 5.0) + worker.run() + assert not aborted["v"] + assert captured["paced"] is True and captured["fps"] == 5.0 + + +# --------------------------------------------------------------------------- +# Task 3: live-preview tap threaded through the worker. +# +# The recording phase must invoke a plain (Qt-free) display callable, throttled +# by display_fps. The z-stack phase must invoke the per-plane signal_new_image +# callback once per plane x channel x FOV x timepoint. +# --------------------------------------------------------------------------- + + +def test_recording_phase_calls_display_fn(tmp_path): + """The recording phase must invoke the plain display callable (throttled). + display_fps=1000 -> effectively every frame; assert at least one call.""" + pytest.importorskip("tensorstore") + import numpy as np + + shown = [] + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, + recording_enabled=True, + zstack_enabled=False, + display_frame_fn=shown.append, + display_fps=1000.0, + duration_s=0.3, + ) + worker.run() + assert len(shown) >= 1 + assert isinstance(shown[0], np.ndarray) + + +def test_zstack_phase_invokes_signal_new_image(tmp_path): + """Pin the per-plane signal_new_image callback (was silently wired to a + no-op before the preview feature): one call per plane x channel x FOV x t.""" + pytest.importorskip("tensorstore") + import dataclasses + from control.core.multi_point_controller import NoOpCallbacks + + new_image_calls = [] + callbacks = dataclasses.replace( + NoOpCallbacks, signal_new_image=lambda frame, info: new_image_calls.append(frame.frame.shape) + ) + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, + recording_enabled=False, + zstack_enabled=True, + zstack_channel_slice=slice(0, 2), + callbacks=callbacks, + z_min_um=-1.0, + z_max_um=1.0, # offsets -1, 0, 1 -> 3 planes + ) + worker.run() + assert len(new_image_calls) == 3 * 2 # planes x channels x 1 FOV x 1 timepoint + + +def test_controller_rejects_recording_without_a_channel(): + """V8: a recording phase with no channel would let the z-stack phase's last channel set + the exposure for later FOVs while the cached fps/pacing assume the recording channel.""" + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters, RecordZStackController + + params = RecordZStackAcquisitionParameters( + base_path="/tmp", experiment_id="x", recording_enabled=True, recording_channel=None + ) + with pytest.raises(ValueError, match="recording_channel"): + RecordZStackController.run_acquisition(object(), params) + + +def test_resolve_effective_fps_bounds_the_request_by_the_camera_maximum(): + from unittest.mock import MagicMock + + from control.core.record_zstack_controller import resolve_effective_fps + + cam = MagicMock() + cam.get_max_frame_rate.return_value = 28.0 + assert resolve_effective_fps(cam, 30.0, 2.0) == 28.0 + assert resolve_effective_fps(cam, 10.0, 2.0) == 10.0 + cam.get_max_frame_rate.assert_called_with(2.0) + # No query, or a failing one: the request stands. + assert resolve_effective_fps(object(), 30.0, 2.0) == 30.0 + cam.get_max_frame_rate.side_effect = RuntimeError("no sdk") + assert resolve_effective_fps(cam, 30.0, 2.0) == 30.0 + + +def test_controller_resolves_effective_fps_before_the_run_and_records_it(tmp_path, monkeypatch): + """The rate is fixed in run_acquisition, before the worker exists: params.effective_fps is + set, acquisition.yaml carries it, and the worker sizes every store from it.""" + pytest.importorskip("tensorstore") + import yaml + import control._def + import tests.control.test_stubs as ts + from control.core.multi_point_controller import NoOpCallbacks + from control.core.record_zstack_controller import ( + RecordZStackAcquisitionParameters, + RecordZStackController, + frame_count, + ) + from control.core.scan_coordinates import ScanCoordinates + + control._def.FILE_SAVING_OPTION = control._def.FileSavingOption.ZARR_V3 + scope = _build_simulated_microscope(64, 48) + live_controller = ts.get_test_live_controller(scope, scope.objective_store.default_objective) + laser_af = ts.get_test_laser_autofocus_controller(scope) + channels = live_controller.get_channels(scope.objective_store.default_objective) + rec_channel = channels[0].model_copy(deep=True) + rec_channel.camera_settings.exposure_time_ms = 200.0 # sim camera: max = 1000/(200+3) ~ 4.9 fps + + z_cfg = scope.stage.get_config().Z_AXIS + scope.stage.move_z_to((z_cfg.MAX_POSITION + z_cfg.MIN_POSITION) / 2.0) + x0 = scope.stage.get_config().X_AXIS.MIN_POSITION + 1.0 + y0 = scope.stage.get_config().Y_AXIS.MIN_POSITION + 1.0 + scan = ScanCoordinates(objectiveStore=scope.objective_store, stage=scope.stage, camera=scope.camera) + scan.region_fov_coordinates = {"A1": [(x0, y0)]} + + controller = RecordZStackController( + microscope=scope, + live_controller=live_controller, + laser_autofocus_controller=laser_af, + objective_store=scope.objective_store, + scan_coordinates=scan, + callbacks=NoOpCallbacks, + ) + params = RecordZStackAcquisitionParameters( + base_path=str(tmp_path), + experiment_id="resolved", + recording_enabled=True, + recording_channel=rec_channel, + fps=30.0, + duration_s=1.0, + ) + expected = scope.camera.get_max_frame_rate(200.0) + assert expected < 30.0 # precondition: the sim camera cannot do 30 fps at 200 ms + controller.run_acquisition(params) + assert params.effective_fps == pytest.approx(expected) # resolved before the worker ran + controller.join(timeout=120) + assert not controller.acquisition_in_progress() + controller.close() + + exp_dir = next(d for d in tmp_path.iterdir() if d.is_dir()) + saved = yaml.safe_load(open(exp_dir / "acquisition.yaml")) + assert saved["recording"]["fps"] == 30.0 + assert saved["recording"]["effective_fps"] == pytest.approx(expected) + import json + + zarr_json = next(exp_dir.glob("recording/**/zarr.json")) + sq = json.load(open(zarr_json))["attributes"]["_squid"] + assert sq["shape"][0] == max(1, frame_count(expected, 1.0)) + assert sq["requested_fps"] == 30.0 and sq["effective_fps"] == pytest.approx(expected) + # The store's spacing is the resolved one unless the simulated camera delivered more than 2% + # off it, in which case the measured spacing is stamped and the resolved one kept alongside. + assert sq.get("nominal_time_increment_s", sq["time_increment_s"]) == pytest.approx(1.0 / expected) + + +def _zstack_squid_attrs(root): + """The _squid attributes of the one OME group zarr.json of the z-stack store under root.""" + import tests.control.test_stubs as ts + + (attrs,) = ts.zarr_squid_attrs(root) + return attrs + + +def test_zstack_uses_the_in_process_runner_and_seals_the_store(tmp_path): + """The z-stack's one runner is the in-process one; its store ends acquisition_complete=True.""" + pytest.importorskip("tensorstore") + from control.core.in_process_zarr_runner import InProcessZarrRunner + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=False, zstack_enabled=True + ) + assert isinstance(worker._job_runners[0][1], InProcessZarrRunner) + worker.run() + assert _zstack_squid_attrs(Path(tmp_path) / "state_restore" / "zarr")["acquisition_complete"] is True + + +def test_abort_mid_zstack_seals_store_aborted(tmp_path): + """Stop during the z-stack: the store says aborted, not complete.""" + pytest.importorskip("tensorstore") + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=False, zstack_enabled=True, zstack_channel_slice=slice(0, 2) + ) + original = worker.acquire_camera_image + calls = [] + + def abort_after_first(*args, **kwargs): + original(*args, **kwargs) + calls.append(1) + if len(calls) == 1: + aborted["v"] = True + + worker.acquire_camera_image = abort_after_first + worker.run() + attrs = _zstack_squid_attrs(Path(tmp_path) / "state_restore" / "zarr") + assert attrs["acquisition_complete"] is False and attrs.get("aborted") is True + + +def test_zarr_write_error_aborts_the_acquisition(tmp_path, monkeypatch, caplog): + """A failed z-stack write must end the run as an error and seal the store incomplete, not finish quietly.""" + pytest.importorskip("tensorstore") + import logging + import tests.control.test_stubs as ts + + ts.make_zarr_writes_fail(monkeypatch) + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=False, zstack_enabled=True, zstack_channel_slice=slice(0, 2) + ) + with caplog.at_level(logging.ERROR): + worker.run() + assert aborted["v"] is True and worker._abort_cause == "error" + assert any("disk full" in r.getMessage() for r in caplog.records) + assert _zstack_squid_attrs(Path(tmp_path) / "state_restore" / "zarr")["acquisition_complete"] is False + + +def test_three_record_zstack_acquisitions_all_sealed(tmp_path, monkeypatch): + """The data loss of PR 564: acquisition 3 used to lose its z-stack. No subprocess is started at all now.""" + pytest.importorskip("tensorstore") + from control.core import job_processing + + started = [] + monkeypatch.setattr(job_processing.JobRunner, "start", lambda self: started.append(self)) + scope, live_controller, channels, controller = _build_controller_harness() + try: + for i in range(1, 4): + base = tmp_path / f"acq{i}" + base.mkdir() + controller.run_acquisition( + _params( + base, channels, experiment_id="b2b", duration_s=0.3, zstack_channels=channels[:2], z_min_um=-1.0 + ) + ) + controller.join(timeout=120) + assert not controller.acquisition_in_progress(), f"acquisition {i} did not finish" + (experiment_dir,) = [d for d in base.iterdir() if d.is_dir()] + assert _zstack_squid_attrs(experiment_dir / "zarr")["acquisition_complete"] is True, f"acquisition {i}" + finally: + controller.close() + assert started == [] + + +def test_recording_switches_to_paced_routing_when_the_camera_only_paces_once_streaming(tmp_path, monkeypatch, caplog): + """The pacing decision is made before streaming. A camera that rejects the stopped-state rate write + (ToupTek after a binning change) reports its free-run maximum there and only paces itself once the + stream runs; the recording must then route sequentially, or at-rate jitter seals it incomplete.""" + pytest.importorskip("tensorstore") + import logging + import tests.control.test_stubs as ts + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + real_set_frame_rate = scope.camera.set_frame_rate + + def late_pacing_set_frame_rate(fps): + real_set_frame_rate(fps) + return fps if scope.camera.get_is_streaming() else 1000.0 + + monkeypatch.setattr(scope.camera, "set_frame_rate", late_pacing_set_frame_rate) + with caplog.at_level(logging.INFO): + worker.run() + assert not aborted["v"] + assert any("routed camera-paced, sequential slots" in r.getMessage() for r in caplog.records) + (attrs,) = ts.zarr_squid_attrs(worker.experiment_path) + assert attrs["acquisition_complete"] is True + + +def _shorten_recording_finalize(monkeypatch, timeout_s): + """Give every per-plane finalize() a short join budget, as a slow disk would exhaust the real one.""" + from control.core.streaming_capture import RecordingWriter + + real_finalize = RecordingWriter.finalize + monkeypatch.setattr(RecordingWriter, "finalize", lambda self, timeout_s=timeout_s: real_finalize(self, timeout_s)) + + +def test_late_recording_write_error_ends_the_run_as_error(tmp_path, monkeypatch, caplog): + """A recording whose drain outlives finalize() still fails the run when its write errors surface + afterwards, and .done is written only once the store is sealed.""" + pytest.importorskip("tensorstore") + import logging + import threading + import tests.control.test_stubs as ts + from control.core.record_zstack_worker import RecordZStackWorker + from control.core.zarr_writer import ZarrWriter + + _shorten_recording_finalize(monkeypatch, 0.2) + monkeypatch.setattr(RecordZStackWorker, "DRAIN_SETTLE_TIMEOUT_S", 5.0) + release = threading.Event() # the writes complete, failing, 0.6 s after the first one is submitted: + timer = [] # after finalize() has given up its 0.2 s wait, before the end-of-run settle gives up + + def late_failing_submit(self, image, t, c, z, fov=None): + if not timer: + timer.append(threading.Timer(0.6, release.set)) + timer[0].start() + return ts.GatedFuture(release, RuntimeError("disk error after finalize")) + + monkeypatch.setattr(ZarrWriter, "submit_frame", late_failing_submit) + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + with caplog.at_level(logging.ERROR): + worker.run() + assert worker._abort_cause == "error" + assert any("after finalize() returned" in r.getMessage() for r in caplog.records) + assert os.path.exists(os.path.join(worker.experiment_path, ".done")) # the directory is final: store sealed + (attrs,) = ts.zarr_squid_attrs(worker.experiment_path) + assert attrs["acquisition_complete"] is False + + +def test_run_withholds_done_while_a_recording_is_still_being_written(tmp_path, monkeypatch, caplog): + """A store still being written when the run ends is not final: no .done, and the run ends as an error.""" + pytest.importorskip("tensorstore") + import logging + import threading + import tests.control.test_stubs as ts + from control.core.record_zstack_worker import RecordZStackWorker + + release = threading.Event() + _shorten_recording_finalize(monkeypatch, 0.2) + monkeypatch.setattr(RecordZStackWorker, "DRAIN_SETTLE_TIMEOUT_S", 0.3) + ts.stub_zarr_writer_io(monkeypatch, lambda self, image, t, c, z, fov=None: ts.GatedFuture(release)) + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False + ) + try: + with caplog.at_level(logging.ERROR): + worker.run() + finally: + release.set() + assert worker._abort_cause == "error" + assert any("still being written" in r.getMessage() for r in caplog.records) + assert not os.path.exists(os.path.join(worker.experiment_path, ".done")) + + +def test_zstack_stops_streaming_when_callback_registration_fails(tmp_path, monkeypatch): + """start_streaming() succeeded but add_frame_callback() raised: the camera must not be left streaming.""" + pytest.importorskip("tensorstore") + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=False, zstack_enabled=True + ) + + def refuse(cb): + raise RuntimeError("no callback slot") + + monkeypatch.setattr(scope.camera, "add_frame_callback", refuse) + with pytest.raises(RuntimeError, match="no callback slot"): + worker.zstack(0, "A1", 0, scope.stage.get_pos().z_mm) + assert not scope.camera.get_is_streaming() + + +def test_record_restores_z_and_camera_mode_when_illumination_fails(tmp_path, monkeypatch): + """Illumination raising after the first-plane Z move must still restore Z and software-trigger mode.""" + pytest.importorskip("tensorstore") + from squid.abc import CameraAcquisitionMode + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=False, recording_bottom_z_offset_um=5.0 + ) + real_move_z_to = scope.stage.move_z_to + moves = [] + + def move_z_to_spy(z_mm): + moves.append(z_mm) + return real_move_z_to(z_mm) + + scope.stage.move_z_to = move_z_to_spy + + def fail(): + raise RuntimeError("illumination") + + monkeypatch.setattr(live_controller, "turn_on_illumination", fail) + z_ref = scope.stage.get_pos().z_mm + with pytest.raises(RuntimeError, match="illumination"): + worker.record(0, "A1", 0, z_ref) + assert moves and moves[-1] == pytest.approx(z_ref), "stage-Z restore must run when illumination fails" + assert scope.camera.get_acquisition_mode() == CameraAcquisitionMode.SOFTWARE_TRIGGER + + +def test_record_zstack_run_writes_the_watchdog_breadcrumb(tmp_path): + """Record + Z-Stack leaves the run.json the multipoint acquisition does, started before the worker and + ended with the run's reason, so the acquisition watchdog covers it.""" + pytest.importorskip("tensorstore") + import squid.acquisition_state as ast + + scope, live_controller, channels, controller = _build_controller_harness() + base = tmp_path / "acq" + base.mkdir() + try: + controller.run_acquisition( + _params(base, channels, experiment_id="wd", zstack_channels=channels[:2], z_min_um=-1.0) + ) + controller.join(timeout=120) + finally: + controller.close() + (experiment_dir,) = [d for d in base.iterdir() if d.is_dir()] + rec = ast.read_run(os.environ["SQUID_WATCHDOG_STATE_DIR"]) # the autouse fixture's per-test state dir + assert rec is not None, "no run.json written for a Record + Z-Stack run" + assert rec["status"] == "ended" and rec["reason"] == "completed" + assert Path(rec["output_path"]) == experiment_dir + assert rec["expected"] == {"timepoints": 1, "regions": 1, "fovs": 1, "channels": 2, "z": 3} + + +def test_hardware_trigger_run_switches_to_software_while_streaming_before_recording(tmp_path, monkeypatch): + """A ToupTek camera moves its trigger source off the hardware input only when the trigger mode changes + on a running stream; switched on a stopped stream it keeps listening to the hardware input and every + z-stack trigger is ignored (on hardware: no z-stack, 'Timed out waiting ... for a frame'). A run started + in hardware trigger must therefore make its one switch to software trigger with the camera streaming, + before the recording phase stops the stream, and still restore hardware trigger at the end.""" + pytest.importorskip("tensorstore") + from control._def import TriggerMode + + scope, live_controller, channels, worker, aborted = _build_worker_harness( + tmp_path, recording_enabled=True, zstack_enabled=True + ) + live_controller.set_microscope_mode(channels[0]) + live_controller.set_trigger_mode(TriggerMode.HARDWARE) + scope.camera.stop_streaming() # e.g. a previous run ended with the camera stopped + + events = [] + orig_set_trigger_mode = live_controller.set_trigger_mode + + def spy_set_trigger_mode(mode): + events.append(("set_trigger_mode", mode, scope.camera.get_is_streaming())) + return orig_set_trigger_mode(mode) + + orig_record = worker.record + + def spy_record(*args, **kwargs): + events.append(("record",)) + return orig_record(*args, **kwargs) + + monkeypatch.setattr(live_controller, "set_trigger_mode", spy_set_trigger_mode) + monkeypatch.setattr(worker, "record", spy_record) + + worker.run() + + assert not aborted["v"] + to_software = [e for e in events if e[0] == "set_trigger_mode" and e[1] == TriggerMode.SOFTWARE] + assert to_software, "the run never switched to software trigger" + assert to_software[0][2], "switched to software trigger with the camera stream stopped" + assert events.index(to_software[0]) < events.index(("record",)), "switched after the recording phase" + assert live_controller.trigger_mode == TriggerMode.HARDWARE, "user's hardware trigger mode not restored" diff --git a/software/tests/core/test_streaming_capture.py b/software/tests/core/test_streaming_capture.py new file mode 100644 index 000000000..66e38b785 --- /dev/null +++ b/software/tests/core/test_streaming_capture.py @@ -0,0 +1,1331 @@ +import time + +import numpy as np +import pytest + +from control.core.streaming_capture import CountStop, RecordingRouter, StreamingCapture + + +def test_count_stop(): + s = CountStop(3) + assert not s.met(2) + assert s.met(3) + assert s.met(4) + + +def test_recording_router_downsamples_and_indexes(): + r = RecordingRouter(fps=10.0) # min spacing 0.1 s + assert r.route(100.00) == (0, 0, 0) # first frame always emits + assert r.route(100.05) is None # 50 ms later -> skip + assert r.route(100.10) == (1, 0, 0) # 100 ms later -> emit, t=1 + # 300 ms after the anchor -> slot 3 (slot 2 stays a fill hole: frames map + # to the slot matching their arrival time; gaps are not compressed). + assert r.route(100.30) == (3, 0, 0) + + +def test_recording_writer_roundtrip(tmp_path): + import pytest + + pytest.importorskip("tensorstore") # optional dep; real ZarrWriter needs it + T, Y, X = 4, 16, 12 + from control.core.zarr_writer import ZarrAcquisitionConfig + from control.core.streaming_capture import RecordingWriter + + cfg = ZarrAcquisitionConfig( + output_path=str(tmp_path / "rec.ome.zarr"), + shape=(T, 1, 1, Y, X), + dtype=np.uint16, + pixel_size_um=1.0, + z_step_um=None, + time_increment_s=0.1, + channel_names=["BF"], + channel_colors=["#FFFFFF"], + channel_wavelengths=[None], + is_hcs=False, + ) + w = RecordingWriter(cfg) + w.start() + for t in range(T): + w.enqueue(np.full((Y, X), t + 1, dtype=np.uint16), t, 0, 0) + w.finalize() + import tensorstore as ts + + ds = ts.open({"driver": "zarr3", "kvstore": {"driver": "file", "path": cfg.output_path}}).result() + assert tuple(ds.shape) == (T, 1, 1, Y, X) + assert int(ds[2, 0, 0, 0, 0].read().result()) == 3 + + +# --------------------------------------------------------------------------- +# Task C3: StreamingCapture + ContinuousFrameSource tests (fake source) +# --------------------------------------------------------------------------- + + +class _FakeFrame: + def __init__(self, ts, arr): + self.timestamp = ts + self.frame = arr + + +class _FakeSource: + """Delivers N frames synchronously when started.""" + + def __init__(self, frames): + self._frames = frames + self._cb = None + + def start(self, on_frame): + self._cb = on_frame + for f in self._frames: + self._cb(f) + + def stop(self): + pass + + +class _ListWriter: + dropped_count = 0 + + def mark_incomplete(self, captured, expected): + pass + + def set_measured_time_increment(self, seconds): + pass + + def __init__(self): + self.writes = [] + + def start(self): + pass + + def enqueue(self, frame, t, c, z): + self.writes.append((t, c, z)) + + def finalize(self): + self.finalized = True + + def abort(self): + pass + + +def test_streaming_capture_counts_and_downsamples(): + frames = [_FakeFrame(100.0 + i * 0.05, np.zeros((4, 4), np.uint16)) for i in range(20)] + w = _ListWriter() + cap = StreamingCapture( + _FakeSource(frames), + RecordingRouter(fps=10.0), + CountStop(5), + w, + abort_fn=lambda: False, + ) + emitted = cap.run() + assert emitted == 5 + assert w.writes == [(0, 0, 0), (1, 0, 0), (2, 0, 0), (3, 0, 0), (4, 0, 0)] + assert getattr(w, "finalized", False) is True + + +# --------------------------------------------------------------------------- +# Fix-batch tests: start() error path, OOB gating, abort path, partial warning +# --------------------------------------------------------------------------- + + +def test_recording_writer_start_failure_propagates_without_join_crash(tmp_path, monkeypatch): + """If ZarrWriter.initialize() raises, start() propagates the original error and + finalize()/abort() must NOT crash trying to join an unstarted thread.""" + from control.core.zarr_writer import ZarrAcquisitionConfig, ZarrWriter + from control.core.streaming_capture import RecordingWriter + + cfg = ZarrAcquisitionConfig( + output_path=str(tmp_path / "rec.ome.zarr"), + shape=(2, 1, 1, 4, 4), + dtype=np.uint16, + pixel_size_um=1.0, + z_step_um=None, + time_increment_s=0.1, + channel_names=["BF"], + channel_colors=["#FFFFFF"], + channel_wavelengths=[None], + is_hcs=False, + ) + + sentinel_error = RuntimeError("boom from initialize") + + def boom(self): + raise sentinel_error + + monkeypatch.setattr(ZarrWriter, "initialize", boom) + + w = RecordingWriter(cfg) + import pytest + + with pytest.raises(RuntimeError, match="boom from initialize"): + w.start() + + assert w._started is False + # finalize() and abort() must be safe no-ops (no "cannot join thread" crash). + w.finalize() + w.abort() + + +def test_recording_writer_aborts_writer_when_thread_start_fails(tmp_path, monkeypatch): + """If initialize() succeeds but the drain thread fails to start, start() must + abort the already-opened ZarrWriter (no leak) and propagate the error.""" + import pytest + from control.core.zarr_writer import ZarrAcquisitionConfig, ZarrWriter + from control.core.streaming_capture import RecordingWriter + + cfg = ZarrAcquisitionConfig( + output_path=str(tmp_path / "rec.ome.zarr"), + shape=(2, 1, 1, 4, 4), + dtype=np.uint16, + pixel_size_um=1.0, + z_step_um=None, + time_increment_s=0.1, + channel_names=["BF"], + channel_colors=["#FFFFFF"], + channel_wavelengths=[None], + is_hcs=False, + ) + + # initialize() succeeds (opens the writer); the drain thread then fails to start. + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + aborted = {"called": False} + monkeypatch.setattr(ZarrWriter, "abort", lambda self: aborted.__setitem__("called", True)) + + w = RecordingWriter(cfg) + + class _BoomThread: + def start(self): + raise RuntimeError("boom from thread start") + + w._thread = _BoomThread() + + with pytest.raises(RuntimeError, match="boom from thread start"): + w.start() + + # The writer that initialize() opened must be released, not leaked. + assert aborted["called"] is True + assert w._started is False + # subsequent finalize()/abort() stay safe no-ops. + w.finalize() + w.abort() + + +class _CountingFakeSource: + """Delivers all frames synchronously, even past the stop count, to exercise the + out-of-bounds guard in _on_frame.""" + + def __init__(self, frames): + self._frames = frames + + def start(self, on_frame): + for f in self._frames: + on_frame(f) + + def stop(self): + pass + + +def test_streaming_capture_no_enqueue_past_T_with_extra_frames(): + """Frames arriving after the stop count is met must never be enqueued (would be + out of bounds for a (T, ...)-shaped dataset).""" + # No downsampling (fps=0 -> emit every frame); 10 frames but T=3. + frames = [_FakeFrame(100.0 + i, np.zeros((4, 4), np.uint16)) for i in range(10)] + w = _ListWriter() + cap = StreamingCapture( + _CountingFakeSource(frames), + RecordingRouter(fps=0.0), + CountStop(3), + w, + abort_fn=lambda: False, + ) + emitted = cap.run() + assert emitted == 3 + # Only t-indices 0..2 — nothing at or beyond T=3. + assert w.writes == [(0, 0, 0), (1, 0, 0), (2, 0, 0)] + assert all(t < 3 for (t, _, _) in w.writes) + + +class _RecordingStubWriter: + """Records which of finalize()/abort() was called.""" + + dropped_count = 0 + + def mark_incomplete(self, captured, expected): + pass + + def set_measured_time_increment(self, seconds): + pass + + def __init__(self): + self.writes = [] + self.finalized = False + self.aborted = False + + def start(self): + pass + + def enqueue(self, frame, t, c, z): + self.writes.append((t, c, z)) + + def finalize(self): + self.finalized = True + + def abort(self): + self.aborted = True + + +def test_streaming_capture_abort_calls_writer_abort_not_finalize(): + frames = [_FakeFrame(100.0 + i, np.zeros((4, 4), np.uint16)) for i in range(10)] + w = _RecordingStubWriter() + cap = StreamingCapture( + _CountingFakeSource(frames), + RecordingRouter(fps=0.0), + CountStop(100), # never reached + w, + abort_fn=lambda: True, # abort on first frame + ) + emitted = cap.run() + assert emitted == 0 + assert w.aborted is True + assert w.finalized is False + + +def test_streaming_capture_complete_calls_finalize_not_abort(): + frames = [_FakeFrame(100.0 + i, np.zeros((4, 4), np.uint16)) for i in range(5)] + w = _RecordingStubWriter() + cap = StreamingCapture( + _CountingFakeSource(frames), + RecordingRouter(fps=0.0), + CountStop(3), + w, + abort_fn=lambda: False, + ) + emitted = cap.run() + assert emitted == 3 + assert w.finalized is True + assert w.aborted is False + + +def test_streaming_capture_partial_warns(caplog): + """Fewer frames than T -> finalize + a loud WARNING about partial capture.""" + import logging + + frames = [_FakeFrame(100.0 + i, np.zeros((4, 4), np.uint16)) for i in range(2)] + w = _RecordingStubWriter() + cap = StreamingCapture( + _CountingFakeSource(frames), + RecordingRouter(fps=0.0), + CountStop(5), # expect 5, only 2 delivered + w, + abort_fn=lambda: False, + ) + with caplog.at_level(logging.WARNING): + emitted = cap.run(timeout=0.1) + assert emitted == 2 + assert w.finalized is True + assert w.aborted is False + assert any("incomplete" in rec.getMessage() and "2/5" in rec.getMessage() for rec in caplog.records) + + +# --------------------------------------------------------------------------- +# Fix-batch5: dropped_count accessor + summary log +# --------------------------------------------------------------------------- + + +def test_recording_writer_dropped_count_accessor(tmp_path): + """dropped_count property returns the number of frames dropped due to a full queue.""" + import pytest + + pytest.importorskip("tensorstore") # optional dep; real ZarrWriter needs it + from control.core.zarr_writer import ZarrAcquisitionConfig + from control.core.streaming_capture import RecordingWriter + + cfg = ZarrAcquisitionConfig( + output_path=str(tmp_path / "rec.ome.zarr"), + shape=(4, 1, 1, 4, 4), + dtype=np.uint16, + pixel_size_um=1.0, + z_step_um=None, + time_increment_s=0.1, + channel_names=["BF"], + channel_colors=["#FFFFFF"], + channel_wavelengths=[None], + is_hcs=False, + ) + # Use a queue of size 1 so extra enqueues are dropped immediately. + w = RecordingWriter(cfg, max_queue=1) + assert w.dropped_count == 0 + w.start() + # Fill the queue slot with first frame, then overflow with two more. + frame = np.zeros((4, 4), dtype=np.uint16) + w.enqueue(frame, 0, 0, 0) + w.enqueue(frame, 1, 0, 0) # may or may not drop depending on drain speed + w.enqueue(frame, 2, 0, 0) # likely dropped + w.finalize() + # At least one drop should have occurred; exact count is timing-dependent. + # Just verify the property exists and returns an int. + assert isinstance(w.dropped_count, int) + + +def test_streaming_capture_logs_dropped_summary(caplog): + """When frames are dropped, StreamingCapture logs a summary WARNING at end.""" + import logging + + class _DroppingWriter: + """Writer that pretends to drop every frame (dropped_count always > 0).""" + + dropped_count = 3 + + def mark_incomplete(self, captured, expected): + pass + + def set_measured_time_increment(self, seconds): + pass + + def start(self): + pass + + def enqueue(self, frame, t, c, z): + pass + + def finalize(self): + pass + + def abort(self): + pass + + frames = [_FakeFrame(100.0 + i, np.zeros((4, 4), np.uint16)) for i in range(3)] + w = _DroppingWriter() + cap = StreamingCapture( + _CountingFakeSource(frames), + RecordingRouter(fps=0.0), + CountStop(3), + w, + abort_fn=lambda: False, + ) + with caplog.at_level(logging.WARNING): + cap.run() + + assert any("dropped" in rec.getMessage() and "3" in rec.getMessage() for rec in caplog.records) + + +# --------------------------------------------------------------------------- +# Review-fix tests: fail-loud drain (F2), bounded finalize (F3), abort wake (F4) +# --------------------------------------------------------------------------- + + +class _DoneFuture: + """Stand-in for a completed TensorStore write future.""" + + def done(self): + return True + + def result(self): + return None + + +def _patch_submit(monkeypatch, fn): + """Fake ZarrWriter.submit_frame so the drain thread runs ``fn`` synchronously at submission + (blocking or raising there, exactly like the old synchronous write_frame did) and receives + an already-completed future. Keeps the wedge / error / queue-full scenarios below meaningful + now that the drain pipelines submissions.""" + from control.core.zarr_writer import ZarrWriter + + def submit(self, image, t, c, z, fov=None): + fn(self, image, t, c, z, fov) + return _DoneFuture() + + monkeypatch.setattr(ZarrWriter, "submit_frame", submit) + + +def _stub_zarr_cfg(tmp_path): + from control.core.zarr_writer import ZarrAcquisitionConfig + + return ZarrAcquisitionConfig( + output_path=str(tmp_path / "rec.ome.zarr"), + shape=(4, 1, 1, 4, 4), + dtype=np.uint16, + pixel_size_um=1.0, + z_step_um=None, + time_increment_s=0.1, + channel_names=["BF"], + channel_colors=["#FFFFFF"], + channel_wavelengths=[None], + is_hcs=False, + ) + + +def test_recording_writer_write_errors_seal_incomplete(tmp_path, monkeypatch): + """If write_frame fails, the store must NOT be sealed acquisition_complete=True: + the drain thread must count the failures and seal via abort() instead.""" + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + calls = [] + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + + def boom(self, image, t, c, z, fov=None): + raise RuntimeError("disk full") + + _patch_submit(monkeypatch, boom) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: calls.append("finalize")) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: calls.append("abort")) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path)) + rw.start() + rw.enqueue(np.zeros((4, 4), np.uint16), 0, 0, 0) + rw.enqueue(np.zeros((4, 4), np.uint16), 1, 0, 0) + rw.finalize() + + assert rw.write_error_count == 2 + assert calls == ["abort"], f"expected incomplete seal via abort(), got {calls}" + + +def test_recording_writer_finalize_bounded_when_drain_wedged(tmp_path, monkeypatch): + """finalize() must not block forever pushing the sentinel onto a full queue + while the drain thread is wedged inside a stalled write.""" + import threading + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + release = threading.Event() + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + _patch_submit(monkeypatch, lambda self, image, t, c, z, fov=None: release.wait(20)) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: None) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: None) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_queue=2) + rw.start() + # First frame wedges the drain thread inside write_frame; two more fill the queue. + rw.enqueue(np.zeros((4, 4), np.uint16), 0, 0, 0) + import time as _t + + _t.sleep(0.2) # let the drain take f0 so f1+f2 truly fill the queue + rw.enqueue(np.zeros((4, 4), np.uint16), 1, 0, 0) + rw.enqueue(np.zeros((4, 4), np.uint16), 2, 0, 0) + + t = threading.Thread(target=lambda: rw.finalize(timeout_s=1.0), daemon=True) + t.start() + t.join(timeout=5.0) + still_stuck = t.is_alive() + release.set() # let the drain thread exit before asserting + rw._thread.join(timeout=5.0) # don't leak the drain into the next test + assert not still_stuck, "finalize() deadlocked on the full bounded queue" + + +def test_streaming_capture_abort_wakes_without_frames(): + """Stop/abort must work even when the camera delivers no frames at all: + run() must poll abort_fn rather than sampling it only in the frame callback.""" + import time as _time + + class _NoFrameSource: + def start(self, on_frame): + pass + + def stop(self): + pass + + w = _RecordingStubWriter() + cap = StreamingCapture( + _NoFrameSource(), + RecordingRouter(fps=10.0), + CountStop(5), + w, + abort_fn=lambda: True, # user pressed Stop + ) + t0 = _time.monotonic() + emitted = cap.run(timeout=10.0) + took = _time.monotonic() - t0 + + assert emitted == 0 + assert took < 2.0, f"abort took {took:.1f}s — run() ignored abort while no frames arrived" + assert w.aborted is True, "aborted capture must be sealed as aborted" + assert w.finalized is False, "aborted capture must not be sealed as complete" + + +def test_recording_router_tolerates_delivery_jitter(): + """F5: with the camera running AT the target rate, ms-level host delivery + jitter must not reject frames — anchoring to the previous emission made + every slightly-early frame fail the gate and halved the effective rate.""" + r = RecordingRouter(fps=10.0) + # 10 fps arrivals with a 1 ms early wobble on every other frame. + stamps = [100.0 + i * 0.1 - (0.001 if i % 2 else 0.0) for i in range(10)] + accepted = [s for s in stamps if r.route(s) is not None] + assert len(accepted) == 10, f"only {len(accepted)}/10 at-rate frames accepted (jitter rejected frames)" + + +def test_recording_router_still_downsamples_faster_camera(): + """The jitter fix must not break downsampling: a camera at 2x the target + rate should still have roughly half its frames rejected.""" + r = RecordingRouter(fps=10.0) + stamps = [100.0 + i * 0.05 for i in range(20)] # 20 fps camera, 10 fps target + accepted = [s for s in stamps if r.route(s) is not None] + assert len(accepted) == 10, f"expected 10/20 accepted, got {len(accepted)}" + + +def test_recording_writer_byte_bound_drops(tmp_path, monkeypatch): + """F7: the queue must bound MEMORY, not just frame count — 256 full-res + 16-bit frames is ~13 GB. Frames beyond max_bytes drop like a full queue.""" + import threading + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + release = threading.Event() + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + _patch_submit(monkeypatch, lambda self, image, t, c, z, fov=None: release.wait(20)) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: None) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: None) + + frame = np.zeros((100, 100), np.uint16) # 20 kB each + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_queue=256, max_bytes=50_000) + rw.start() + for i in range(6): # 120 kB total >> 50 kB cap; first frame wedges in write + rw.enqueue(frame, i, 0, 0) + dropped = rw.dropped_count + release.set() + rw.finalize(timeout_s=2.0) + rw._thread.join(timeout=5.0) # don't leak the drain into the next test + assert dropped >= 3, f"byte cap not enforced: only {dropped} frames dropped" + + +def test_dropped_frames_seal_store_incomplete(tmp_path, monkeypatch): + """Round-2: frames dropped by backpressure leave fill-value holes, so the + store must NOT be sealed acquisition_complete=True (CountStop still counts + routed frames, so the completeness attribute was lying).""" + import threading + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + release = threading.Event() + calls = [] + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + _patch_submit(monkeypatch, lambda self, image, t, c, z, fov=None: release.wait(10)) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: calls.append("finalize")) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: calls.append("abort")) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_queue=1) + rw.start() + for i in range(4): # first wedges in write, second queues, rest drop + rw.enqueue(np.zeros((4, 4), np.uint16), i, 0, 0) + assert rw.dropped_count > 0 + release.set() + rw.finalize(timeout_s=5.0) + + assert calls == ["abort"], f"store with dropped frames sealed via {calls}, expected incomplete seal" + assert rw.finalize_wedged is False + + +def test_finalize_wedged_flag_feeds_fail_fast(tmp_path, monkeypatch): + """Round-2: when finalize() takes the wedged-drain fallback it returns with + the drain thread still running — the caller's write_error_count check reads + 0 and the fail-fast never fires. The writer must expose the wedged state.""" + import threading + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + release = threading.Event() + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + _patch_submit(monkeypatch, lambda self, image, t, c, z, fov=None: release.wait(20)) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: None) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: None) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_queue=2) + rw.start() + rw.enqueue(np.zeros((4, 4), np.uint16), 0, 0, 0) + import time as _t + + _t.sleep(0.2) # drain takes f0 (wedges); f1+f2 then fill the queue + rw.enqueue(np.zeros((4, 4), np.uint16), 1, 0, 0) + rw.enqueue(np.zeros((4, 4), np.uint16), 2, 0, 0) + rw.finalize(timeout_s=1.0) # sentinel can't be queued -> wedged fallback + still_wedged = rw.finalize_wedged + release.set() + rw._thread.join(timeout=5.0) # don't leak the drain into the next test + assert still_wedged is True + + +def test_finalize_total_time_respects_timeout_budget(tmp_path, monkeypatch): + """Round-2: the sentinel put and the join must share ONE timeout budget — + a late-accepted sentinel followed by a full-length join blocked callers + for up to ~2x timeout_s.""" + import time as _time + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + _patch_submit(monkeypatch, lambda self, image, t, c, z, fov=None: _time.sleep(0.95)) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: None) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: None) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_queue=2) + rw.start() + # f0 wedges the drain mid-write; f1+f2 then fill the queue, so the sentinel + # is accepted LATE (~0.75s into the 1.0s put deadline) — the join must use + # only the remaining budget, not a fresh full timeout. + rw.enqueue(np.zeros((4, 4), np.uint16), 0, 0, 0) + _time.sleep(0.2) # let the drain thread take f0 and start the slow write + rw.enqueue(np.zeros((4, 4), np.uint16), 1, 0, 0) + rw.enqueue(np.zeros((4, 4), np.uint16), 2, 0, 0) + t0 = _time.monotonic() + rw.finalize(timeout_s=1.0) + took = _time.monotonic() - t0 + rw._thread.join(timeout=10.0) # drain finishes backlog; don't leak into the next test + assert took < 1.5, f"finalize(timeout_s=1.0) blocked {took:.1f}s — put+join must share one budget" + + +# --------------------------------------------------------------------------- +# Round-2-complete fixes (R1, R3, R4, R5, R9) +# --------------------------------------------------------------------------- + + +def test_under_delivery_marks_store_incomplete(tmp_path, monkeypatch): + """R1: a capture that times out with emitted < expected must not let the + drain seal acquisition_complete=True — StreamingCapture must tell the + writer the capture is incomplete.""" + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + calls = [] + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + _patch_submit(monkeypatch, lambda self, image, t, c, z, fov=None: None) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: calls.append(("finalize",))) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: calls.append(("abort", k))) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path)) + rw.start() + rw.enqueue(np.zeros((4, 4), np.uint16), 0, 0, 0) + rw.mark_incomplete(captured=1, expected=4) # camera stalled at 1/4 frames + rw.finalize(timeout_s=5.0) + + assert len(calls) == 1 and calls[0][0] == "abort", f"expected incomplete seal, got {calls}" + extra = calls[0][1].get("extra_attrs") or {} + assert extra.get("captured_frames") == 1 and extra.get("expected_frames") == 4 + assert calls[0][1].get("mark_aborted") is False, "under-delivery is not a user abort" + + +def test_streaming_capture_calls_mark_incomplete_on_timeout(): + """R1 (capture side): run() must call writer.mark_incomplete when the stop + condition was not met (frames never arrived — no drops, no write errors).""" + frames = [_FakeFrame(100.0 + i, np.zeros((4, 4), np.uint16)) for i in range(3)] + + class _StubWriterWithIncomplete(_RecordingStubWriter): + def __init__(self): + super().__init__() + self.incomplete = None + + def mark_incomplete(self, captured, expected): + self.incomplete = (captured, expected) + + w = _StubWriterWithIncomplete() + cap = StreamingCapture( + _FakeSource(frames), + RecordingRouter(fps=0.0), + CountStop(10), # only 3 will arrive + w, + abort_fn=lambda: False, + ) + emitted = cap.run(timeout=0.5) + assert emitted == 3 + assert w.incomplete == (3, 10), "run() must flag under-delivery to the writer" + assert w.finalized is True and w.aborted is False + + +def test_router_leaves_holes_after_stall(): + """R3: a burst after a delivery stall must NOT back-fill consecutive slots + (compressing the time axis) — frames map to the slot matching their actual + arrival time, leaving fill holes for the stall.""" + r = RecordingRouter(fps=10.0) + stamps = [100.0, 100.1, 102.0, 102.001, 102.002, 102.1] + slots = [r.route(s) for s in stamps] + accepted = [s[0] for s in slots if s is not None] + assert accepted == [0, 1, 20, 21], f"expected stall gap preserved as holes (slots [0, 1, 20, 21]), got {accepted}" + + +def test_wedged_finalize_flushes_backlog_instead_of_discarding(tmp_path, monkeypatch): + """R4: when finalize() gives up on a wedged drain, the already-captured + queued frames must still be written once the stall clears — the old abort + path discarded them.""" + import threading + import time as _time + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + release = threading.Event() + writing = threading.Event() + written = [] + + def slow_write(self, image, t, c, z, fov=None): + writing.set() # drain dequeued a frame and entered the write + release.wait(20) + written.append(t) + + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + _patch_submit(monkeypatch, slow_write) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: None) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: None) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_queue=2) + rw.start() + rw.enqueue(np.zeros((4, 4), np.uint16), 0, 0, 0) + assert writing.wait(5.0) # drain took f0 and is wedged; f1, f2 now queue + rw.enqueue(np.zeros((4, 4), np.uint16), 1, 0, 0) + rw.enqueue(np.zeros((4, 4), np.uint16), 2, 0, 0) + rw.finalize(timeout_s=0.5) + assert rw.finalize_wedged is True + + release.set() # stall clears + deadline = _time.monotonic() + 5.0 + while len(written) < 3 and _time.monotonic() < deadline: + _time.sleep(0.05) + rw._thread.join(timeout=5.0) # don't leak the drain into the next test + assert written == [0, 1, 2], f"queued captured frames discarded after wedge: only {written} written" + + +def test_join_timeout_is_not_wedged(tmp_path, monkeypatch): + """R5: a drain that is slow but steadily writing a backlog is NOT wedged — + flagging it aborts whole multi-well runs over stores that finish fine.""" + import time as _time + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + _patch_submit(monkeypatch, lambda self, image, t, c, z, fov=None: _time.sleep(0.4)) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: None) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: None) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_queue=8) + rw.start() + for i in range(4): # ~1.6s of backlog, sentinel enqueues immediately + rw.enqueue(np.zeros((4, 4), np.uint16), i, 0, 0) + rw.finalize(timeout_s=0.5) # join times out while the drain makes progress + assert rw.finalize_wedged is False, "slow-but-progressing drain wrongly flagged wedged" + + +def test_frame_source_skips_mode_switch_when_already_configured_but_applies_rate_after_start(): + """R11: record() already probes set_acquisition_mode + set_frame_rate for the + achievable fps; ContinuousFrameSource must not repeat the mode switch (a strobe/ + exposure re-send on every FOV). The frame-rate hint, however, is applied once + more AFTER start_streaming(): on toupcam a hint written while the stream is + stopped can be rejected against a stale range after a binning change, and the + sensor then keeps its previous pacing.""" + from control.core.streaming_capture import ContinuousFrameSource + + class _CountingCamera: + def __init__(self): + self.mode_calls = 0 + self.rate_calls = [] + self.streaming = False + + def set_acquisition_mode(self, mode): + self.mode_calls += 1 + + def set_frame_rate(self, fps): + self.rate_calls.append((fps, self.streaming)) + return fps + + def add_frame_callback(self, cb): + return 1 + + def remove_frame_callback(self, cb_id): + pass + + def start_streaming(self): + self.streaming = True + + def stop_streaming(self): + self.streaming = False + + cam = _CountingCamera() + src = ContinuousFrameSource(cam, fps=10.0, already_configured=True) + assert src.start(lambda f: None) == 10.0 # the running-stream rate, which decides the router's regime + src.stop() + assert cam.mode_calls == 0, f"already-configured source still switched the mode ({cam.mode_calls})" + assert cam.rate_calls == [(10.0, True)] # exactly one hint, with the stream running + + cam2 = _CountingCamera() + src2 = ContinuousFrameSource(cam2, fps=10.0) + src2.start(lambda f: None) + src2.stop() + assert cam2.mode_calls == 1 and cam2.rate_calls == [(10.0, True)] + + +def test_zarr_config_extra_squid_attrs_written(tmp_path): + """Per-plane recording metadata rides in _squid via extra_squid_attrs.""" + import json + import pytest as _pytest + + _pytest.importorskip("tensorstore") + from control.core.zarr_writer import ZarrAcquisitionConfig, ZarrWriter + + cfg = ZarrAcquisitionConfig( + output_path=str(tmp_path / "plane.ome.zarr"), + shape=(2, 1, 1, 4, 4), + dtype=np.uint16, + pixel_size_um=1.0, + z_step_um=None, + time_increment_s=0.1, + channel_names=["BF"], + channel_colors=["#FFFFFF"], + channel_wavelengths=[None], + is_hcs=False, + extra_squid_attrs={"plane_index": 2, "plane_z_offset_um": 6.0, "n_planes": 3}, + ) + w = ZarrWriter(cfg) + w.initialize() + w.write_frame(np.zeros((4, 4), np.uint16), t=0, c=0, z=0) + w.finalize() + + meta = json.load(open(tmp_path / "plane.ome.zarr" / "zarr.json")) + squid_attrs = meta["attributes"]["_squid"] + assert squid_attrs["plane_index"] == 2 + assert squid_attrs["plane_z_offset_um"] == 6.0 + assert squid_attrs["n_planes"] == 3 + + +# --------------------------------------------------------------------------- +# Pipelined drain: submissions are reaped later, errors still counted, window bounded +# --------------------------------------------------------------------------- + + +def test_recording_writer_reaps_future_errors_and_seals_incomplete(tmp_path, monkeypatch): + """A write that fails *after* submission (future.result() raises) must still count as a + write error and seal the store incomplete, even though the drain no longer blocks per frame.""" + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + class _FailingFuture: + def done(self): + return True + + def result(self): + raise RuntimeError("commit failed: disk full") + + calls = [] + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + monkeypatch.setattr(ZarrWriter, "submit_frame", lambda self, image, t, c, z, fov=None: _FailingFuture()) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: calls.append("finalize")) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: calls.append(("abort", k.get("mark_aborted")))) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_inflight=4) + rw.start() + for t in range(3): + rw.enqueue(np.zeros((4, 4), np.uint16), t, 0, 0) + rw.finalize() + + assert rw.write_error_count == 3 + assert calls == [("abort", False)], calls + assert rw._held_bytes == 0 # byte accounting released on reap, not leaked + + +def test_recording_writer_bounds_inflight_submissions(tmp_path, monkeypatch): + """At most max_inflight writes are outstanding: the drain must reap the oldest before + submitting more, so a stalled store cannot accumulate unbounded TensorStore buffers.""" + import threading + import time as _time + from control.core.zarr_writer import ZarrWriter + from control.core.streaming_capture import RecordingWriter + + release = threading.Event() + submitted = [] + + class _BlockingFuture: + def done(self): + return release.is_set() + + def result(self): + release.wait(20) + + monkeypatch.setattr(ZarrWriter, "initialize", lambda self: None) + # Record (writer, t): drain threads leaked by earlier wedge tests in this module also + # hit this class-level fake, so only this writer's submissions are asserted on. + monkeypatch.setattr( + ZarrWriter, + "submit_frame", + lambda self, image, t, c, z, fov=None: (submitted.append((self, t)), _BlockingFuture())[1], + ) + monkeypatch.setattr(ZarrWriter, "finalize", lambda self, *a, **k: None) + monkeypatch.setattr(ZarrWriter, "abort", lambda self, *a, **k: None) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_queue=64, max_inflight=4) + mine = lambda: [t for w, t in submitted if w is rw._writer] + rw.start() + for t in range(12): + rw.enqueue(np.zeros((4, 4), np.uint16), t, 0, 0) + _time.sleep(0.5) + # 4 submitted; the drain is blocked reaping the oldest before submitting the 5th. + assert mine() == [0, 1, 2, 3], mine() + assert rw.dropped_count == 0 + release.set() + rw.finalize() + assert mine() == list(range(12)) + assert rw.write_error_count == 0 + assert rw._held_bytes == 0 + + +def test_recording_writer_pipelined_real_store_is_complete(tmp_path): + """End to end through a real TensorStore store with the default in-flight window: every + frame lands in its slot and the store seals complete.""" + import json + + pytest.importorskip("tensorstore") + from control.core.streaming_capture import RecordingWriter + from control.core.zarr_writer import ZarrAcquisitionConfig + + T = 40 + cfg = ZarrAcquisitionConfig( + output_path=str(tmp_path / "pipelined.ome.zarr"), + shape=(T, 1, 1, 16, 16), + dtype=np.uint16, + pixel_size_um=1.0, + z_step_um=None, + time_increment_s=0.1, + channel_names=["BF"], + channel_colors=["#FFFFFF"], + channel_wavelengths=[None], + is_hcs=False, + ) + rw = RecordingWriter(cfg) + rw.start() + for t in range(T): + rw.enqueue(np.full((16, 16), t + 1, np.uint16), t, 0, 0) + rw.finalize() + assert rw.write_error_count == 0 and rw.dropped_count == 0 + + import tensorstore as ts + + ds = ts.open({"driver": "zarr3", "kvstore": {"driver": "file", "path": cfg.output_path}}).result() + for t in range(T): + assert int(np.asarray(ds[t, 0, 0].read().result())[0, 0]) == t + 1 + meta = json.load(open(tmp_path / "pipelined.ome.zarr" / "zarr.json")) + assert meta["attributes"]["_squid"]["acquisition_complete"] is True + + +# --------------------------------------------------------------------------- +# Paced router: camera delivers at (or below) the target rate -> sequential slots +# --------------------------------------------------------------------------- + + +def test_paced_router_takes_every_frame_sequentially_despite_rate_mismatch(): + # Sensor free-running ~2% fast (period 98 ms vs nominal 100 ms): the nearest-slot + # rule would reject a frame every ~50 as a "duplicate"; paced routing never does. + r = RecordingRouter(fps=10.0, paced=True) + slots = [r.route(100.0 + k * 0.098) for k in range(120)] + assert slots == [(k, 0, 0) for k in range(120)] + + +def test_paced_router_absorbs_a_late_callback(): + # Frame 3 delivered 70 ms late (host stall), frame 4 on time -> gap 170 ms then + # 30 ms; neither is a stall, no hole, no rejection. + r = RecordingRouter(fps=10.0, paced=True) + assert r.route(100.00) == (0, 0, 0) + assert r.route(100.10) == (1, 0, 0) + assert r.route(100.20) == (2, 0, 0) + assert r.route(100.37) == (3, 0, 0) # 70 ms late + assert r.route(100.40) == (4, 0, 0) # catch-up frame + + +def test_paced_router_absolute_floor_absorbs_host_hiccups_at_high_fps(): + # 100 fps (10 ms period): a 60 ms host pause is 6 periods but under the 100 ms floor, + # so it is delivery jitter, not a stall; a 250 ms gap is a stall (24 holes). + r = RecordingRouter(fps=100.0, paced=True) + assert r.route(100.00) == (0, 0, 0) + assert r.route(100.01) == (1, 0, 0) + assert r.route(100.07) == (2, 0, 0) # 60 ms late + assert r.route(100.08) == (3, 0, 0) + assert r.route(100.33) == (28, 0, 0) # 250 ms gap -> round(25) - 1 = 24 holes + + +def test_paced_router_keeps_holes_for_a_real_stall(): + # Two whole periods with no frame (gap 300 ms at 10 fps) -> two slots stay holes. + r = RecordingRouter(fps=10.0, paced=True) + assert r.route(100.0) == (0, 0, 0) + assert r.route(100.1) == (1, 0, 0) + assert r.route(100.4) == (4, 0, 0) # slots 2 and 3 are fill holes + assert r.route(100.5) == (5, 0, 0) + + +def test_unpaced_router_still_downsamples_a_2x_source(): + # Regression guard: the default regime is unchanged for cameras that free-run faster. + r = RecordingRouter(fps=10.0) + slots = [r.route(100.0 + k * 0.05) for k in range(10)] + assert slots == [(0, 0, 0), None, (1, 0, 0), None, (2, 0, 0), None, (3, 0, 0), None, (4, 0, 0), None] + + +# Task 2: throttled live-preview display tap +# --------------------------------------------------------------------------- + + +def test_display_fn_throttled_and_first_frame_shown(): + # 20 frames at 20 fps (0.05 s apart), preview at 5 fps (0.2 s interval): + # displays at t=100.00, 100.20, 100.40, 100.60, 100.80 -> exactly 5 calls. + frames = [_FakeFrame(100.0 + i * 0.05, np.zeros((4, 4), np.uint16)) for i in range(20)] + w = _ListWriter() + shown = [] + cap = StreamingCapture( + _FakeSource(frames), + RecordingRouter(fps=20.0), + CountStop(20), + w, + abort_fn=lambda: False, + display_fn=shown.append, + display_fps=5.0, + ) + emitted = cap.run() + assert emitted == 20 + assert len(shown) == 5 + + +def test_display_fn_error_disables_preview_but_recording_completes(): + frames = [_FakeFrame(100.0 + i * 0.05, np.zeros((4, 4), np.uint16)) for i in range(20)] + w = _ListWriter() + calls = [] + + def bad_display(frame): + calls.append(1) + raise RuntimeError("display broke") + + cap = StreamingCapture( + _FakeSource(frames), + RecordingRouter(fps=20.0), + CountStop(20), + w, + abort_fn=lambda: False, + display_fn=bad_display, + display_fps=1000.0, # would fire every frame if not disabled + ) + emitted = cap.run() + assert emitted == 20 # recording unaffected + assert len(calls) == 1 # disabled after the first raise + assert w.finalized # store still sealed normally + + +def test_display_fps_zero_means_no_preview(): + frames = [_FakeFrame(100.0 + i * 0.05, np.zeros((4, 4), np.uint16)) for i in range(5)] + shown = [] + cap = StreamingCapture( + _FakeSource(frames), + RecordingRouter(fps=20.0), + CountStop(5), + _ListWriter(), + abort_fn=lambda: False, + display_fn=shown.append, + display_fps=0.0, + ) + assert cap.run() == 5 + assert shown == [] + + +# --------------------------------------------------------------------------- +# Review fixes: measured t-axis spacing (paced source off its nominal rate), stall logging +# --------------------------------------------------------------------------- + + +class _MeasuringWriter(_ListWriter): + def __init__(self): + super().__init__() + self.measured = None + + def set_measured_time_increment(self, seconds): + self.measured = seconds + + +def test_paced_router_measures_delivered_period_and_counts_stalls(): + r = RecordingRouter(fps=10.0, paced=True) + assert r.measured_period() is None + for k in range(5): + r.route(100.0 + k * 0.2) + # 200 ms gaps at a 100 ms nominal period are stalls (>= 175 ms): one hole each, so + # 4 stalls / 4 holes, and the measured spacing per spanned slot is still 100 ms. + assert r.stall_count == 4 and r.hole_count == 4 + assert r.measured_period() == pytest.approx(0.1) + r2 = RecordingRouter(fps=10.0, paced=True) + for k in range(11): + r2.route(100.0 + k * 0.15) # 150 ms gaps: under the stall gap, absorbed sequentially + assert r2.stall_count == 0 and r2.nominal_period == pytest.approx(0.1) + assert r2.measured_period() == pytest.approx(0.15) + + +def test_capture_stamps_measured_spacing_when_paced_source_runs_slow(): + # Paced at 10 fps, source actually delivering every 150 ms: every slot fills (no holes, + # store complete) but the t axis is 1.5x longer than nominal -> the writer is told. + frames = [_FakeFrame(100.0 + i * 0.15, np.zeros((4, 4), np.uint16)) for i in range(20)] + w = _MeasuringWriter() + cap = StreamingCapture( + _FakeSource(frames), RecordingRouter(fps=10.0, paced=True), CountStop(20), w, abort_fn=lambda: False + ) + assert cap.run() == 20 + assert w.finalized + assert w.measured == pytest.approx(0.15) + + +def test_capture_keeps_nominal_spacing_within_tolerance(): + # 1% slow: within tolerance, nominal value kept (no measured stamp). + frames = [_FakeFrame(100.0 + i * 0.101, np.zeros((4, 4), np.uint16)) for i in range(20)] + w = _MeasuringWriter() + cap = StreamingCapture( + _FakeSource(frames), RecordingRouter(fps=10.0, paced=True), CountStop(20), w, abort_fn=lambda: False + ) + assert cap.run() == 20 + assert w.measured is None + + +def test_capture_stamps_measured_spacing_when_paced_source_runs_fast(): + frames = [_FakeFrame(100.0 + i * 0.05, np.zeros((4, 4), np.uint16)) for i in range(20)] + w = _MeasuringWriter() + cap = StreamingCapture( + _FakeSource(frames), RecordingRouter(fps=10.0, paced=True), CountStop(20), w, abort_fn=lambda: False + ) + assert cap.run() == 20 + assert w.measured == pytest.approx(0.05) + + +def test_measured_spacing_lands_in_zarr_metadata(tmp_path): + """End to end: a paced recording that ran 1.5x slow seals complete with time_increment_s + (both _squid and the OME t scale) rewritten to the measured spacing and the nominal kept; + a 2x-slow one (real stalls, holes) seals incomplete with the per-slot spacing unchanged.""" + import json + + pytest.importorskip("tensorstore") + from control.core.streaming_capture import RecordingWriter + from control.core.zarr_writer import ZarrAcquisitionConfig + + T = 12 + + def cfg(name): + return ZarrAcquisitionConfig( + output_path=str(tmp_path / name), + shape=(T, 1, 1, 8, 8), + dtype=np.uint16, + pixel_size_um=0.5, + z_step_um=None, + time_increment_s=0.1, + channel_names=["BF"], + channel_colors=["#FFFFFF"], + channel_wavelengths=[None], + is_hcs=False, + ) + + # 200 ms gaps ARE stalls at 10 fps (>= 175 ms): one hole per gap, slots 0,2,4,... fill and the + # router runs past T after 6 frames -> the incomplete seal path. + frames = [_FakeFrame(100.0 + i * 0.2, np.full((8, 8), i + 1, np.uint16)) for i in range(T)] + w = RecordingWriter(cfg("stalls.ome.zarr")) + cap = StreamingCapture( + _FakeSource(frames), RecordingRouter(fps=10.0, paced=True), CountStop(T), w, abort_fn=lambda: False + ) + assert cap.run() == 6 + sq = json.load(open(tmp_path / "stalls.ome.zarr" / "zarr.json"))["attributes"]["_squid"] + assert sq["acquisition_complete"] is False and sq["captured_frames"] == 6 + assert sq["time_increment_s"] == pytest.approx(0.1) # per-slot spacing is still the nominal 100 ms + + # 150 ms gaps: no holes, every slot filled, complete -- and the t axis stamped honestly. + frames = [_FakeFrame(100.0 + i * 0.15, np.full((8, 8), i + 1, np.uint16)) for i in range(T)] + w2 = RecordingWriter(cfg("slow.ome.zarr")) + cap2 = StreamingCapture( + _FakeSource(frames), RecordingRouter(fps=10.0, paced=True), CountStop(T), w2, abort_fn=lambda: False + ) + assert cap2.run() == T + meta = json.load(open(tmp_path / "slow.ome.zarr" / "zarr.json")) + sq = meta["attributes"]["_squid"] + assert sq["acquisition_complete"] is True + assert sq["time_increment_s"] == pytest.approx(0.15) + assert sq["nominal_time_increment_s"] == pytest.approx(0.1) + ms = meta["attributes"]["ome"]["multiscales"][0] + t_axis = [a["name"] for a in ms["axes"]].index("t") + assert ms["datasets"][0]["coordinateTransformations"][0]["scale"][t_axis] == pytest.approx(0.15) + + +def test_frame_source_discards_frames_around_a_live_frame_rate_write(): + """toupcam with a hardware ROI: the stopped-state hint is re-clamped at Start, so the + post-start write is what sets the rate, and it corrupts the frame integrating at that + moment. The source drops what arrives within a period (+20 ms) of the write.""" + from control.core.streaming_capture import ContinuousFrameSource + + class _Cam: + frame_rate_hint_live_write_ts = None + + def __init__(self): + self.cb = None + self.streaming = False + + def set_acquisition_mode(self, mode): + pass + + def set_frame_rate(self, fps): + if self.streaming: + self.frame_rate_hint_live_write_ts = time.time() # wrote into the running stream + return fps + + def add_frame_callback(self, cb): + self.cb = cb + return 1 + + def remove_frame_callback(self, cb_id): + pass + + def start_streaming(self): + self.streaming = True + + def stop_streaming(self): + self.streaming = False + + cam = _Cam() + src = ContinuousFrameSource(cam, fps=10.0, already_configured=True) + got = [] + src.start(got.append) + t_write = cam.frame_rate_hint_live_write_ts + assert t_write is not None + cam.cb(_FakeFrame(t_write + 0.05, "corrupt")) # integrating during the write -> dropped + cam.cb(_FakeFrame(t_write + 0.11, "in-flight")) # inside period+20 ms window -> dropped + cam.cb(_FakeFrame(t_write + 0.13, "clean")) # past the window -> delivered + cam.cb(_FakeFrame(t_write + 0.23, "clean2")) + src.stop() + assert [f.frame for f in got] == ["clean", "clean2"] + assert src._discarded == 2 + + # No live write (camera already had the value, or full-frame stopped write stuck): nothing dropped. + cam2 = _Cam() + cam2.set_frame_rate = lambda fps: fps + src2 = ContinuousFrameSource(cam2, fps=10.0, already_configured=True) + got2 = [] + src2.start(got2.append) + cam2.cb(_FakeFrame(time.time(), "first")) + src2.stop() + assert [f.frame for f in got2] == ["first"] + + +def test_router_decides_the_regime_from_the_running_stream_rate(): + """The regime follows what the camera reports once the stream runs (a stopped-state probe can be + rejected: toupcam after a binning change reports its free-run maximum there). An at-rate camera + is routed sequentially, so delivery with ordinary jitter fills every slot instead of skipping one; + a faster free-running camera keeps nearest-slot downsampling.""" + paced = RecordingRouter(10.0) + paced.set_paced_from_rate(10.0) + assert [paced.route(ts)[0] for ts in (0.0, 0.16, 0.26, 0.36, 0.46)] == [0, 1, 2, 3, 4] + + free_running = RecordingRouter(10.0) + free_running.set_paced_from_rate(28.0) + assert [free_running.route(ts) for ts in (0.0, 0.03, 0.1)] == [(0, 0, 0), None, (1, 0, 0)] + + +def test_finalize_reports_a_drain_that_outlived_it_and_wait_sealed_sees_its_errors(tmp_path, monkeypatch): + """A drain still writing when finalize() gives up its join must not look healthy: finalize() returns + False, and wait_sealed() lets the caller pick up the write errors that surface after it returned.""" + import threading + import tests.control.test_stubs as ts + from control.core.streaming_capture import RecordingWriter + + release = threading.Event() + ts.stub_zarr_writer_io( + monkeypatch, + lambda self, image, t, c, z, fov=None: ts.GatedFuture(release, RuntimeError("disk error after finalize")), + ) + + rw = RecordingWriter(_stub_zarr_cfg(tmp_path), max_queue=8) + rw.start() + rw.enqueue(np.zeros((4, 4), np.uint16), 0, 0, 0) + assert rw.finalize(timeout_s=0.2) is False + assert rw.finalize_wedged is False and rw.write_error_count == 0 # looks healthy: the error is still ahead + assert rw.wait_sealed(timeout_s=0.0) is False + release.set() + assert rw.wait_sealed(timeout_s=5.0) is True + assert rw.write_error_count == 1 diff --git a/software/tests/test_acquisition_yaml_drop_mixin.py b/software/tests/test_acquisition_yaml_drop_mixin.py new file mode 100644 index 000000000..0207ff814 --- /dev/null +++ b/software/tests/test_acquisition_yaml_drop_mixin.py @@ -0,0 +1,56 @@ +"""Tests for the two AcquisitionYAMLDropMixin generalizations needed to support a 3rd +widget_type (record_zstack) without breaking the existing wellplate/flexible behavior.""" + +from unittest.mock import MagicMock + +from control.widgets import AcquisitionYAMLDropMixin + + +class _HostWithMultipointController(AcquisitionYAMLDropMixin): + def __init__(self, camera): + self.multipointController = MagicMock(camera=camera) + + def _get_expected_widget_type(self): + return "wellplate" + + +def test_default_camera_hook_reads_multipoint_controller_camera(): + camera = object() + host = _HostWithMultipointController(camera) + assert host._get_camera_for_binning_check() is camera + + +def test_get_other_widget_name_maps_all_three_types(): + host = _HostWithMultipointController(camera=None) + assert host._get_other_widget_name("wellplate") == "Wellplate Multipoint" + assert host._get_other_widget_name("flexible") == "Flexible Multipoint" + assert host._get_other_widget_name("record_zstack") == "Record + Z-Stack" + + +def test_parse_well_name_basic(): + from control.widgets import _parse_well_name + + assert _parse_well_name("C4") == (2, 3) + assert _parse_well_name("A1") == (0, 0) + assert _parse_well_name("not-a-well") == (None, None) + + +def test_load_well_regions_selects_items_and_emits_signal(): + from control.widgets import _load_well_regions + + well_widget = MagicMock() + well_widget.rowCount.return_value = 8 + well_widget.columnCount.return_value = 12 + item = MagicMock() + well_widget.item.return_value = item + + _load_well_regions(well_widget, [{"name": "C4", "center_mm": [1, 2, 3], "shape": "Square"}]) + + item.setSelected.assert_called_once_with(True) + well_widget.signal_wellSelected.emit.assert_called_once_with(True) + + +def test_load_well_regions_noop_when_widget_is_none(): + from control.widgets import _load_well_regions + + _load_well_regions(None, [{"name": "C4"}]) # must not raise diff --git a/software/tests/test_record_zstack_camera_fps.py b/software/tests/test_record_zstack_camera_fps.py new file mode 100644 index 000000000..99c3d5195 --- /dev/null +++ b/software/tests/test_record_zstack_camera_fps.py @@ -0,0 +1,292 @@ +import time + +import pytest + +import squid.config +import squid.camera.utils +from squid.abc import CameraAcquisitionMode + + +def test_clamp_precise_framerate_tenths(): + from control.camera_toupcam import clamp_precise_framerate_tenths + + # fps -> tenths, clamped to [min,max] in tenths + assert clamp_precise_framerate_tenths(11.5, min_tenths=10, max_tenths=600) == 115 + assert clamp_precise_framerate_tenths(0.1, min_tenths=10, max_tenths=600) == 10 # below min + assert clamp_precise_framerate_tenths(999.0, min_tenths=10, max_tenths=600) == 600 # above max + + +def _sim_camera(): + cfg = squid.config.get_camera_config().model_copy(update={"rotate_image_angle": None, "flip": None}) + return squid.camera.utils.get_camera(cfg, simulated=True) + + +def test_set_frame_rate_returns_achievable_and_is_clamped(): + cam = _sim_camera() + cam.set_exposure_time(10) # 10 ms -> max ~100 fps (exposure-limited) + # Requesting more than achievable returns the achievable max. + achievable = cam.set_frame_rate(10_000.0) + assert achievable <= 1000.0 / cam.get_total_frame_time() + 1e-6 + assert achievable > 0 + + +def test_simulated_camera_honors_frame_rate(): + cam = _sim_camera() + cam.set_exposure_time(1) # 1 ms exposure -> would free-run very fast + cam.set_frame_rate(20.0) # but cap to 20 fps + cam.set_acquisition_mode(CameraAcquisitionMode.CONTINUOUS) + received = [] + cam.add_frame_callback(lambda f: received.append(f.timestamp)) + cam.start_streaming() + time.sleep(1.0) + cam.stop_streaming() + # ~20 fps over ~1s -> well under 40, comfortably over 5 (loose bounds for CI). + assert 5 <= len(received) <= 40 + + +def _bare_toupcam(strobe_time_us, exposure_ms): + """A ToupcamCamera with just the attributes set_frame_rate/_continuous_max_framerate touch. + + Built without __init__ (no hardware) so the readout-vs-exposure rate math can be + tested against the exact values the real ITR3CMOS26000KMA produces (strobe_time_us=35666). + """ + from control.camera_toupcam import ToupcamCamera, StrobeInfo + + cam = object.__new__(ToupcamCamera) + cam._log = squid.logging.get_logger("test_toupcam_fps") + cam._strobe_info = StrobeInfo(strobe_time_us=float(strobe_time_us), trigger_delay_us=15666.0) + cam._exposure_time = float(exposure_ms) + cam._current_mode_key = lambda: (3104, 2084, 2) # 2x2 binning, 16-bit; no SDK behind a bare instance + cam._raw_camera_stream_started = False # stopped unless a test says otherwise + return cam + + +def test_continuous_max_framerate_is_readout_limited_not_triggered_frame_time(): + # ITR3CMOS26000KMA: readout period 35.666 ms => ~28 fps free-run, exposure (20ms) < readout. + # The BUG returned 1000/get_total_frame_time() = 1000/(20 + (35666+15666)/1000) = ~14 fps. + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=20.0) + fps = cam._continuous_max_framerate() + assert 27.5 < fps < 28.5, fps # readout-limited ~28, NOT the halved ~14 + + +def test_continuous_max_framerate_is_exposure_limited_for_long_exposure(): + # Exposure (100 ms) longer than readout (35.666 ms) => continuous rate is exposure-limited. + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=100.0) + fps = cam._continuous_max_framerate() + assert 9.5 < fps < 10.5, fps # 1000/100 + + +class _FakeToupcamSdk: + """Just enough of the toupcam SDK object for set_frame_rate. + + ``readable=False`` mimics a stopped pull-mode stream: on the ITR3CMOS26000KMA the + MIN/MAX_PRECISE_FRAMERATE *reads* fail with E_UNEXPECTED while the camera is stopped, + but the PRECISE_FRAMERATE *write* is accepted and takes effect on the next Start. + """ + + E_UNEXPECTED = -2147418113 + + def __init__(self, min_tenths=19, max_tenths=280, readable=True, writable=True): + import control.toupcam as toupcam + + self._toupcam = toupcam + self.min_tenths, self.max_tenths = min_tenths, max_tenths + self.readable, self.writable = readable, writable + self.puts = [] + + def get_Option(self, opt): + if not self.readable: + raise self._toupcam.HRESULTException(self.E_UNEXPECTED) + if opt == self._toupcam.TOUPCAM_OPTION_MAX_PRECISE_FRAMERATE: + return self.max_tenths + if opt == self._toupcam.TOUPCAM_OPTION_MIN_PRECISE_FRAMERATE: + return self.min_tenths + raise AssertionError(f"unexpected get_Option({opt})") + + def put_Option(self, opt, value): + if not self.writable: + raise self._toupcam.HRESULTException(self.E_UNEXPECTED) + assert opt == self._toupcam.TOUPCAM_OPTION_PRECISE_FRAMERATE + self.puts.append(value) + + +def test_set_frame_rate_without_cached_range_bounds_by_readout_and_still_writes(): + # Range unreadable (stream stopped) and nothing cached (mode changed since the last + # stream start): bound the request by the readout/exposure-limited continuous max + # (~28 fps, NOT the ~14 fps of the triggered frame time), write that, and report it. + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=20.0) + cam._camera = _FakeToupcamSdk(readable=False) + achievable = cam.set_frame_rate(30.0) + assert 27.5 < achievable < 28.5, achievable + assert cam._camera.puts == [280] + # A request below the readout max is written as-is (the camera clamps to its own minimum). + assert cam.set_frame_rate(10.0) == 10.0 + assert cam._camera.puts == [280, 100] + + +def test_set_frame_rate_range_unreadable_and_unwritable_falls_back_to_continuous_max(): + # A model with no PRECISE_FRAMERATE support at all: no write goes through, report the + # free-run maximum so the caller downsamples in software. + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=20.0) + cam._camera = _FakeToupcamSdk(readable=False, writable=False) + achievable = cam.set_frame_rate(10.0) + assert 27.5 < achievable < 28.5, achievable + + +def test_mode_change_invalidates_cached_range(): + # The cached (min, max) belongs to one (resolution, bit depth); a mode change must drop it + # so a stopped-stream set_frame_rate() can't clamp the new mode to the old maximum + # (28 fps at 2x2 16-bit vs 63 fps at 3x3 on the ITR3CMOS26000KMA). + from control.camera_toupcam import ToupcamCamera + + cam = _bare_toupcam(strobe_time_us=15821.0, exposure_ms=2.0) # 3x3 readout: ~63 fps + cam._camera = _FakeToupcamSdk(min_tenths=19, max_tenths=280, readable=True) + cam._refresh_precise_framerate_range() # cached (19, 280) for the 2x2 mode key + cam._camera.readable = False # stream stopped + cam._current_mode_key = lambda: (2064, 1386, 2) # switched to 3x3 + # What _update_internal_settings does on a mode change: + if cam._current_mode_key() != cam._precise_framerate_mode_key: + cam._precise_framerate_range_tenths = None + assert cam.set_frame_rate(1000.0) == pytest.approx(1000.0 / 15.821, abs=0.1) + assert cam._camera.puts[-1] == 632 + + +def test_set_frame_rate_applies_hint_while_streaming(): + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=20.0) + cam._camera = _FakeToupcamSdk() + assert cam.set_frame_rate(10.0) == 10.0 + assert cam._camera.puts == [100] + assert cam._precise_framerate_range_tenths == (19, 280) + + +def test_set_frame_rate_uses_cached_range_while_stopped(): + # The bug seen on hardware: record() calls set_frame_rate right after the frame-shape probe + # stopped the stream, the range read raised E_UNEXPECTED, and the hint was silently dropped + # (camera then free-ran at 28 fps). With the range cached from the last read the write + # must still go through and the returned rate must be the requested one. + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=20.0) + cam._camera = _FakeToupcamSdk(readable=True) + cam._refresh_precise_framerate_range() # populated while "streaming" + cam._camera.readable = False # stream stopped + assert cam.set_frame_rate(10.0) == 10.0 + assert cam._camera.puts == [100] + + +def test_set_frame_rate_clamps_to_range(): + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=1.0) + cam._camera = _FakeToupcamSdk(min_tenths=19, max_tenths=280) + assert cam.set_frame_rate(1.0) == 1.9 # below MIN -> MIN + assert cam.set_frame_rate(100.0) == 28.0 # above MAX -> MAX (== readout-limited max here) + assert cam._camera.puts == [19, 280] + + +def test_set_frame_rate_is_bounded_by_exposure(): + # MAX_PRECISE_FRAMERATE ignores exposure: at 100 ms exposure the camera still reports 28 fps + # max but delivers 10 fps. The returned rate sizes the recording, so it must be 10, not 28. + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=100.0) + cam._camera = _FakeToupcamSdk() + assert cam.set_frame_rate(30.0) == 10.0 + assert cam._camera.puts == [280] + + +def test_set_frame_rate_write_failure_falls_back_to_continuous_max(): + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=20.0) + cam._camera = _FakeToupcamSdk(writable=False) + achievable = cam.set_frame_rate(10.0) + assert 27.5 < achievable < 28.5, achievable + + +def test_refresh_range_never_raises_and_only_caches_in_continuous_mode(): + # V4: a failing ROI / pixel-format lookup inside the refresh must not escape into + # _start_raw_camera_stream (it would desync the streaming flag from the SDK stream). + from squid.abc import CameraAcquisitionMode + + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=2.0) + cam._camera = _FakeToupcamSdk() + + def boom(): + raise RuntimeError("get_Roi failed") + + cam._current_mode_key = boom + assert cam._refresh_precise_framerate_range() is None # swallowed, nothing cached + cam._current_mode_key = lambda: (3104, 2084, 2) + + # B4: the post-start refresh runs only for a CONTINUOUS (free-run) stream; a + # trigger-mode stream (z-stack phase) must not overwrite the free-run range. + class _StartingSdk(_FakeToupcamSdk): + def StartPullModeWithCallback(self, cb, ctx): + pass + + cam._camera = _StartingSdk(min_tenths=48, max_tenths=1085) + cam._raw_camera_stream_started = False + cam.get_acquisition_mode = lambda: CameraAcquisitionMode.SOFTWARE_TRIGGER + cam._start_raw_camera_stream() + assert cam._raw_camera_stream_started is True + assert cam._precise_framerate_range_tenths is None + cam.get_acquisition_mode = lambda: CameraAcquisitionMode.CONTINUOUS + cam._start_raw_camera_stream() + assert cam._precise_framerate_range_tenths == (48, 1085) + + +def test_set_frame_rate_skips_live_write_when_value_already_set_and_records_live_writes(): + # While streaming, a write corrupts the frame in flight (hardware ROI), so the driver reads + # PRECISE_FRAMERATE back first and only writes when the camera does not already have the + # value; a live write that does happen is timestamped for ContinuousFrameSource. + import time as _time + + class _Sdk(_FakeToupcamSdk): + def __init__(self, current): + super().__init__() + self.current = current + + def get_Option(self, opt): + import control.toupcam as toupcam + + if opt == toupcam.TOUPCAM_OPTION_PRECISE_FRAMERATE: + return self.current + return super().get_Option(opt) + + def put_Option(self, opt, value): + super().put_Option(opt, value) + self.current = value + + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=2.0) + cam._camera = _Sdk(current=100) + cam._raw_camera_stream_started = True + assert cam.set_frame_rate(10.0) == 10.0 + assert cam._camera.puts == [] # already at 100 tenths: no live write + assert cam.frame_rate_hint_live_write_ts is None + t0 = _time.time() + assert cam.set_frame_rate(20.0) == 20.0 + assert cam._camera.puts == [200] + assert cam.frame_rate_hint_live_write_ts is not None and cam.frame_rate_hint_live_write_ts >= t0 + # Stopped: always write, never timestamp. + cam._raw_camera_stream_started = False + ts_before = cam.frame_rate_hint_live_write_ts + assert cam.set_frame_rate(15.0) == 15.0 + assert cam._camera.puts == [200, 150] + assert cam.frame_rate_hint_live_write_ts == ts_before + + +def test_get_max_frame_rate_is_a_pure_readout_and_exposure_query(): + # 2x2 16-bit readout 35.666 ms: 28 fps at short exposures, exposure-limited beyond. + cam = _bare_toupcam(strobe_time_us=35666.0, exposure_ms=2.0) + cam._camera = _FakeToupcamSdk() + assert 27.5 < cam.get_max_frame_rate() < 28.5 + assert cam.get_max_frame_rate(100.0) == pytest.approx(10.0) + assert cam.get_max_frame_rate(2.0) == pytest.approx(cam.get_max_frame_rate()) + assert cam._camera.puts == [] # never writes + # Bounded by the cached SDK maximum for the current mode when it is lower. + cam._precise_framerate_range_tenths = (19, 250) + cam._precise_framerate_mode_key = cam._current_mode_key() + assert cam.get_max_frame_rate(2.0) == pytest.approx(25.0) + # A cache for another mode is ignored. + cam._precise_framerate_mode_key = (1, 1, 1) + assert 27.5 < cam.get_max_frame_rate(2.0) < 28.5 + + +def test_simulated_camera_max_frame_rate_matches_its_set_frame_rate_clamp(): + cam = _sim_camera() + cam.set_exposure_time(10) + assert cam.get_max_frame_rate() == pytest.approx(cam.set_frame_rate(10_000.0)) + assert cam.get_max_frame_rate(97.0) == pytest.approx(1000.0 / (97.0 + cam.get_strobe_time())) diff --git a/software/tests/test_record_zstack_widget.py b/software/tests/test_record_zstack_widget.py new file mode 100644 index 000000000..5a61fedaf --- /dev/null +++ b/software/tests/test_record_zstack_widget.py @@ -0,0 +1,2415 @@ +"""Tests for RecordZStackMultiPointWidget (Tasks E1 + E2). + +Covers: widget skeleton, validation, inline editors, Copy-from-Live, computed labels. + +Two test layers: +1. Pure validation helper (validate_record_zstack_params) — no Qt needed. +2. Full widget instantiation using qtbot (pytest-qt manages QApplication). + +The validation rules are extracted into a pure helper so they can be tested +without any Qt machinery. The widget tests verify that the widget reads its +fields correctly and delegates to the same rules. + +NOTE on testability (as required by the task brief): +The validation logic lives in validate_record_zstack_params() in +record_zstack_controller.py so that all constraint rules can be exercised +without instantiating QWidget (the widget's validate() delegates to it). +Creating a QApplication manually via PyQt5.QtWidgets.QApplication +aborts because pytest-qt (loaded by napari's conftest) creates a PyQt6 +QApplication before the test body runs. Using qtbot solves this — it is +available in the test suite and is the pattern used by all other widget tests +in tests/control/. +""" + +import sys +from typing import List, Optional +from unittest.mock import MagicMock, patch + +import pytest +from qtpy.QtWidgets import QMessageBox + +from control.core.record_zstack_controller import validate_record_zstack_params as _validate_record_zstack_params + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_channel(name: str = "BF LED matrix full"): + """Return a minimal AcquisitionChannel with the given name.""" + from control.models.acquisition_config import AcquisitionChannel, CameraSettings, IlluminationSettings + + return AcquisitionChannel( + name=name, + camera_settings=CameraSettings(exposure_time_ms=50.0, gain_mode=0.0), + illumination_settings=IlluminationSettings(intensity=50.0), + ) + + +def _base_params(**overrides): + """Return a dict of valid _validate_record_zstack_params kwargs, with optional overrides.""" + defaults = dict( + base_path="/tmp/test", + selected_well_count=1, + recording_enabled=False, + fps=10.0, + duration_s=1.0, + recording_channel_name="BF LED matrix full", + zstack_enabled=True, + z_min=-3.0, + z_max=3.0, + step=1.0, + zstack_channel_names=["BF LED matrix full"], + use_laser_af=False, + laser_af_has_reference=False, + ) + defaults.update(overrides) + return defaults + + +# --------------------------------------------------------------------------- +# Pure-function validation tests (no Qt required) +# --------------------------------------------------------------------------- + + +def test_validate_helper_no_base_path(): + params = _base_params(base_path="") + assert _validate_record_zstack_params(**params) is not None + + +def test_validate_helper_no_wells(): + params = _base_params(selected_well_count=0) + assert _validate_record_zstack_params(**params) is not None + + +def test_validate_helper_no_phases_enabled(): + params = _base_params(recording_enabled=False, zstack_enabled=False) + assert _validate_record_zstack_params(**params) is not None + + +def test_validate_helper_bad_zstack_range(): + params = _base_params(z_min=3.0, z_max=-3.0) + assert _validate_record_zstack_params(**params) is not None + + +def test_validate_helper_bad_zstack_step(): + params = _base_params(step=0.0) + assert _validate_record_zstack_params(**params) is not None + + +def test_validate_helper_zstack_no_channels(): + params = _base_params(zstack_channel_names=[]) + assert _validate_record_zstack_params(**params) is not None + + +def test_validate_helper_recording_no_channel(): + params = _base_params(recording_enabled=True, recording_channel_name=None, zstack_enabled=False) + assert _validate_record_zstack_params(**params) is not None + + +def test_validate_helper_recording_bad_fps(): + params = _base_params(recording_enabled=True, fps=0.0, zstack_enabled=False) + assert _validate_record_zstack_params(**params) is not None + + +def test_validate_helper_laser_af_no_reference(): + params = _base_params(use_laser_af=True, laser_af_has_reference=False) + assert _validate_record_zstack_params(**params) is not None + + +def test_validate_helper_laser_af_with_reference(): + params = _base_params(use_laser_af=True, laser_af_has_reference=True) + assert _validate_record_zstack_params(**params) is None + + +def test_validate_helper_valid_zstack_only(): + params = _base_params() + assert _validate_record_zstack_params(**params) is None + + +def test_validate_helper_valid_recording_only(): + params = _base_params( + recording_enabled=True, + fps=10.0, + duration_s=1.0, + recording_channel_name="BF LED matrix full", + zstack_enabled=False, + ) + assert _validate_record_zstack_params(**params) is None + + +def test_validate_helper_valid_both_phases(): + params = _base_params( + recording_enabled=True, + fps=10.0, + duration_s=1.0, + recording_channel_name="BF LED matrix full", + zstack_enabled=True, + ) + assert _validate_record_zstack_params(**params) is None + + +# --------------------------------------------------------------------------- +# Widget-level fixtures +# --------------------------------------------------------------------------- + + +def _make_stub_live_controller(): + """Stub liveController whose get_channels() returns two fake AcquisitionChannels.""" + ctrl = MagicMock() + ctrl.get_channels.return_value = [ + _make_channel("BF LED matrix full"), + _make_channel("Fluorescence 488 nm Ex"), + ] + ctrl.currentConfiguration = _make_channel("BF LED matrix full") + return ctrl + + +def _make_stub_objective_store(): + store = MagicMock() + store.current_objective = "10x" + return store + + +def _make_stub_scan_coordinates(): + """Stub scanCoordinates reporting 1 selected well.""" + sc = MagicMock() + sc.get_selected_wells.return_value = ["A1"] + sc.region_centers = {} + sc.region_shapes = {} + return sc + + +@pytest.fixture +def simulated_widget_deps(tmp_path): + """Provide lightweight stub dependencies for RecordZStackMultiPointWidget.""" + stage = MagicMock() + stage.get_pos.return_value = MagicMock(x_mm=1.5, y_mm=2.5, z_mm=0.0) + + return dict( + stage=stage, + navigationViewer=MagicMock(), + recordZStackController=MagicMock(), + liveController=_make_stub_live_controller(), + objectiveStore=_make_stub_objective_store(), + scanCoordinates=_make_stub_scan_coordinates(), + well_selection_widget=None, + tab_widget=None, + ) + + +# --------------------------------------------------------------------------- +# Widget-level tests — use qtbot (pytest-qt) for QApplication management. +# --------------------------------------------------------------------------- + + +def test_validate_rejects_bad_zstack_and_builds_params(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + # Must set base path so validation can reach the z-stack checks + w.lineEdit_savingDir.setText("/tmp/test") + + w.checkbox_zstack.setChecked(True) + w.entry_zmin.setValue(3.0) + w.entry_zmax.setValue(-3.0) # invalid: max < min + assert w.validate() is not None # returns an error string + + w.entry_zmin.setValue(-3.0) + w.entry_zmax.setValue(3.0) + w.entry_step.setValue(1.0) + # Add one z-stack channel row so the phase is valid + w._add_zstack_channel_row(w.liveController.get_channels(w.objectiveStore.current_objective)[0].name) + assert w.validate() is None + + params = w.build_parameters() + assert params.zstack_enabled and len(params.zstack_channels) == 1 + + +def test_validate_requires_base_path(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("") + w.checkbox_zstack.setChecked(True) + w.entry_zmin.setValue(-3.0) + w.entry_zmax.setValue(3.0) + w.entry_step.setValue(1.0) + w._add_zstack_channel_row("BF LED matrix full") + assert w.validate() is not None + + +def test_validate_requires_phase_enabled(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.checkbox_zstack.setChecked(False) + w.checkbox_recording.setChecked(False) + assert w.validate() is not None + + +def test_build_parameters_recording_phase(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.lineEdit_experimentID.setText("my_exp") + w.checkbox_recording.setChecked(True) + w.entry_fps.setValue(20.0) + w.entry_duration.setValue(5.0) + w.checkbox_zstack.setChecked(False) + + params = w.build_parameters() + assert params.recording_enabled is True + assert params.fps == pytest.approx(20.0) + assert params.duration_s == pytest.approx(5.0) + assert params.experiment_id == "my_exp" + assert params.zstack_enabled is False + assert params.recording_channel is not None + + +def test_build_parameters_includes_xy_mode_and_scan_settings(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.combobox_xy_mode.setCurrentText("Current Position") + w.entry_scan_size.setValue(1.5) + w.entry_overlap.setValue(12.0) + + params = w.build_parameters() + assert params.xy_mode == "Current Position" + assert params.scan_size_mm == pytest.approx(1.5) + assert params.overlap_percent == pytest.approx(12.0) + + +def test_add_zstack_channel_row_deduplicates(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w._add_zstack_channel_row("BF LED matrix full") + w._add_zstack_channel_row("BF LED matrix full") # duplicate + # Both the internal list AND the table must stay at 1 entry after dedup. + assert w._zstack_channel_names.count("BF LED matrix full") == 1 + assert w.zstack_channel_table.rowCount() == 1 + + +def test_zstack_channel_row_tooltip_shows_full_name(qtbot, simulated_widget_deps): + """The Channel column truncates long names visually (narrow Stretch column), + so the full name must be available as a tooltip on hover.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w._add_zstack_channel_row("Fluorescence 488 nm Ex") + + item = w.zstack_channel_table.item(0, 0) + assert item.toolTip() == "Fluorescence 488 nm Ex" + + +def test_validate_helper_recording_bad_duration(): + params = _base_params(recording_enabled=True, duration_s=0.0, zstack_enabled=False) + assert _validate_record_zstack_params(**params) is not None + + +# --------------------------------------------------------------------------- +# E2: Copy-from-Live, dynamic z-stack rows, computed labels +# --------------------------------------------------------------------------- + + +def _make_live_channel(name: str, exposure: float, gain: float, intensity: float): + """Return an AcquisitionChannel stub with specific settings.""" + from control.models.acquisition_config import AcquisitionChannel, CameraSettings, IlluminationSettings + + return AcquisitionChannel( + name=name, + camera_settings=CameraSettings(exposure_time_ms=exposure, gain_mode=gain), + illumination_settings=IlluminationSettings(intensity=intensity), + ) + + +def test_copy_from_live_uses_selected_channels_own_settings(qtbot, simulated_widget_deps): + """Bug fix: the (Recording) button must refresh from the recording row's own + selected channel, not whatever channel currently happens to be active in the + Live tab (currentConfiguration) — clicking it must not switch the row's + channel selection either.""" + from control.widgets import RecordZStackMultiPointWidget + + simulated_widget_deps["liveController"].get_channels.return_value = [ + _make_live_channel("BF LED matrix full", exposure=50.0, gain=0.0, intensity=50.0), + _make_live_channel("Fluorescence 488 nm Ex", exposure=33.0, gain=2.5, intensity=75.0), + ] + # Live tab is showing a *different* channel than the one selected in the + # recording row below — currentConfiguration must be ignored entirely. + simulated_widget_deps["liveController"].currentConfiguration = _make_live_channel( + "BF LED matrix full", exposure=999.0, gain=99.0, intensity=99.0 + ) + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.checkbox_recording.setChecked(True) + w._recording_ch_combo.setCurrentText("Fluorescence 488 nm Ex") + # Selecting the channel above already auto-seeds these same values via + # _on_recording_channel_changed; overwrite them so the assertions below + # can only pass if the button click itself re-applies them. + w._recording_exp_spin.setValue(1.0) + w._recording_gain_spin.setValue(1.0) + w._recording_illum_spin.setValue(1.0) + w.btn_copy_from_live.click() + + # Channel selection must be unchanged. + assert w._recording_channel_name() == "Fluorescence 488 nm Ex" + # Spinboxes must reflect that channel's own settings, not currentConfiguration's. + assert w._recording_exposure() == pytest.approx(33.0) + assert w._recording_gain() == pytest.approx(2.5) + assert w._recording_illumination() == pytest.approx(75.0) + + +def test_copy_zstack_row_from_live_uses_that_channels_own_settings(qtbot, simulated_widget_deps): + """Same bug fix as test_copy_from_live_uses_selected_channels_own_settings, + for the per-row z-stack '⟳' button: it must use the row's own channel + (name), not whatever channel currently happens to be active in the Live tab.""" + from control.widgets import RecordZStackMultiPointWidget + + simulated_widget_deps["liveController"].get_channels.return_value = [ + _make_live_channel("BF LED matrix full", exposure=50.0, gain=0.0, intensity=50.0), + _make_live_channel("Fluorescence 488 nm Ex", exposure=33.0, gain=2.5, intensity=75.0), + ] + simulated_widget_deps["liveController"].currentConfiguration = _make_live_channel( + "BF LED matrix full", exposure=999.0, gain=99.0, intensity=99.0 + ) + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.checkbox_zstack.setChecked(True) + w._add_zstack_channel_row("Fluorescence 488 nm Ex") + w._copy_zstack_row_from_live("Fluorescence 488 nm Ex") + + assert w._get_zstack_row_values("Fluorescence 488 nm Ex") == pytest.approx((33.0, 2.5, 75.0)) + + +def test_copy_from_live_leaves_row_unchanged_when_channel_not_found(qtbot, simulated_widget_deps, caplog): + """Bug fix: if the recording row's selected channel is no longer present in + liveController.get_channels() (e.g. the objective changed elsewhere and the + combo wasn't refreshed), the refresh button must leave the row's values + untouched and warn, rather than silently resetting them to + _channel_settings()'s hardcoded (50, 0, 50) fallback.""" + import logging + + from control.widgets import RecordZStackMultiPointWidget + + simulated_widget_deps["liveController"].get_channels.return_value = [ + _make_live_channel("BF LED matrix full", exposure=50.0, gain=0.0, intensity=50.0), + _make_live_channel("Fluorescence 488 nm Ex", exposure=33.0, gain=2.5, intensity=75.0), + ] + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.checkbox_recording.setChecked(True) + w._recording_ch_combo.setCurrentText("Fluorescence 488 nm Ex") + w._recording_exp_spin.setValue(12.0) + w._recording_gain_spin.setValue(3.0) + w._recording_illum_spin.setValue(20.0) + + # Simulate the channel disappearing from the current objective's list + # without the (stale) combo selection being refreshed. + simulated_widget_deps["liveController"].get_channels.return_value = [ + _make_live_channel("BF LED matrix full", exposure=50.0, gain=0.0, intensity=50.0), + ] + + with caplog.at_level(logging.WARNING): + w.btn_copy_from_live.click() + + assert w._recording_exposure() == pytest.approx(12.0) + assert w._recording_gain() == pytest.approx(3.0) + assert w._recording_illumination() == pytest.approx(20.0) + assert any("not found" in rec.message for rec in caplog.records) + + +def test_add_remove_zstack_channel_row_syncs_list_and_table(qtbot, simulated_widget_deps): + """_add_zstack_channel_row and _remove_zstack_channel_row keep list+table in sync.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + assert w.zstack_channel_table.rowCount() == 0 + assert len(w._zstack_channel_names) == 0 + + w._add_zstack_channel_row("BF LED matrix full") + assert w.zstack_channel_table.rowCount() == 1 + assert len(w._zstack_channel_names) == 1 + + w._add_zstack_channel_row("Fluorescence 488 nm Ex") + assert w.zstack_channel_table.rowCount() == 2 + assert len(w._zstack_channel_names) == 2 + + w._remove_zstack_channel_row("BF LED matrix full") + assert w.zstack_channel_table.rowCount() == 1 + assert len(w._zstack_channel_names) == 1 + assert "BF LED matrix full" not in w._zstack_channel_names + assert "Fluorescence 488 nm Ex" in w._zstack_channel_names + + +def test_computed_plane_label_updates_on_spinbox_change(qtbot, simulated_widget_deps): + """label_zstack_planes updates to '→ N planes' when zmin/zmax/step change.""" + from control.core.record_zstack_controller import zstack_plane_count + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.entry_zmin.setValue(-2.0) + w.entry_zmax.setValue(2.0) + w.entry_step.setValue(1.0) + expected = zstack_plane_count(-2.0, 2.0, 1.0) # 5 + assert str(expected) in w.label_zstack_planes.text() + + +def test_computed_plane_label_degrades_gracefully_on_invalid_range(qtbot, simulated_widget_deps): + """label_zstack_planes shows '—' or '--' when z_max < z_min (invalid range).""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.entry_zmin.setValue(3.0) + w.entry_zmax.setValue(-3.0) # invalid: max < min + # Label should show a placeholder, not crash + text = w.label_zstack_planes.text() + assert text == "-- planes" + + +def test_build_parameters_uses_inline_editor_values(qtbot, simulated_widget_deps): + """build_parameters() reflects recording table row values, not just channel name lookup.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.checkbox_recording.setChecked(True) + w.checkbox_zstack.setChecked(False) + + # Set values via the table's spinbox cell widgets + w._recording_exp_spin.setValue(99.0) + w._recording_gain_spin.setValue(1.5) + w._recording_illum_spin.setValue(60.0) + + params = w.build_parameters() + assert params.recording_channel is not None + assert params.recording_channel.exposure_time == pytest.approx(99.0) + assert params.recording_channel.analog_gain == pytest.approx(1.5) + assert params.recording_channel.illumination_intensity == pytest.approx(60.0) + + +def test_zstack_row_inline_editors_reflected_in_build_parameters(qtbot, simulated_widget_deps): + """Z-stack row inline editor values are used when build_parameters() is called.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.checkbox_zstack.setChecked(True) + w.checkbox_recording.setChecked(False) + w.entry_zmin.setValue(-1.0) + w.entry_zmax.setValue(1.0) + w.entry_step.setValue(1.0) + + w._add_zstack_channel_row("BF LED matrix full") + # Set inline editor values for the row + w._set_zstack_row_values("BF LED matrix full", exposure=77.0, gain=3.0, illumination=45.0) + + params = w.build_parameters() + assert len(params.zstack_channels) == 1 + ch = params.zstack_channels[0] + assert ch.exposure_time == pytest.approx(77.0) + assert ch.analog_gain == pytest.approx(3.0) + assert ch.illumination_intensity == pytest.approx(45.0) + + +# --------------------------------------------------------------------------- +# E3: Start/Stop handoff to RecordZStackController +# --------------------------------------------------------------------------- + + +def _make_stub_controller(): + """Stub RecordZStackController that records calls and reports not-in-progress.""" + ctrl = MagicMock() + ctrl.acquisition_in_progress.return_value = False + return ctrl + + +def _make_valid_widget(qtbot, deps): + """Return a widget pre-configured for a valid z-stack-only acquisition.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test_e3") + w.checkbox_zstack.setChecked(True) + w.checkbox_recording.setChecked(False) + w.entry_zmin.setValue(-2.0) + w.entry_zmax.setValue(2.0) + w.entry_step.setValue(1.0) + w._add_zstack_channel_row("BF LED matrix full") + return w + + +def test_toggle_acquisition_start_valid_calls_run_acquisition(qtbot, simulated_widget_deps): + """Clicking Start with valid params calls run_acquisition() exactly once.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_widget(qtbot, simulated_widget_deps) + + # Simulate pressing the Start button (checked=True) + w.toggle_acquisition(True) + + ctrl.run_acquisition.assert_called_once() + + +def test_toggle_acquisition_start_valid_pushes_params_to_controller(qtbot, simulated_widget_deps): + """Clicking Start with valid params calls run_acquisition(params) with correct values.""" + from control.core.record_zstack_controller import RecordZStackAcquisitionParameters + + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_widget(qtbot, simulated_widget_deps) + w.toggle_acquisition(True) + + # run_acquisition should be called once with a RecordZStackAcquisitionParameters object + ctrl.run_acquisition.assert_called_once() + call_args = ctrl.run_acquisition.call_args + params = call_args.args[0] if call_args.args else call_args.kwargs.get("params") + assert isinstance(params, RecordZStackAcquisitionParameters) + assert params.base_path == "/tmp/test_e3" + assert params.zstack_enabled is True + assert params.recording_enabled is False + assert params.z_min_um == pytest.approx(-2.0) + assert params.z_max_um == pytest.approx(2.0) + assert params.z_step_um == pytest.approx(1.0) + + +def test_toggle_acquisition_start_invalid_no_phase_does_not_call_run(qtbot, simulated_widget_deps): + """Clicking Start with both phases disabled does NOT call run_acquisition().""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test_e3") + w.checkbox_zstack.setChecked(False) + w.checkbox_recording.setChecked(False) + + with patch("control.widgets.QMessageBox.warning") as mock_warn: + w.toggle_acquisition(True) + + ctrl.run_acquisition.assert_not_called() + mock_warn.assert_called_once() + # Button must be un-checked after a failed start + assert not w.btn_startAcquisition.isChecked() + + +def test_toggle_acquisition_start_invalid_bad_zrange_shows_warning(qtbot, simulated_widget_deps): + """Clicking Start with z_max <= z_min pops a warning and does not start.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test_e3") + w.checkbox_zstack.setChecked(True) + w.entry_zmin.setValue(3.0) + w.entry_zmax.setValue(-3.0) # invalid: max < min + w.entry_step.setValue(1.0) + w._add_zstack_channel_row("BF LED matrix full") + + warning_args = [] + with patch("control.widgets.QMessageBox.warning", side_effect=lambda *a: warning_args.append(a)): + w.toggle_acquisition(True) + + ctrl.run_acquisition.assert_not_called() + assert len(warning_args) == 1 + assert not w.btn_startAcquisition.isChecked() + + +def test_toggle_acquisition_stop_calls_request_abort(qtbot, simulated_widget_deps): + """Un-pressing the Start button (pressed=False) calls request_abort().""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_widget(qtbot, simulated_widget_deps) + + # Simulate pressing stop (unchecked) + w.toggle_acquisition(False) + + ctrl.request_abort.assert_called_once() + ctrl.run_acquisition.assert_not_called() + + +# --------------------------------------------------------------------------- +# Confirmation dialog: recording plane summary shown before Start proceeds +# --------------------------------------------------------------------------- + + +def _make_valid_recording_widget(qtbot, deps, *, laser_af=False): + """Return a widget pre-configured for a valid recording-only acquisition. + + Recording is enabled, Z-Stack is disabled. If laser_af is True, a stub + laser_autofocus_controller reporting a reference plane is wired in and + checkbox_laser_af is checked (needed for validate() to pass when Laser AF + is on) -- the caller is then free to set entry_recording_bottom_z. + """ + from control.widgets import RecordZStackMultiPointWidget + + deps = dict(deps) + if laser_af: + laser_ctrl = MagicMock() + laser_ctrl.laser_af_properties.has_reference = True + deps["laser_autofocus_controller"] = laser_ctrl + + w = RecordZStackMultiPointWidget(**deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test_confirm") + w.checkbox_zstack.setChecked(False) + w.checkbox_recording.setChecked(True) + if laser_af: + w.checkbox_laser_af.setChecked(True) + return w + + +def test_toggle_acquisition_confirm_single_plane_summary_and_yes_proceeds(qtbot, simulated_widget_deps): + """Nz == 1: the confirmation dialog shows the single-plane summary and + answering Yes lets the acquisition start.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_recording_widget(qtbot, simulated_widget_deps, laser_af=True) + w.entry_recording_Nz.setValue(1) + w.entry_recording_bottom_z.setValue(-1.5) + w.entry_duration.setValue(2.0) + + expected = f"1 plane @ {-1.5:+.1f} µm — {1 * 2.0:.1f} s/FOV" + + captured = {} + + def fake_question(parent, title, text, *args, **kwargs): + captured["title"] = title + captured["text"] = text + return QMessageBox.Yes + + with patch("control.widgets.QMessageBox.question", side_effect=fake_question): + w.toggle_acquisition(True) + + assert expected in captured["text"] + ctrl.run_acquisition.assert_called_once() + + +def test_toggle_acquisition_confirm_multi_plane_summary_and_yes_proceeds(qtbot, simulated_widget_deps): + """Nz > 1: the confirmation dialog shows the multi-plane summary with the + correct computed top (bottom + (nz - 1) * dz) and answering Yes proceeds.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_recording_widget(qtbot, simulated_widget_deps, laser_af=True) + w.entry_recording_Nz.setValue(4) + w.entry_recording_bottom_z.setValue(-3.0) + w.entry_recording_dz.setValue(2.0) + # entry_duration has 0 decimals (whole seconds only) -- use a value that + # can't be affected by rounding. + w.entry_duration.setValue(3.0) + + nz, bottom, dz, duration = 4, -3.0, 2.0, 3.0 + top = bottom + (nz - 1) * dz + per_fov_s = nz * duration + expected = f"{nz} planes: {bottom:+.1f} … {top:+.1f} µm rel. reference — {per_fov_s:.1f} s/FOV" + + captured = {} + + def fake_question(parent, title, text, *args, **kwargs): + captured["text"] = text + return QMessageBox.Yes + + with patch("control.widgets.QMessageBox.question", side_effect=fake_question): + w.toggle_acquisition(True) + + assert expected in captured["text"] + ctrl.run_acquisition.assert_called_once() + + +def test_toggle_acquisition_confirm_uses_zero_offset_when_laser_af_off(qtbot, simulated_widget_deps): + """Laser AF OFF: the summary's bottom must be 0.0, not whatever stale value + is left sitting in entry_recording_bottom_z from when Laser AF was on. + + This mirrors build_parameters()'s effective-value logic + (entry_recording_bottom_z.value() if checkbox_laser_af.isChecked() else 0.0) + -- the dialog must not show a number that doesn't match what actually runs. + """ + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + laser_ctrl = MagicMock() + laser_ctrl.laser_af_properties.has_reference = True + simulated_widget_deps["laser_autofocus_controller"] = laser_ctrl + + w = _make_valid_recording_widget(qtbot, simulated_widget_deps, laser_af=True) + # Leave a nonzero stale value in the field, then turn Laser AF back off. + w.entry_recording_bottom_z.setValue(7.5) + w.checkbox_laser_af.setChecked(False) + w.entry_recording_Nz.setValue(1) + w.entry_duration.setValue(1.0) + + assert w.entry_recording_bottom_z.value() == pytest.approx(7.5) # stale value still sitting there + assert not w.checkbox_laser_af.isChecked() + + captured = {} + + def fake_question(parent, title, text, *args, **kwargs): + captured["text"] = text + return QMessageBox.Yes + + with patch("control.widgets.QMessageBox.question", side_effect=fake_question): + w.toggle_acquisition(True) + + assert "+0.0" in captured["text"] + assert "+7.5" not in captured["text"] + ctrl.run_acquisition.assert_called_once() + + +def test_toggle_acquisition_confirm_no_aborts_start(qtbot, simulated_widget_deps): + """Answering No to the confirmation dialog must NOT start the acquisition, + and must leave the Start button unchecked.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_recording_widget(qtbot, simulated_widget_deps) + w.btn_startAcquisition.setChecked(True) + + with patch("control.widgets.QMessageBox.question", return_value=QMessageBox.No): + w.toggle_acquisition(True) + + ctrl.run_acquisition.assert_not_called() + assert not w.btn_startAcquisition.isChecked() + + +def test_toggle_acquisition_no_confirm_when_recording_disabled(qtbot, simulated_widget_deps): + """When the Recording phase is not enabled, no confirmation dialog should + appear at all -- Start proceeds straight through as before.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_widget(qtbot, simulated_widget_deps) # zstack-only, recording off + assert not w.checkbox_recording.isChecked() + + def fail_if_called(*args, **kwargs): + raise AssertionError("QMessageBox.question must not be called when recording is disabled") + + with patch("control.widgets.QMessageBox.question", side_effect=fail_if_called): + w.toggle_acquisition(True) + + ctrl.run_acquisition.assert_called_once() + + +def test_recording_bottom_z_tooltip_matches_caption(qtbot, simulated_widget_deps): + """The entry_recording_bottom_z tooltip must track the same multi/single + wording as its label caption (label_recording_bottom_z), so it doesn't go + stale (e.g. still saying "Bottom plane offset" when Nz == 1 and the label + already reverted to "Z offset:").""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + # Default is Nz == 1 (single plane). + assert w.entry_recording_Nz.value() == 1 + assert w.label_recording_bottom_z.text() == "Z offset:" + assert w.entry_recording_bottom_z.toolTip() == "Offset relative to the Z reference" + + w.entry_recording_Nz.setValue(3) + assert w.label_recording_bottom_z.text() == "Bottom Z offset:" + assert w.entry_recording_bottom_z.toolTip() == "Bottom plane offset relative to the Z reference" + + w.entry_recording_Nz.setValue(1) + assert w.label_recording_bottom_z.text() == "Z offset:" + assert w.entry_recording_bottom_z.toolTip() == "Offset relative to the Z reference" + + +# --------------------------------------------------------------------------- +# New tests added by fix-batch3 +# --------------------------------------------------------------------------- + + +# --- IMPORTANT 9b: frame_count < 1 rejected --- + + +def test_validate_helper_recording_zero_frame_count(): + """fps × duration rounds to 0 frames — must be rejected.""" + # 0.1 fps × 1.0 s = 0.1 → round → 0 frames + params = _base_params(recording_enabled=True, fps=0.1, duration_s=1.0, zstack_enabled=False) + err = _validate_record_zstack_params(**params) + assert err is not None + assert "0 frames" in err or "frame" in err.lower() + + +def test_validate_helper_recording_one_frame_is_valid(): + """fps × duration = exactly 1 frame — must pass.""" + # 1.0 fps × 1.0 s = 1 frame + params = _base_params(recording_enabled=True, fps=1.0, duration_s=1.0, zstack_enabled=False) + assert _validate_record_zstack_params(**params) is None + + +def test_validate_helper_recording_borderline_zero_frames(): + """fps × duration just below 0.5 → rounds to 0 — must be rejected.""" + params = _base_params(recording_enabled=True, fps=0.4, duration_s=1.0, zstack_enabled=False) + err = _validate_record_zstack_params(**params) + assert err is not None + + +# --- IMPORTANT 3+4: well count handles None / glass-slide --- + + +def test_get_selected_well_count_glass_slide_returns_one(qtbot, simulated_widget_deps): + """_get_selected_well_count returns 1 (not 0) when scanCoordinates.get_selected_wells() is None (glass-slide).""" + from unittest.mock import MagicMock + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + sc.get_selected_wells.return_value = None # glass-slide: returns None + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + assert w._get_selected_well_count() == 1 + + +def test_get_selected_well_count_empty_wellplate_returns_zero(qtbot, simulated_widget_deps): + """_get_selected_well_count returns 0 when no wells are selected on a wellplate.""" + from unittest.mock import MagicMock + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + sc.get_selected_wells.return_value = {} # wellplate, no wells selected + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + assert w._get_selected_well_count() == 0 + + +def test_get_selected_well_count_wellplate_selection(qtbot, simulated_widget_deps): + """_get_selected_well_count reflects current well_selector, not a stale cached widget.""" + from unittest.mock import MagicMock + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + sc.get_selected_wells.return_value = {"A1": (0.0, 0.0), "A2": (1.0, 0.0)} + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + assert w._get_selected_well_count() == 2 + + # Simulate a plate-format change that updates the scanCoordinates well_selector + sc.get_selected_wells.return_value = {"B1": (0.0, 1.0)} + assert w._get_selected_well_count() == 1 # picks up new selection, no stale cache + + +def test_validate_rejects_no_wells_selected(qtbot, simulated_widget_deps): + """validate() returns an error when no wells are selected (dict empty from scanCoordinates).""" + from unittest.mock import MagicMock + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + sc.get_selected_wells.return_value = {} # no wells + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.checkbox_zstack.setChecked(True) + w.entry_zmin.setValue(-1.0) + w.entry_zmax.setValue(1.0) + w.entry_step.setValue(1.0) + w._add_zstack_channel_row("BF LED matrix full") + + err = w.validate() + assert err is not None + assert "well" in err.lower() + + +# --- MEDIUM: _abort_event threading.Event path --- + + +def test_abort_event_set_on_request_abort(): + """request_abort() sets the abort event; run_acquisition() clears it before starting.""" + import threading + from unittest.mock import MagicMock, patch + + ctrl_kwargs = dict( + microscope=MagicMock(), + live_controller=MagicMock(), + laser_autofocus_controller=MagicMock(), + objective_store=MagicMock(), + scan_coordinates=MagicMock(), + callbacks=MagicMock(), + ) + + with patch("control._def.Acquisition.USE_MULTIPROCESSING", False): + from control.core.record_zstack_controller import RecordZStackController + + ctrl = RecordZStackController(**ctrl_kwargs) + + # Event should start clear + assert not ctrl._abort_event.is_set() + + ctrl.request_abort() + assert ctrl._abort_event.is_set() + + # Simulate run_acquisition clearing the event before spawning the worker + ctrl._abort_event.clear() + assert not ctrl._abort_event.is_set() + + +# --------------------------------------------------------------------------- +# Fix-batch5: signal_acquisition_started wiring +# --------------------------------------------------------------------------- + + +def test_signal_acquisition_started_emits_true_on_start(qtbot, simulated_widget_deps): + """signal_acquisition_started(True) is emitted when toggle_acquisition(True) succeeds.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_widget(qtbot, simulated_widget_deps) + + emitted = [] + w.signal_acquisition_started.connect(emitted.append) + + w.toggle_acquisition(True) + + assert emitted == [True] + + +def test_signal_acquisition_started_not_emitted_on_invalid_start(qtbot, simulated_widget_deps): + """signal_acquisition_started must NOT emit when validation rejects the start.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.checkbox_zstack.setChecked(False) + w.checkbox_recording.setChecked(False) + + emitted = [] + w.signal_acquisition_started.connect(emitted.append) + + with patch("control.widgets.QMessageBox.warning"): + w.toggle_acquisition(True) + + assert emitted == [] + + +def test_signal_acquisition_started_emits_false_on_finish(qtbot, simulated_widget_deps): + """signal_acquisition_started(False) is emitted when acquisition_is_finished() is called.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_widget(qtbot, simulated_widget_deps) + + emitted = [] + w.signal_acquisition_started.connect(emitted.append) + + w.acquisition_is_finished() + + assert emitted == [False] + + +def test_signal_acquisition_started_emitted_before_run_acquisition(qtbot, simulated_widget_deps): + """emit(True) must happen BEFORE run_acquisition() spawns the worker thread. + + Otherwise a fast/one-frame acquisition could finish (emit False) before this + widget reaches the emit(True) line, leaving the UI permanently locked. + """ + events = [] + + ctrl = _make_stub_controller() + ctrl.run_acquisition.side_effect = lambda *a, **k: events.append("run") + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_widget(qtbot, simulated_widget_deps) + w.signal_acquisition_started.connect(lambda started: events.append(f"emit({started})")) + + w.toggle_acquisition(True) + + # The True emit must come first, then run_acquisition. + assert events == ["emit(True)", "run"] + + +def test_signal_acquisition_started_emits_false_when_run_raises(qtbot, simulated_widget_deps): + """If run_acquisition() raises, the UI is unlocked: emit(True) then emit(False).""" + ctrl = _make_stub_controller() + ctrl.run_acquisition.side_effect = RuntimeError("boom starting worker") + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_widget(qtbot, simulated_widget_deps) + + emitted = [] + w.signal_acquisition_started.connect(emitted.append) + + w.toggle_acquisition(True) + + assert emitted == [True, False] + # Button must be un-checked after a failed start. + assert not w.btn_startAcquisition.isChecked() + + +# --------------------------------------------------------------------------- +# FOV-grid wiring tests (entry_scan_size / entry_overlap / combobox_shape) +# --------------------------------------------------------------------------- + + +def test_entry_scan_size_exists(qtbot, simulated_widget_deps): + """entry_scan_size widget is created on the widget.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + assert hasattr(w, "entry_scan_size") + + +def test_fov_grid_wired_overlap_calls_set_well_coordinates(qtbot, simulated_widget_deps): + """Changing entry_overlap triggers scanCoordinates.set_well_coordinates with correct args.""" + from unittest.mock import MagicMock + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + sc.reset_mock() + w.entry_overlap.setValue(20.0) + + sc.set_well_coordinates.assert_called() + call_args = sc.set_well_coordinates.call_args + _scan_size_mm, overlap_pct, _shape = call_args.args + assert overlap_pct == pytest.approx(20.0) + + +def test_fov_grid_wired_scan_size_calls_set_well_coordinates(qtbot, simulated_widget_deps): + """Changing entry_scan_size triggers scanCoordinates.set_well_coordinates with correct args.""" + from unittest.mock import MagicMock + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + sc.reset_mock() + w.entry_scan_size.setValue(2.5) + + sc.set_well_coordinates.assert_called() + call_args = sc.set_well_coordinates.call_args + scan_size_mm, _overlap_pct, _shape = call_args.args + assert scan_size_mm == pytest.approx(2.5) + + +def test_fov_grid_wired_shape_calls_set_well_coordinates(qtbot, simulated_widget_deps): + """Changing combobox_shape triggers scanCoordinates.set_well_coordinates with correct shape.""" + from unittest.mock import MagicMock + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + sc.reset_mock() + w.combobox_shape.setCurrentText("Circle") + + sc.set_well_coordinates.assert_called() + call_args = sc.set_well_coordinates.call_args + _scan_size_mm, _overlap_pct, shape = call_args.args + assert shape == "Circle" + + +def test_fov_grid_wired_set_well_coordinates_receives_all_three_args(qtbot, simulated_widget_deps): + """_update_scan_regions passes scan_size_mm, overlap_percent, shape to set_well_coordinates.""" + from unittest.mock import MagicMock + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.entry_scan_size.setValue(1.5) + w.entry_overlap.setValue(15.0) + w.combobox_shape.setCurrentText("Rectangle") + + sc.reset_mock() + w._update_scan_regions() + + sc.set_well_coordinates.assert_called_once_with(pytest.approx(1.5), pytest.approx(15.0), "Rectangle") + + +def test_fov_grid_clears_regions_before_set_well_coordinates(qtbot, simulated_widget_deps): + """_update_scan_regions must clear_regions() BEFORE set_well_coordinates(). + + Regression guard: set_well_coordinates only adds wells not already present, so + without clearing first, already-selected wells keep their old tile geometry and + a new size/overlap/shape is silently ignored. + """ + from unittest.mock import MagicMock, call + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + sc.has_regions.return_value = True # there is an existing region to clear + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + sc.reset_mock() + sc.has_regions.return_value = True + w._update_scan_regions() + + # clear_regions must be called, and must precede set_well_coordinates. + sc.clear_regions.assert_called_once() + sc.set_well_coordinates.assert_called_once() + relevant = [c for c in sc.method_calls if c[0] in ("clear_regions", "set_well_coordinates")] + assert relevant[0] == call.clear_regions() + assert relevant[1][0] == "set_well_coordinates" + + +def test_fov_grid_no_crash_without_scan_coordinates(qtbot, simulated_widget_deps): + """_update_scan_regions is a no-op when scanCoordinates is None.""" + from control.widgets import RecordZStackMultiPointWidget + + simulated_widget_deps["scanCoordinates"] = None + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + # Must not raise + w._update_scan_regions() + + +def test_toggle_acquisition_calls_update_scan_regions_before_run(qtbot, simulated_widget_deps): + """toggle_acquisition calls _update_scan_regions before run_acquisition.""" + from unittest.mock import MagicMock + + sc = MagicMock() + # Non-empty well selection so validate() passes (no modal warning dialog). + sc.get_selected_wells.return_value = {"A1": (0.0, 0.0)} + simulated_widget_deps["scanCoordinates"] = sc + + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + + w = _make_valid_widget(qtbot, simulated_widget_deps) + w.scanCoordinates = sc + + sc.reset_mock() + ctrl.reset_mock() + ctrl.acquisition_in_progress.return_value = False + + call_order = [] + sc.set_well_coordinates.side_effect = lambda *a: call_order.append("set_well_coordinates") + ctrl.run_acquisition.side_effect = lambda *a: call_order.append("run_acquisition") + + w.toggle_acquisition(True) + + assert "set_well_coordinates" in call_order + assert "run_acquisition" in call_order + assert call_order.index("set_well_coordinates") < call_order.index("run_acquisition") + + +def test_refresh_channel_list_repopulates_combos(qtbot, simulated_widget_deps): + """Channel sets are per-objective: after an objective/profile change the + combos must repopulate, or stale names silently fall back to a bare + channel with no illumination source (dark acquisition).""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.liveController.get_channels.return_value = [ + _make_channel("New Channel A"), + _make_channel("New Channel B"), + ] + w.refresh_channel_list() + + rec_names = [w._recording_ch_combo.itemText(i) for i in range(w._recording_ch_combo.count())] + add_names = [w.combobox_zstack_add_channel.itemText(i) for i in range(w.combobox_zstack_add_channel.count())] + assert rec_names == ["New Channel A", "New Channel B"] + assert add_names == ["New Channel A", "New Channel B"] + + +def test_refresh_channel_list_drops_stale_zstack_rows(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w._add_zstack_channel_row("BF LED matrix full") # valid for the old objective + + w.liveController.get_channels.return_value = [_make_channel("New Channel A")] + w.refresh_channel_list() + + assert "BF LED matrix full" not in w._zstack_channel_names + + +def test_refresh_channel_list_preserves_state_on_failure(qtbot, simulated_widget_deps): + """Round-2: a transient get_channels failure (or empty result) during an + objective/profile switch must NOT wipe the user's configured z-stack rows + and channel combos — keep the existing lists until a successful refresh.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w._add_zstack_channel_row("BF LED matrix full") + combo_count_before = w._recording_ch_combo.count() + + w.liveController.get_channels.side_effect = RuntimeError("config repo hiccup") + w.refresh_channel_list() + assert w._zstack_channel_names == ["BF LED matrix full"], "rows wiped on transient failure" + assert w._recording_ch_combo.count() == combo_count_before, "combo wiped on transient failure" + + w.liveController.get_channels.side_effect = None + w.liveController.get_channels.return_value = [] + w.refresh_channel_list() + assert w._zstack_channel_names == ["BF LED matrix full"], "rows wiped on empty channel list" + assert w._recording_ch_combo.count() == combo_count_before + + +def test_on_well_selection_changed_rebuilds_regions(qtbot, simulated_widget_deps): + """R7: well clicks while this tab is current must rebuild the FOV grid so + the navigation viewer shows scan coverage (wellplate's handler early-returns + for other tabs, leaving this tab's preview inert).""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + sc = w.scanCoordinates + sc.reset_mock() + + w.on_well_selection_changed() + + assert sc.set_well_coordinates.called, "well selection change did not rebuild the FOV grid" + + +def test_refresh_channel_list_warns_on_silent_selection_swap(qtbot, simulated_widget_deps, caplog): + """R8: when the previously selected recording channel vanishes after an + objective/profile change, the combo falls back to the first channel — that + swap must be loud, or the next Start records the wrong channel.""" + import logging + + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + # Select the second channel, then refresh with a set that lacks it. + w._recording_ch_combo.setCurrentIndex(1) + prev = w._recording_ch_combo.currentText() + w.liveController.get_channels.return_value = [_make_channel("Only Channel")] + + with caplog.at_level(logging.WARNING): + w.refresh_channel_list() + + assert w._recording_ch_combo.currentText() == "Only Channel" + assert any( + prev in rec.message and "Only Channel" in rec.message for rec in caplog.records + ), f"no warning about the recording selection changing from {prev!r}" + + +def test_refresh_channel_list_preserves_user_edited_recording_settings(qtbot, simulated_widget_deps): + """R9: refresh_channel_list() must not silently reset the user's manually + edited recording exposure/gain/illumination when the selected channel is + still available afterward — even though repopulating the combo transiently + fires currentIndexChanged for other channels along the way.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w._recording_exp_spin.setValue(999.0) + w._recording_gain_spin.setValue(77.0) + w._recording_illum_spin.setValue(88.0) + + # Same channel list, same selection still available (e.g. an objective + # switch that doesn't change the configured channels). + w.refresh_channel_list() + + assert w._recording_exposure() == pytest.approx(999.0) + assert w._recording_gain() == pytest.approx(77.0) + assert w._recording_illumination() == pytest.approx(88.0) + + +# --------------------------------------------------------------------------- +# Channel-add seeds from the channel's configured live-controller settings +# (instead of the hardcoded 50 ms / 0 gain / 50% defaults). +# --------------------------------------------------------------------------- + + +def test_zstack_add_channel_seeds_from_channel_config(qtbot, simulated_widget_deps): + """Clicking + Add on a channel must seed its row from that channel's own + configured exposure/gain/illumination, not the hardcoded (50, 0, 50).""" + from control.widgets import RecordZStackMultiPointWidget + + simulated_widget_deps["liveController"].get_channels.return_value = [ + _make_live_channel("Fluorescence 488 nm Ex", exposure=123.0, gain=4.0, intensity=77.0), + ] + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.checkbox_zstack.setChecked(True) # unchecked group box disables its children + w.combobox_zstack_add_channel.setCurrentText("Fluorescence 488 nm Ex") + w.btn_zstack_add_channel.click() + + exposure, gain, illum = w._get_zstack_row_values("Fluorescence 488 nm Ex") + assert exposure == pytest.approx(123.0) + assert gain == pytest.approx(4.0) + assert illum == pytest.approx(77.0) + + +def test_recording_row_seeds_from_channel_config_on_construction(qtbot, simulated_widget_deps): + """The recording table's initial row must reflect the first channel's own + configured settings, not the hardcoded (50, 0, 50) defaults.""" + from control.widgets import RecordZStackMultiPointWidget + + simulated_widget_deps["liveController"].get_channels.return_value = [ + _make_live_channel("BF LED matrix full", exposure=12.0, gain=1.5, intensity=33.0), + _make_live_channel("Fluorescence 488 nm Ex", exposure=123.0, gain=4.0, intensity=77.0), + ] + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + assert w._recording_channel_name() == "BF LED matrix full" + assert w._recording_exposure() == pytest.approx(12.0) + assert w._recording_gain() == pytest.approx(1.5) + assert w._recording_illumination() == pytest.approx(33.0) + + +def test_recording_row_seeds_from_channel_config_on_selection_change(qtbot, simulated_widget_deps): + """Switching the recording channel combo must reseed exposure/gain/illum + from the newly selected channel's own configured settings.""" + from control.widgets import RecordZStackMultiPointWidget + + simulated_widget_deps["liveController"].get_channels.return_value = [ + _make_live_channel("BF LED matrix full", exposure=12.0, gain=1.5, intensity=33.0), + _make_live_channel("Fluorescence 488 nm Ex", exposure=123.0, gain=4.0, intensity=77.0), + ] + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w._recording_ch_combo.setCurrentText("Fluorescence 488 nm Ex") + + assert w._recording_exposure() == pytest.approx(123.0) + assert w._recording_gain() == pytest.approx(4.0) + assert w._recording_illumination() == pytest.approx(77.0) + + +# --------------------------------------------------------------------------- +# XY / Time tabbed row (mirrors WellplateMultiPointWidget, skipping Z). +# --------------------------------------------------------------------------- + + +def test_checkbox_xy_exists_and_defaults_checked(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + assert w.checkbox_xy.isChecked() is True + assert w.combobox_xy_mode.isEnabled() is True + # isHidden() reflects the explicit show/hide flag; isVisible() would be + # False regardless since the top-level widget itself is never shown(). + assert w.xy_controls_frame.isHidden() is False + + +def test_unchecking_xy_hides_scan_grid_controls(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.checkbox_xy.setChecked(False) + assert w.xy_controls_frame.isHidden() is True + assert w.combobox_xy_mode.isEnabled() is False + + w.checkbox_xy.setChecked(True) + assert w.xy_controls_frame.isHidden() is False + assert w.combobox_xy_mode.isEnabled() is True + + +def test_combobox_xy_mode_has_current_position_and_select_wells(qtbot, simulated_widget_deps): + """The XY mode combo offers both modes, defaulting to Select Wells so + existing tiling behavior is unchanged out of the box.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + items = [w.combobox_xy_mode.itemText(i) for i in range(w.combobox_xy_mode.count())] + assert items == ["Current Position", "Select Wells"] + assert w.combobox_xy_mode.currentText() == "Select Wells" + + +def test_selecting_current_position_mode_hides_scan_grid_controls(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.combobox_xy_mode.setCurrentText("Current Position") + assert w.xy_controls_frame.isHidden() is True + + w.combobox_xy_mode.setCurrentText("Select Wells") + assert w.xy_controls_frame.isHidden() is False + + +def test_current_position_mode_uses_live_stage_position(qtbot, simulated_widget_deps): + """Current Position mode must build a single region at the live stage + position via add_region, bypassing well selection entirely.""" + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + sc.has_regions.return_value = False + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + sc.set_well_coordinates.reset_mock() # construction seeds the default Select Wells mode + + w.combobox_xy_mode.setCurrentText("Current Position") + + sc.add_region.assert_called_with("current", 1.5, 2.5, 0.01, 0, "Square") + sc.set_well_coordinates.assert_not_called() + + +def test_unchecking_xy_forces_current_position_and_restores_on_recheck(qtbot, simulated_widget_deps): + """Mirrors WellplateMultiPointWidget: unchecking XY forces Current + Position (single stage-position FOV) and disables the mode combo; + re-checking restores whatever mode was previously selected.""" + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + sc.has_regions.return_value = False + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.checkbox_xy.setChecked(False) + assert w.combobox_xy_mode.currentText() == "Current Position" + assert w.combobox_xy_mode.isEnabled() is False + sc.add_region.assert_called_with("current", 1.5, 2.5, 0.01, 0, "Square") + + w.checkbox_xy.setChecked(True) + assert w.combobox_xy_mode.currentText() == "Select Wells" + assert w.combobox_xy_mode.isEnabled() is True + + +def test_toggling_xy_rebuilds_scan_region_exactly_once(qtbot, simulated_widget_deps): + """Toggling XY changes combobox_xy_mode's text, which already triggers + _update_scan_regions() via _on_xy_mode_changed — _on_xy_toggled must not + also call it unconditionally, or the region gets rebuilt twice (including + an extra stage.get_pos() query) per toggle.""" + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + sc.has_regions.return_value = False + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.stage.get_pos.reset_mock() + w.checkbox_xy.setChecked(False) + w.stage.get_pos.assert_called_once() + + w.stage.get_pos.reset_mock() + sc.set_well_coordinates.reset_mock() + w.checkbox_xy.setChecked(True) + sc.set_well_coordinates.assert_called_once() + + +def test_validate_current_position_mode_bypasses_well_selection(qtbot, simulated_widget_deps): + """With no wells selected, Current Position mode must still validate + (it doesn't need any wells), while Select Wells mode still requires one.""" + from control.widgets import RecordZStackMultiPointWidget + + sc = MagicMock() + sc.get_selected_wells.return_value = {} + simulated_widget_deps["scanCoordinates"] = sc + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.checkbox_zstack.setChecked(True) + w.entry_zmin.setValue(-1.0) + w.entry_zmax.setValue(1.0) + w.entry_step.setValue(1.0) + w._add_zstack_channel_row("BF LED matrix full") + + assert w.validate() is not None # Select Wells (default) with 0 wells: still rejected + + w.combobox_xy_mode.setCurrentText("Current Position") + assert w.validate() is None + + +# --------------------------------------------------------------------------- +# Time tabbed row (mirrors WellplateMultiPointWidget). +# --------------------------------------------------------------------------- + + +def test_checkbox_time_exists_and_defaults_unchecked(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + assert w.checkbox_time.isChecked() is False + assert w.time_controls_frame.isHidden() is True + + +def test_checking_time_shows_nt_dt_controls(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.checkbox_time.setChecked(True) + assert w.time_controls_frame.isHidden() is False + + +def test_unchecking_time_resets_and_restores_nt_dt(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w.checkbox_time.setChecked(True) + w.entry_Nt.setValue(5) + w.entry_dt.setValue(10.0) + + w.checkbox_time.setChecked(False) + assert w.entry_Nt.value() == 1 + assert w.entry_dt.value() == pytest.approx(0.0) + assert w.time_controls_frame.isHidden() is True + + w.checkbox_time.setChecked(True) + assert w.entry_Nt.value() == 5 + assert w.entry_dt.value() == pytest.approx(10.0) + + +# --------------------------------------------------------------------------- +# AcquisitionYAMLDropMixin integration (Task 7) +# --------------------------------------------------------------------------- + + +def test_apply_yaml_settings_round_trips_all_fields(qtbot, simulated_widget_deps): + from control.acquisition_yaml_loader import RecordZStackYAMLData + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + yaml_data = RecordZStackYAMLData( + widget_type="record_zstack", + xy_mode="Current Position", + nt=4, + delta_t_s=2.5, + laser_af=True, + recording_enabled=True, + recording_channel={ + "name": "BF LED matrix full", + "camera_settings": {"exposure_time_ms": 33.0, "gain_mode": 1.0}, + "illumination_settings": {"intensity": 60.0}, + }, + fps=25.0, + duration_s=3.0, + recording_bottom_z_offset_um=2.0, + recording_nz=3, + recording_dz_um=1.5, + zstack_enabled=True, + zstack_channels=[ + { + "name": "Fluorescence 488 nm Ex", + "camera_settings": {"exposure_time_ms": 80.0, "gain_mode": 0.5}, + "illumination_settings": {"intensity": 40.0}, + } + ], + z_min_um=-4.0, + z_max_um=4.0, + z_step_um=2.0, + scan_size_mm=2.0, + overlap_percent=15.0, + ) + + w._apply_yaml_settings(yaml_data) + + assert w.entry_Nt.value() == 4 + assert w.entry_dt.value() == pytest.approx(2.5) + assert w.checkbox_laser_af.isChecked() is True + assert w.checkbox_recording.isChecked() is True + assert w._recording_channel_name() == "BF LED matrix full" + assert w._recording_exposure() == pytest.approx(33.0) + assert w._recording_gain() == pytest.approx(1.0) + assert w._recording_illumination() == pytest.approx(60.0) + assert w.entry_fps.value() == pytest.approx(25.0) + assert w.entry_duration.value() == pytest.approx(3.0) + assert w.entry_recording_bottom_z.value() == pytest.approx(2.0) + assert w.entry_recording_Nz.value() == 3 + assert w.entry_recording_dz.value() == pytest.approx(1.5) + assert w.checkbox_zstack.isChecked() is True + assert w._zstack_channel_names == ["Fluorescence 488 nm Ex"] + assert w._get_zstack_row_values("Fluorescence 488 nm Ex") == pytest.approx((80.0, 0.5, 40.0)) + assert w.entry_zmin.value() == pytest.approx(-4.0) + assert w.entry_zmax.value() == pytest.approx(4.0) + assert w.entry_step.value() == pytest.approx(2.0) + assert w.combobox_xy_mode.currentText() == "Current Position" + assert w.entry_scan_size.value() == pytest.approx(2.0) + assert w.entry_overlap.value() == pytest.approx(15.0) + assert w.checkbox_time.isChecked() is True + + +def test_apply_yaml_settings_unknown_recording_channel_keeps_existing_row(qtbot, simulated_widget_deps, caplog): + """Fix Round 1 / Finding 1: when the YAML's recording channel name isn't in the + current combo (e.g. objective changed, or the channel was renamed/removed since + the YAML was saved), the exposure/gain/illumination spinboxes must NOT be + silently paired with a channel that doesn't match the combo's selection.""" + import logging + + from control.acquisition_yaml_loader import RecordZStackYAMLData + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + # Pre-set the recording row to known, distinct values that must survive untouched. + w._recording_ch_combo.setCurrentIndex(0) + pre_channel_name = w._recording_ch_combo.currentText() + w._recording_exp_spin.setValue(11.0) + w._recording_gain_spin.setValue(2.0) + w._recording_illum_spin.setValue(22.0) + + yaml_data = RecordZStackYAMLData( + widget_type="record_zstack", + recording_enabled=True, + recording_channel={ + "name": "Channel Not In Combo", + "camera_settings": {"exposure_time_ms": 99.0, "gain_mode": 9.0}, + "illumination_settings": {"intensity": 99.0}, + }, + ) + + with caplog.at_level(logging.WARNING): + w._apply_yaml_settings(yaml_data) + + # Combo selection and spinbox values are unchanged -- no name/value mismatch. + assert w._recording_ch_combo.currentText() == pre_channel_name + assert w._recording_exp_spin.value() == pytest.approx(11.0) + assert w._recording_gain_spin.value() == pytest.approx(2.0) + assert w._recording_illum_spin.value() == pytest.approx(22.0) + assert any( + "Channel Not In Combo" in rec.message for rec in caplog.records + ), "expected a warning naming the missing recording channel" + + +def test_apply_yaml_settings_checks_time_checkbox_and_shows_frame(qtbot, simulated_widget_deps): + """Fix Round 1 / Finding 2: loading a multi-timepoint YAML (nt > 1) must check + checkbox_time and make the time-controls frame visible immediately, not just + update entry_Nt/entry_dt under the hood.""" + from control.acquisition_yaml_loader import RecordZStackYAMLData + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + # Sanity check on the default (unchecked) state before loading. + assert w.checkbox_time.isChecked() is False + assert w.time_controls_frame.isHidden() is True + + yaml_data = RecordZStackYAMLData(widget_type="record_zstack", nt=4, delta_t_s=2.0) + + w._apply_yaml_settings(yaml_data) + + assert w.checkbox_time.isChecked() is True + assert w.entry_Nt.value() == 4 + assert w.entry_dt.value() == pytest.approx(2.0) + assert w.time_controls_frame.isHidden() is False + + +def test_apply_yaml_settings_refreshes_time_tab_styling(qtbot, simulated_widget_deps): + """Fix Round 2: loading a multi-timepoint YAML (nt > 1) must also refresh the + Time tab's stylesheet (border/background), not just the checkbox state and + frame visibility. checkbox_time.toggled is blocked during the load, so the + normal _on_time_toggled -> _update_tab_styles path never fires; the fix calls + _update_tab_styles() directly in the finally block. Compare against a second + widget where checkbox_time is toggled normally (unblocked) to avoid hardcoding + the expected stylesheet string.""" + from control.acquisition_yaml_loader import RecordZStackYAMLData + from control.widgets import RecordZStackMultiPointWidget + + w_loaded = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w_loaded) + + yaml_data = RecordZStackYAMLData(widget_type="record_zstack", nt=4, delta_t_s=2.0) + w_loaded._apply_yaml_settings(yaml_data) + + # Reference widget: toggle checkbox_time normally (signals not blocked), so + # _on_time_toggled -> _update_tab_styles runs through its ordinary path. + w_reference = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w_reference) + w_reference.checkbox_time.setChecked(True) + + assert w_reference.checkbox_time.isChecked() is True + assert w_loaded.time_frame.styleSheet() == w_reference.time_frame.styleSheet() + assert w_loaded.time_controls_frame.styleSheet() == w_reference.time_controls_frame.styleSheet() + # Guard against both sides trivially being empty strings (which would make + # the equality assertions above vacuous rather than a real regression check). + assert w_reference.time_frame.styleSheet() != "" + assert w_reference.time_controls_frame.styleSheet() != "" + + +def test_apply_yaml_settings_resyncs_xy_controls_frame_visibility(qtbot, simulated_widget_deps): + """Final-review Finding 1: like checkbox_time's toggled signal, checkbox_xy's + toggled signal (and combobox_xy_mode's currentTextChanged) are blocked during + the load, so the normal _on_xy_mode_changed-driven visibility refresh never + fires. Loading a YAML with xy_mode="Current Position" onto a widget that starts + in its default state (XY checked, Select Wells, controls visible) must still + hide xy_controls_frame and disable combobox_xy_mode's peer state correctly.""" + from control.acquisition_yaml_loader import RecordZStackYAMLData + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + # Sanity check on the default state before loading. + assert w.checkbox_xy.isChecked() is True + assert w.combobox_xy_mode.currentText() == "Select Wells" + assert w.xy_controls_frame.isHidden() is False + + yaml_data = RecordZStackYAMLData(widget_type="record_zstack", xy_mode="Current Position") + + w._apply_yaml_settings(yaml_data) + + assert w.combobox_xy_mode.currentText() == "Current Position" + # The FOV overlap/shape/size controls don't apply in Current Position mode. + assert w.xy_controls_frame.isHidden() is True + + +def test_apply_yaml_settings_resyncs_recording_and_zstack_section_visibility(qtbot, simulated_widget_deps): + """Code-review finding: like checkbox_time/checkbox_xy, checkbox_recording's + and checkbox_zstack's toggled signals are blocked during the load, so the + collapse-when-unchecked visibility wiring in _build_recording_group / + _build_zstack_group never fires on its own. Loading a YAML that flips both + phases from the widget's default state (Recording on, Z-Stack off) must + still collapse Recording's content and expand Z-Stack's.""" + from control.acquisition_yaml_loader import RecordZStackYAMLData + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + # Sanity check on the default state before loading. + assert w.checkbox_recording.isChecked() is True + assert w.recording_channel_table.isHidden() is False + assert w.checkbox_zstack.isChecked() is False + assert w.entry_zmin.isHidden() is True + + yaml_data = RecordZStackYAMLData(widget_type="record_zstack", recording_enabled=False, zstack_enabled=True) + + w._apply_yaml_settings(yaml_data) + + assert w.checkbox_recording.isChecked() is False + assert w.recording_channel_table.isHidden() is True + assert w.checkbox_zstack.isChecked() is True + assert w.entry_zmin.isHidden() is False + + +def test_start_button_disabled_when_neither_phase_enabled(qtbot, simulated_widget_deps): + """Start Acquisition must be grayed out (instead of only erroring on click) + when neither Recording nor Z-Stack is enabled.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + # Recording is checked by default, so the button starts enabled. + assert w.checkbox_recording.isChecked() is True + assert w.btn_startAcquisition.isEnabled() is True + + w.checkbox_recording.setChecked(False) + assert w.checkbox_zstack.isChecked() is False + assert w.btn_startAcquisition.isEnabled() is False + + w.checkbox_zstack.setChecked(True) + assert w.btn_startAcquisition.isEnabled() is True + + # Both enabled must also stay enabled. + w.checkbox_recording.setChecked(True) + assert w.btn_startAcquisition.isEnabled() is True + + # Back down to neither. + w.checkbox_recording.setChecked(False) + w.checkbox_zstack.setChecked(False) + assert w.btn_startAcquisition.isEnabled() is False + + +def test_apply_yaml_settings_resyncs_start_button_enabled(qtbot, simulated_widget_deps): + """checkbox_recording's/checkbox_zstack's toggled signals are blocked during + YAML load (see test_apply_yaml_settings_resyncs_recording_and_zstack_section_visibility), + so the Start Acquisition enabled-state resync needs the same explicit fix-up.""" + from control.acquisition_yaml_loader import RecordZStackYAMLData + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + assert w.btn_startAcquisition.isEnabled() is True + + yaml_data = RecordZStackYAMLData(widget_type="record_zstack", recording_enabled=False, zstack_enabled=False) + w._apply_yaml_settings(yaml_data) + + assert w.checkbox_recording.isChecked() is False + assert w.checkbox_zstack.isChecked() is False + assert w.btn_startAcquisition.isEnabled() is False + + +def test_get_expected_widget_type_is_record_zstack(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + assert w._get_expected_widget_type() == "record_zstack" + + +def test_get_camera_for_binning_check_uses_live_controller(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + camera = object() + simulated_widget_deps["liveController"].camera = camera + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + assert w._get_camera_for_binning_check() is camera + + +def test_save_settings_button_writes_yaml(qtbot, simulated_widget_deps, tmp_path, monkeypatch): + """Verify that clicking Save Settings runs the REAL save chain end to end: + _on_save_settings_clicked -> build_parameters -> _build_objective_info -> + _save_record_zstack_yaml -> a real YAML file on disk. Only QFileDialog is mocked + (real file dialogs can't run in tests). + + simulated_widget_deps's stub objectiveStore/liveController are plain MagicMocks + with no objectives_dict/camera attributes configured, so the real + _build_objective_info() would otherwise produce MagicMock leaf values (e.g. for + magnification/pixel_size_um) that yaml.dump() cannot serialize -- a failure that + _save_record_zstack_yaml() swallows internally (logs and returns) via its own + pre-existing try/except. Hardening just these two stubs (not the shared fixture) + gives _build_objective_info() cleanly-serializable values so the real save runs + for real and actually writes the file. + """ + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText(str(tmp_path)) + w.checkbox_zstack.setChecked(True) + w._add_zstack_channel_row("BF LED matrix full") + + # Harden this test's own objectiveStore/camera stubs so _build_objective_info() + # returns real, YAML-serializable values instead of MagicMock leaves. + w.objectiveStore.objectives_dict = {"10x": {"magnification": 10.0}} + w.objectiveStore.current_objective = "10x" + w.objectiveStore.get_pixel_size_factor.return_value = 1.0 + + camera = MagicMock() + camera.get_binning.return_value = (1, 1) + camera.get_pixel_size_binned_um.return_value = 0.5 + w.liveController.camera = camera + + save_path = tmp_path / "my_preset.yaml" + monkeypatch.setattr("control.widgets.QFileDialog.getSaveFileName", lambda *a, **kw: (str(save_path), "")) + + w.btn_saveSettings.click() + + # Verify the REAL file was written by the REAL _save_record_zstack_yaml call. + assert save_path.exists() + import yaml as pyyaml + + data = pyyaml.safe_load(save_path.read_text()) + assert data["acquisition"]["widget_type"] == "record_zstack" + assert data["z_stack"]["enabled"] is True + assert data["z_stack"]["channels"][0]["name"] == "BF LED matrix full" + assert data["objective"]["name"] == "10x" + assert data["objective"]["magnification"] == pytest.approx(10.0) + assert data["objective"]["pixel_size_um"] == pytest.approx(0.5) + assert data["objective"]["camera_binning"] == [1, 1] + + +def test_save_settings_button_shows_warning_when_write_fails(qtbot, simulated_widget_deps, tmp_path, monkeypatch): + """Final-review Finding 3: when the underlying _save_record_zstack_yaml() write + genuinely fails, _on_save_settings_clicked()'s own try/except must reach the user + via QMessageBox.warning -- it must NOT log "Settings saved" and silently show no + warning. Mirrors test_save_settings_button_writes_yaml's structure but points the + save at a path inside a non-existent directory so the real open() raises.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText(str(tmp_path)) + w.checkbox_zstack.setChecked(True) + w._add_zstack_channel_row("BF LED matrix full") + + w.objectiveStore.objectives_dict = {"10x": {"magnification": 10.0}} + w.objectiveStore.current_objective = "10x" + w.objectiveStore.get_pixel_size_factor.return_value = 1.0 + + camera = MagicMock() + camera.get_binning.return_value = (1, 1) + camera.get_pixel_size_binned_um.return_value = 0.5 + w.liveController.camera = camera + + # Parent directory doesn't exist -> the real open() call raises OSError. + save_path = tmp_path / "does_not_exist" / "my_preset.yaml" + monkeypatch.setattr("control.widgets.QFileDialog.getSaveFileName", lambda *a, **kw: (str(save_path), "")) + + with patch("control.widgets.QMessageBox.warning") as mock_warn: + w.btn_saveSettings.click() + + assert not save_path.exists() + mock_warn.assert_called_once() + assert mock_warn.call_args[0][1] == "Save Error" + + +def test_load_settings_button_applies_yaml(qtbot, simulated_widget_deps, tmp_path, monkeypatch): + """Verify that clicking Load Settings runs the REAL load chain end to end: + _on_load_settings_clicked -> _load_acquisition_yaml -> parse_acquisition_yaml -> + validate_hardware -> _apply_yaml_settings. Only QFileDialog is mocked (real file + dialogs can't run in tests); a real, valid record_zstack YAML file is written to + disk and actually read back. + + The YAML has no objective:/camera_binning keys, so yaml_data.objective_name and + yaml_data.camera_binning are both falsy and validate_hardware() reports + is_valid=True -- no mismatch dialog blocks the test. + """ + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + yaml_path = tmp_path / "preset.yaml" + yaml_path.write_text( + "acquisition:\n" + " widget_type: record_zstack\n" + " xy_mode: Current Position\n" + "time_series:\n" + " nt: 7\n" + " delta_t_s: 1.0\n" + ) + + monkeypatch.setattr("control.widgets.QFileDialog.getOpenFileName", lambda *a, **kw: (str(yaml_path), "")) + + w.btn_loadSettings.click() + + # Verify settings were applied via the REAL parse -> validate -> apply chain. + assert w.entry_Nt.value() == 7 + assert w.entry_dt.value() == pytest.approx(1.0) + assert w.combobox_xy_mode.currentText() == "Current Position" + + +def test_load_acquisition_yaml_malformed_channel_shows_warning_not_exception(qtbot, simulated_widget_deps, tmp_path): + """Final-review Finding 2: _apply_yaml_settings() calls + AcquisitionChannel.model_validate() on recording_channel/zstack_channels with no + guard. A YAML with valid syntax/widget_type but a malformed channel dict (here, + missing the required camera_settings field) must not crash the drop/button slot + with an uncaught pydantic ValidationError -- the shared + AcquisitionYAMLDropMixin._load_acquisition_yaml() must catch it, log, warn, and + return False, exactly like the existing YAML-parse-error path a few lines above it.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + yaml_path = tmp_path / "malformed.yaml" + yaml_path.write_text( + "acquisition:\n" + " widget_type: record_zstack\n" + " xy_mode: Current Position\n" + "recording:\n" + " enabled: true\n" + " channel:\n" + " name: Foo\n" # missing required camera_settings -> pydantic ValidationError + ) + + with patch("control.widgets.QMessageBox.warning") as mock_warn: + result = w._load_acquisition_yaml(str(yaml_path)) + + assert result is False + mock_warn.assert_called_once() + assert mock_warn.call_args[0][1] == "Load Error" + + +def test_full_save_load_round_trip_preserves_settings(qtbot, simulated_widget_deps, tmp_path): + """Save via build_parameters()+_save_record_zstack_yaml, load into a fresh widget, + and confirm the fresh widget's build_parameters() output matches exactly (excluding base_path/experiment_id). + + Tests round-trip serialization and deserialization via YAML for all user-facing settings: + - recording channel and its numeric settings (exposure_time, analog_gain, illumination_intensity) + - z-stack channels and their numeric settings + - acquisition parameters (FPS, duration, z-range, XY mode) + - multi-plane recording fields (Laser AF on, Nz, dz, bottom-Z offset) -- build_parameters() + forces recording_bottom_z_offset_um to 0.0 when Laser AF is off (see + _update_recording_planes_ui), so Laser AF must be enabled for the bottom-Z value to + round-trip meaningfully. + """ + from control.core.record_zstack_controller import _save_record_zstack_yaml + from control.acquisition_yaml_loader import parse_acquisition_yaml + from control.widgets import RecordZStackMultiPointWidget + + w1 = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w1) + w1.lineEdit_savingDir.setText(str(tmp_path)) + w1.checkbox_recording.setChecked(True) + w1.checkbox_laser_af.setChecked(True) + w1.entry_fps.setValue(30.0) + w1.entry_duration.setValue(4.0) + w1.entry_recording_Nz.setValue(3) + w1.entry_recording_dz.setValue(2.5) + w1.entry_recording_bottom_z.setValue(-4.0) + w1.checkbox_zstack.setChecked(True) + w1._add_zstack_channel_row("Fluorescence 488 nm Ex", exposure=80.0, gain=1.0, illumination=45.0) + w1.entry_zmin.setValue(-5.0) + w1.entry_zmax.setValue(5.0) + w1.entry_step.setValue(2.5) + w1.combobox_xy_mode.setCurrentText("Current Position") + + params1 = w1.build_parameters() + yaml_path = tmp_path / "roundtrip.yaml" + _save_record_zstack_yaml(params1, str(yaml_path)) + + yaml_data = parse_acquisition_yaml(str(yaml_path)) + + w2 = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w2) + w2._apply_yaml_settings(yaml_data) + params2 = w2.build_parameters() + + # Acquisition control parameters + assert params2.recording_enabled == params1.recording_enabled + assert params2.fps == pytest.approx(params1.fps) + assert params2.duration_s == pytest.approx(params1.duration_s) + assert params2.zstack_enabled == params1.zstack_enabled + assert params2.z_min_um == pytest.approx(params1.z_min_um) + assert params2.z_max_um == pytest.approx(params1.z_max_um) + assert params2.z_step_um == pytest.approx(params1.z_step_um) + assert params2.xy_mode == params1.xy_mode + + # Recording channel (enabled above, so should be non-None) + assert params2.recording_channel == params1.recording_channel + + # Multi-plane recording fields (Laser AF on, Nz/dz/bottom-Z offset) + assert params2.use_laser_af == params1.use_laser_af + assert params2.recording_Nz == params1.recording_Nz + assert params2.recording_dz_um == pytest.approx(params1.recording_dz_um) + assert params2.recording_bottom_z_offset_um == pytest.approx(params1.recording_bottom_z_offset_um) + + # Z-stack channels: check channel names and numeric settings + assert [c.name for c in params2.zstack_channels] == [c.name for c in params1.zstack_channels] + assert len(params2.zstack_channels) == len(params1.zstack_channels) + for ch2, ch1 in zip(params2.zstack_channels, params1.zstack_channels): + assert ch2.exposure_time == pytest.approx(ch1.exposure_time) + assert ch2.analog_gain == pytest.approx(ch1.analog_gain) + assert ch2.illumination_intensity == pytest.approx(ch1.illumination_intensity) + + +def test_validate_helper_recording_nz_and_dz(): + params = _base_params(recording_enabled=True, recording_nz=0) + assert _validate_record_zstack_params(**params) is not None # Nz < 1 + params = _base_params(recording_enabled=True, recording_nz=3, recording_dz_um=0.0) + assert _validate_record_zstack_params(**params) is not None # dz <= 0 with Nz > 1 + params = _base_params(recording_enabled=True, recording_nz=1, recording_dz_um=0.0) + assert _validate_record_zstack_params(**params) is None # Nz=1: dz irrelevant + params = _base_params(recording_enabled=True, recording_nz=3, recording_dz_um=0.5) + assert _validate_record_zstack_params(**params) is None + + +def test_recording_dz_hidden_when_nz_is_one(qtbot, simulated_widget_deps): + """dz must be HIDDEN (not just disabled) when Nz == 1 — user requirement.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + assert w.entry_recording_Nz.value() == 1 + assert w.entry_recording_dz.isHidden() + assert w.label_recording_dz.isHidden() + + w.entry_recording_Nz.setValue(3) + assert not w.entry_recording_dz.isHidden() + assert not w.label_recording_dz.isHidden() + + w.entry_recording_Nz.setValue(1) + assert w.entry_recording_dz.isHidden() + assert w.label_recording_dz.isHidden() + + +def test_collapsed_recording_section_stays_collapsed(qtbot, simulated_widget_deps): + """With the Recording phase unchecked its fields are hidden, and changing Laser AF or Nz + does not bring the Z offset or dz back; expanding shows the fields those settings call for.""" + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.checkbox_recording.setChecked(False) + assert w.entry_fps.isHidden() + + w.checkbox_laser_af.setChecked(True) + assert w.entry_recording_bottom_z.isHidden() + assert w.label_recording_bottom_z.isHidden() + + w.entry_recording_Nz.setValue(3) + assert w.entry_recording_dz.isHidden() + assert w.label_recording_dz.isHidden() + + w.checkbox_recording.setChecked(True) + assert not w.entry_recording_bottom_z.isHidden() + assert not w.label_recording_bottom_z.isHidden() + assert not w.entry_recording_dz.isHidden() + assert not w.label_recording_dz.isHidden() + + # Expanding does not show what the settings do not call for. + w.checkbox_recording.setChecked(False) + w.checkbox_laser_af.setChecked(False) + w.entry_recording_Nz.setValue(1) + w.checkbox_recording.setChecked(True) + assert not w.entry_fps.isHidden() + assert w.entry_recording_bottom_z.isHidden() + assert w.entry_recording_dz.isHidden() + + +def test_apply_yaml_settings_keeps_a_disabled_recording_section_collapsed(qtbot, simulated_widget_deps): + """Loading settings with the recording phase off, Laser AF on and Nz > 1 leaves the + Recording section collapsed, Z offset and dz included.""" + from control.acquisition_yaml_loader import RecordZStackYAMLData + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + + w._apply_yaml_settings( + RecordZStackYAMLData( + widget_type="record_zstack", recording_enabled=False, zstack_enabled=True, laser_af=True, recording_nz=3 + ) + ) + + assert w.checkbox_recording.isChecked() is False + assert w.checkbox_laser_af.isChecked() is True + assert w.entry_recording_Nz.value() == 3 + assert w.entry_fps.isHidden() + assert w.entry_recording_bottom_z.isHidden() + assert w.label_recording_bottom_z.isHidden() + assert w.entry_recording_dz.isHidden() + assert w.label_recording_dz.isHidden() + + +def test_recording_offset_caption_and_build_parameters(qtbot, simulated_widget_deps): + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.checkbox_recording.setChecked(True) + w.entry_duration.setValue(2.0) + w.entry_recording_bottom_z.setValue(-2.0) + w.entry_recording_Nz.setValue(3) + w.entry_recording_dz.setValue(4.0) + + # Without Laser AF there is no reference plane to offset from: the field + # is hidden and its (stale) value must not reach the parameters. + assert not w.checkbox_laser_af.isChecked() + assert w.entry_recording_bottom_z.isHidden() + assert w.label_recording_bottom_z.isHidden() + assert w.build_parameters().recording_bottom_z_offset_um == 0.0 + + w.checkbox_laser_af.setChecked(True) + assert not w.entry_recording_bottom_z.isHidden() + assert not w.label_recording_bottom_z.isHidden() + + # Caption uses multi-plane wording when Nz > 1 and reverts at Nz == 1. + assert w.label_recording_bottom_z.text() == "Bottom Z offset:" + w.entry_recording_Nz.setValue(1) + assert w.label_recording_bottom_z.text() == "Z offset:" + w.entry_recording_Nz.setValue(3) + + params = w.build_parameters() + assert params.recording_bottom_z_offset_um == -2.0 + assert params.recording_Nz == 3 + assert params.recording_dz_um == 4.0 + + +def test_validate_wires_recording_nz_dz(qtbot, simulated_widget_deps, monkeypatch): + """Widget.validate() must forward the recording_nz/recording_dz_um spinbox + values to the helper. The spinbox minimums (Nz>=1, dz>0) make invalid + values unreachable through the UI, so asserting only ``validate() is + None`` would still pass even if those kwargs were silently dropped from + the call. Intercept the helper instead and assert the actual kwargs it + receives.""" + import control.core.record_zstack_controller as rzc_mod + from control.widgets import RecordZStackMultiPointWidget + + w = RecordZStackMultiPointWidget(**simulated_widget_deps) + qtbot.addWidget(w) + w.lineEdit_savingDir.setText("/tmp/test") + w.checkbox_recording.setChecked(True) + w.checkbox_zstack.setChecked(False) + w.entry_recording_Nz.setValue(3) + w.entry_recording_dz.setValue(4.0) + + # validate() imports the helper from record_zstack_controller at call time, + # so patch it there. + real_validate = rzc_mod.validate_record_zstack_params + captured = {} + + def spy(**kwargs): + captured.update(kwargs) + return real_validate(**kwargs) + + monkeypatch.setattr(rzc_mod, "validate_record_zstack_params", spy) + + result = w.validate() + + assert result is None + assert captured.get("recording_nz") == 3 + assert captured.get("recording_dz_um") == 4.0 + + +def test_toggle_acquisition_confirm_warns_when_camera_cannot_reach_requested_fps(qtbot, simulated_widget_deps): + """The camera's own max-rate estimate for the recording exposure goes into the confirmation + dialog when the user asked for more, with the rate and frame count the run will really use.""" + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + camera = MagicMock() + camera.get_max_frame_rate.return_value = 28.0 + simulated_widget_deps["liveController"].camera = camera + + w = _make_valid_recording_widget(qtbot, simulated_widget_deps) + w.entry_fps.setValue(100.0) + w.entry_duration.setValue(3.0) + + captured = {} + + def fake_question(parent, title, text, *args, **kwargs): + captured["text"] = text + return QMessageBox.Yes + + with patch("control.widgets.QMessageBox.question", side_effect=fake_question): + w.toggle_acquisition(True) + + assert "at most 28.0 fps, not 100" in captured["text"] + assert "28.0 fps (84 frames for 3 s)" in captured["text"] + camera.get_max_frame_rate.assert_called_once_with(w._recording_exposure()) + ctrl.run_acquisition.assert_called_once() + + +def test_toggle_acquisition_confirm_has_no_limit_note_when_rate_is_reachable(qtbot, simulated_widget_deps): + ctrl = _make_stub_controller() + simulated_widget_deps["recordZStackController"] = ctrl + camera = MagicMock() + camera.get_max_frame_rate.return_value = 28.0 + simulated_widget_deps["liveController"].camera = camera + + w = _make_valid_recording_widget(qtbot, simulated_widget_deps) + w.entry_fps.setValue(10.0) + captured = {} + + def fake_question(parent, title, text, *args, **kwargs): + captured["text"] = text + return QMessageBox.Yes + + with patch("control.widgets.QMessageBox.question", side_effect=fake_question): + w.toggle_acquisition(True) + + assert "Camera limit" not in captured["text"] + ctrl.run_acquisition.assert_called_once() + + +# --------------------------------------------------------------------------- +# Zero exposure: rounded to 0.0 by a one-decimal spinbox, then rejected by the camera mid-run +# (ToupTek "The parameter is incorrect"), aborting the acquisition with the z-stack half written. +# --------------------------------------------------------------------------- + + +def test_validate_helper_rejects_zero_recording_exposure(): + from control.core.record_zstack_controller import validate_record_zstack_params + + err = validate_record_zstack_params(**_base_params(recording_enabled=True, recording_exposure_ms=0.0)) + assert err is not None and "exposure" in err and "BF LED matrix full" in err + + +def test_validate_helper_rejects_zero_zstack_exposure_naming_the_channel(): + from control.core.record_zstack_controller import validate_record_zstack_params + + params = _base_params( + zstack_channel_names=["Fluorescence 488 nm Ex", "Fluorescence 405 nm Ex"], + zstack_exposures_ms={"Fluorescence 488 nm Ex": 30.0, "Fluorescence 405 nm Ex": 0.0}, + ) + err = validate_record_zstack_params(**params) + assert err is not None and "Fluorescence 405 nm Ex" in err and "exposure" in err + + +def test_validate_helper_accepts_positive_exposures(): + from control.core.record_zstack_controller import validate_record_zstack_params + + params = _base_params( + recording_enabled=True, recording_exposure_ms=0.1, zstack_exposures_ms={"BF LED matrix full": 0.1} + ) + assert validate_record_zstack_params(**params) is None + + +def test_exposure_editors_cannot_hold_zero(qtbot, simulated_widget_deps): + w = _make_valid_widget(qtbot, simulated_widget_deps) + w._recording_exp_spin.setValue(0.0) + assert w._recording_exposure() > 0 + w._set_zstack_row_values("BF LED matrix full", 0.0, 0.0, 50.0) + assert w._get_zstack_row_values("BF LED matrix full")[0] > 0 + + +def test_validate_rejects_zero_zstack_exposure(qtbot, simulated_widget_deps, monkeypatch): + """A zero exposure that reaches the table anyway (e.g. from a settings file) is refused before Start.""" + w = _make_valid_widget(qtbot, simulated_widget_deps) + monkeypatch.setattr(w, "_get_zstack_row_values", lambda name: (0.0, 0.0, 50.0)) + err = w.validate() + assert err is not None and "exposure" in err