diff --git a/software/control/_def.py b/software/control/_def.py index 182a0683e..66ce23c14 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -577,6 +577,26 @@ def is_piezo_only(self) -> bool: return self == ZMotorConfig.PIEZO +class WellShape(Enum): + """Well shape for sample format definitions.""" + + CIRCULAR = "circular" + SQUARE = "square" + RECTANGULAR = "rectangular" + + @property + def is_round(self): + return self == WellShape.CIRCULAR + + @staticmethod + def from_str(value): + """Convert string to WellShape, defaulting to CIRCULAR.""" + for member in WellShape: + if member.value == value: + return member + return WellShape.CIRCULAR + + PRINT_CAMERA_FPS = True ########################################################### @@ -1129,16 +1149,33 @@ def read_sample_formats_csv(file_path): sample_formats = {} with open(file_path, "r") as csvfile: reader = csv.DictReader(csvfile) + fieldnames = reader.fieldnames or [] + is_old_format = "well_spacing_mm" in fieldnames and "well_spacing_x_mm" not in fieldnames for row in reader: format_ = str(row["format"]) format_key = f"{format_} well plate" if format_.isdigit() else format_ + if is_old_format: + size = float(row["well_size_mm"]) + spacing = float(row["well_spacing_mm"]) + well_size_x_mm = well_size_y_mm = size + well_spacing_x_mm = well_spacing_y_mm = spacing + well_shape = WellShape.CIRCULAR + else: + well_size_x_mm = float(row["well_size_x_mm"]) + well_size_y_mm = float(row["well_size_y_mm"]) + well_spacing_x_mm = float(row["well_spacing_x_mm"]) + well_spacing_y_mm = float(row["well_spacing_y_mm"]) + well_shape = WellShape.from_str(row["well_shape"]) sample_formats[format_key] = { "a1_x_mm": float(row["a1_x_mm"]), "a1_y_mm": float(row["a1_y_mm"]), "a1_x_pixel": int(row["a1_x_pixel"]), "a1_y_pixel": int(row["a1_y_pixel"]), - "well_size_mm": float(row["well_size_mm"]), - "well_spacing_mm": float(row["well_spacing_mm"]), + "well_size_x_mm": well_size_x_mm, + "well_size_y_mm": well_size_y_mm, + "well_spacing_x_mm": well_spacing_x_mm, + "well_spacing_y_mm": well_spacing_y_mm, + "well_shape": well_shape, "number_of_skip": int(row["number_of_skip"]), "rows": int(row["rows"]), "cols": int(row["cols"]), @@ -1184,8 +1221,11 @@ def get_wellplate_settings(wellplate_format): "a1_y_mm": 0, "a1_x_pixel": 0, "a1_y_pixel": 0, - "well_size_mm": 0, - "well_spacing_mm": 0, + "well_size_x_mm": 0, + "well_size_y_mm": 0, + "well_spacing_x_mm": 0, + "well_spacing_y_mm": 0, + "well_shape": WellShape.CIRCULAR, "number_of_skip": 0, "rows": 1, "cols": 1, @@ -1358,8 +1398,11 @@ class SlackNotifications: NUMBER_OF_SKIP = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT][ "number_of_skip" ] # num rows/cols to skip on wellplate edge -WELL_SIZE_MM = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["well_size_mm"] -WELL_SPACING_MM = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["well_spacing_mm"] +WELL_SIZE_X_MM = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["well_size_x_mm"] +WELL_SIZE_Y_MM = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["well_size_y_mm"] +WELL_SPACING_X_MM = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["well_spacing_x_mm"] +WELL_SPACING_Y_MM = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["well_spacing_y_mm"] +WELL_SHAPE = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["well_shape"] A1_X_MM = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["a1_x_mm"] # measured stage position - to update A1_Y_MM = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["a1_y_mm"] # measured stage position - to update A1_X_PIXEL = WELLPLATE_FORMAT_SETTINGS[WELLPLATE_FORMAT]["a1_x_pixel"] # coordinate on the png diff --git a/software/control/core/core.py b/software/control/core/core.py index ad1fa2526..8a96f74ae 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -1335,8 +1335,11 @@ def __init__(self, objectivestore, camera, sample="glass slide", invertX=False, self.sample = sample self.objectiveStore = objectivestore self.camera = camera - self.well_size_mm = WELL_SIZE_MM - self.well_spacing_mm = WELL_SPACING_MM + self.well_size_x_mm = WELL_SIZE_X_MM + self.well_size_y_mm = WELL_SIZE_Y_MM + self.well_spacing_x_mm = WELL_SPACING_X_MM + self.well_spacing_y_mm = WELL_SPACING_Y_MM + self.well_shape = WELL_SHAPE self.number_of_skip = NUMBER_OF_SKIP self.a1_x_mm = A1_X_MM self.a1_y_mm = A1_Y_MM @@ -1492,8 +1495,11 @@ def update_wellplate_settings( a1_y_mm, a1_x_pixel, a1_y_pixel, - well_size_mm, - well_spacing_mm, + well_size_x_mm, + well_size_y_mm, + well_spacing_x_mm, + well_spacing_y_mm, + well_shape, number_of_skip, rows, cols, @@ -1514,8 +1520,11 @@ def update_wellplate_settings( self.a1_y_mm = a1_y_mm self.a1_x_pixel = a1_x_pixel self.a1_y_pixel = a1_y_pixel - self.well_size_mm = well_size_mm - self.well_spacing_mm = well_spacing_mm + self.well_size_x_mm = well_size_x_mm + self.well_size_y_mm = well_size_y_mm + self.well_spacing_x_mm = well_spacing_x_mm + self.well_spacing_y_mm = well_spacing_y_mm + self.well_shape = WellShape.from_str(well_shape) if isinstance(well_shape, str) else well_shape self.number_of_skip = number_of_skip self.rows = rows self.cols = cols diff --git a/software/control/core/geometry_utils.py b/software/control/core/geometry_utils.py index e1d38f54a..f05eb8627 100644 --- a/software/control/core/geometry_utils.py +++ b/software/control/core/geometry_utils.py @@ -7,28 +7,35 @@ import math -def get_effective_well_size(well_size_mm, fov_size_mm, shape, is_round_well=True): +def get_effective_well_size(well_size_x_mm, well_size_y_mm, fov_size_mm, shape, is_round_well=True): """Calculate the default scan size for a well based on shape. Args: - well_size_mm: Well diameter (round) or side length (square) + well_size_x_mm: Well X dimension (or diameter for round wells) + well_size_y_mm: Well Y dimension (same as X for round wells) fov_size_mm: Field of view size in mm shape: Scan shape ("Circle", "Square", or "Rectangle") - is_round_well: True for round wells, False for square wells + is_round_well: True for round wells, False for rectangular wells Returns: - Effective scan size in mm that provides ~100% coverage + Effective scan size — scalar for round wells or circle scan, + tuple (x, y) for rectangular wells with non-circle scan. """ - if shape == "Circle": - return well_size_mm + fov_size_mm * (1 + math.sqrt(2)) - elif shape == "Square" and is_round_well: - # Inscribed square side length = diameter / sqrt(2) - return well_size_mm / math.sqrt(2) - elif shape == "Rectangle" and is_round_well: - # Rectangle with 0.6 aspect ratio inscribed in circle - # h = diameter / sqrt(1 + 0.6²) = diameter / sqrt(1.36) - return well_size_mm / math.sqrt(1.36) - return well_size_mm + if is_round_well: + diameter = well_size_x_mm + if shape == "Circle": + return diameter + fov_size_mm * (1 + math.sqrt(2)) + elif shape == "Square": + return diameter / math.sqrt(2) + elif shape == "Rectangle": + return diameter / math.sqrt(1.36) + return diameter + else: + # Rectangular well — return tuple (x, y) for per-axis scan sizes + if shape == "Circle": + return math.sqrt(well_size_x_mm**2 + well_size_y_mm**2) + fov_size_mm * (1 + math.sqrt(2)) + else: + return (well_size_x_mm, well_size_y_mm) def get_tile_positions(scan_size_mm, fov_size_mm, overlap_percent, shape): @@ -92,7 +99,9 @@ def get_tile_positions(scan_size_mm, fov_size_mm, overlap_percent, shape): return tiles if tiles else [(0, 0)] -def calculate_well_coverage(scan_size_mm, fov_size_mm, overlap_percent, shape, well_size_mm, is_round_well=True): +def calculate_well_coverage( + scan_size_mm, fov_size_mm, overlap_percent, shape, well_size_x_mm, well_size_y_mm=None, is_round_well=True +): """Calculate what fraction of the well is covered by FOV tiles. Uses grid sampling to determine coverage. @@ -102,40 +111,46 @@ def calculate_well_coverage(scan_size_mm, fov_size_mm, overlap_percent, shape, w fov_size_mm: Field of view size in mm overlap_percent: Overlap between adjacent tiles (%) shape: Scan shape ("Circle", "Square", or "Rectangle") - well_size_mm: Well diameter (round) or side length (square) - is_round_well: True for round wells, False for square wells + well_size_x_mm: Well X dimension (or diameter for round wells) + well_size_y_mm: Well Y dimension (defaults to well_size_x_mm for backward compat) + is_round_well: True for round wells, False for rectangular wells Returns: Coverage percentage (0-100) """ + if well_size_y_mm is None: + well_size_y_mm = well_size_x_mm + step_size = fov_size_mm * (1 - overlap_percent / 100) - if step_size <= 0 or scan_size_mm <= 0 or well_size_mm <= 0: + if step_size <= 0 or scan_size_mm <= 0 or well_size_x_mm <= 0 or well_size_y_mm <= 0: return 0 tiles = get_tile_positions(scan_size_mm, fov_size_mm, overlap_percent, shape) if not tiles: return 0 - well_radius = well_size_mm / 2 + well_half_x = well_size_x_mm / 2 + well_half_y = well_size_y_mm / 2 fov_half = fov_size_mm / 2 # Grid sampling to calculate coverage resolution = 100 covered = 0 total = 0 - step = 2 * well_radius / (resolution - 1) if resolution > 1 else 0 + step_x = 2 * well_half_x / (resolution - 1) if resolution > 1 else 0 + step_y = 2 * well_half_y / (resolution - 1) if resolution > 1 else 0 for i in range(resolution): for j in range(resolution): - x = -well_radius + step * i - y = -well_radius + step * j + x = -well_half_x + step_x * i + y = -well_half_y + step_y * j # Check if point is inside well if is_round_well: - if x * x + y * y > well_radius * well_radius: + if x * x + y * y > well_half_x * well_half_x: continue else: - if abs(x) > well_radius or abs(y) > well_radius: + if abs(x) > well_half_x or abs(y) > well_half_y: continue total += 1 diff --git a/software/control/core/scan_coordinates.py b/software/control/core/scan_coordinates.py index 8bfb16765..184ce48fc 100644 --- a/software/control/core/scan_coordinates.py +++ b/software/control/core/scan_coordinates.py @@ -66,8 +66,11 @@ def __init__( self.a1_y_mm = control._def.A1_Y_MM self.wellplate_offset_x_mm = control._def.WELLPLATE_OFFSET_X_mm self.wellplate_offset_y_mm = control._def.WELLPLATE_OFFSET_Y_mm - self.well_spacing_mm = control._def.WELL_SPACING_MM - self.well_size_mm = control._def.WELL_SIZE_MM + self.well_spacing_x_mm = control._def.WELL_SPACING_X_MM + self.well_spacing_y_mm = control._def.WELL_SPACING_Y_MM + self.well_size_x_mm = control._def.WELL_SIZE_X_MM + self.well_size_y_mm = control._def.WELL_SIZE_Y_MM + self.well_shape = control._def.WELL_SHAPE self.a1_x_pixel = None self.a1_y_pixel = None self.number_of_skip = None @@ -81,15 +84,31 @@ def add_well_selector(self, well_selector): self.well_selector = well_selector def update_wellplate_settings( - self, format_, a1_x_mm, a1_y_mm, a1_x_pixel, a1_y_pixel, size_mm, spacing_mm, number_of_skip + self, + format_, + a1_x_mm, + a1_y_mm, + a1_x_pixel, + a1_y_pixel, + size_x_mm, + size_y_mm, + spacing_x_mm, + spacing_y_mm, + well_shape, + number_of_skip, + rows=None, + cols=None, ): self.format = format_ self.a1_x_mm = a1_x_mm self.a1_y_mm = a1_y_mm self.a1_x_pixel = a1_x_pixel self.a1_y_pixel = a1_y_pixel - self.well_size_mm = size_mm - self.well_spacing_mm = spacing_mm + self.well_size_x_mm = size_x_mm + self.well_size_y_mm = size_y_mm + self.well_spacing_x_mm = spacing_x_mm + self.well_spacing_y_mm = spacing_y_mm + self.well_shape = control._def.WellShape.from_str(well_shape) if isinstance(well_shape, str) else well_shape self.number_of_skip = number_of_skip def _index_to_row(self, index): @@ -123,8 +142,8 @@ def get_selected_wells(self): if _increasing == False: columns = np.flip(columns) for column in columns: - x_mm = self.a1_x_mm + (column * self.well_spacing_mm) + self.wellplate_offset_x_mm - y_mm = self.a1_y_mm + (row * self.well_spacing_mm) + self.wellplate_offset_y_mm + x_mm = self.a1_x_mm + (column * self.well_spacing_x_mm) + self.wellplate_offset_x_mm + y_mm = self.a1_y_mm + (row * self.well_spacing_y_mm) + self.wellplate_offset_y_mm well_id = self._index_to_row(row) + str(column + 1) well_centers[well_id] = (x_mm, y_mm) _increasing = not _increasing @@ -135,7 +154,7 @@ def set_live_scan_coordinates(self, x_mm, y_mm, scan_size_mm, overlap_percent, s self.clear_regions() self.add_region("current", x_mm, y_mm, scan_size_mm, overlap_percent, shape) - def set_well_coordinates(self, scan_size_mm, overlap_percent, shape): + def set_well_coordinates(self, scan_size_mm, overlap_percent, shape, scan_size_y_mm=None): new_region_centers = self.get_selected_wells() if self.format == "glass slide": @@ -151,7 +170,7 @@ def set_well_coordinates(self, scan_size_mm, overlap_percent, shape): # Add regions for selected wells for well_id, (x, y) in new_region_centers.items(): if well_id not in self.region_centers: - self.add_region(well_id, x, y, scan_size_mm, overlap_percent, shape) + self.add_region(well_id, x, y, scan_size_mm, overlap_percent, shape, scan_size_y_mm=scan_size_y_mm) else: self.clear_regions() @@ -178,13 +197,36 @@ def set_manual_coordinates(self, manual_shapes, overlap_percent): else: self._log.info("No Manual ROI found") - def add_region(self, well_id, center_x, center_y, scan_size_mm, overlap_percent=10, shape="Square"): + def add_region( + self, well_id, center_x, center_y, scan_size_mm, overlap_percent=10, shape="Square", scan_size_y_mm=None + ): """add region based on user inputs""" fov_size_mm = self.objectiveStore.get_pixel_size_factor() * self.camera.get_fov_size_mm() step_size_mm = fov_size_mm * (1 - overlap_percent / 100) scan_coordinates = [] - if shape == "Rectangle": + if scan_size_y_mm is not None and scan_size_y_mm != scan_size_mm: + # Per-axis scan: X and Y have different sizes (rectangular wells) + width_mm = scan_size_mm + height_mm = scan_size_y_mm + + steps_width = max(1, math.floor(width_mm / step_size_mm)) + steps_height = max(1, math.floor(height_mm / step_size_mm)) + + half_steps_width = (steps_width - 1) / 2 + half_steps_height = (steps_height - 1) / 2 + + for i in range(steps_height): + row = [] + y = center_y + (i - half_steps_height) * step_size_mm + for j in range(steps_width): + x = center_x + (j - half_steps_width) * step_size_mm + if self.validate_coordinates(x, y): + row.append((x, y)) + if self.fov_pattern == "S-Pattern" and i % 2 == 1: + row.reverse() + scan_coordinates.extend(row) + elif shape == "Rectangle": # Use scan_size_mm as height, width is 0.6 * height height_mm = scan_size_mm width_mm = scan_size_mm * 0.6 @@ -681,16 +723,25 @@ def get_scan_coordinates_from_selected_wells( wellplate_settings = control._def.get_wellplate_settings(wellplate_format) self.get_selected_well_coordinates(well_name, wellplate_settings) - if wellplate_format in ["384 well plate", "1536 well plate"]: + well_shape_value = wellplate_settings.get("well_shape", control._def.WellShape.CIRCULAR) + if isinstance(well_shape_value, str): + well_shape_value = control._def.WellShape.from_str(well_shape_value) + if not well_shape_value.is_round: well_shape = "Square" else: well_shape = "Circle" + scan_size_y_mm = None if scan_size_mm is None: - scan_size_mm = wellplate_settings["well_size_mm"] + scan_size_mm = wellplate_settings["well_size_x_mm"] + scan_size_y_mm = wellplate_settings["well_size_y_mm"] + if scan_size_y_mm == scan_size_mm: + scan_size_y_mm = None # No need for per-axis if equal for k, v in self.region_centers.items(): - coords = self.create_region_coordinates(v[0], v[1], scan_size_mm, overlap_percent, well_shape) + coords = self.create_region_coordinates( + v[0], v[1], scan_size_mm, overlap_percent, well_shape, scan_size_y_mm=scan_size_y_mm + ) self.region_fov_coordinates[k] = coords def get_selected_well_coordinates(self, well_names, wellplate_settings): @@ -734,35 +785,56 @@ def index_to_row(index): for col in cols: x_mm = ( wellplate_settings["a1_x_mm"] - + col * wellplate_settings["well_spacing_mm"] + + col * wellplate_settings["well_spacing_x_mm"] + control._def.WELLPLATE_OFFSET_X_mm ) y_mm = ( wellplate_settings["a1_y_mm"] - + row * wellplate_settings["well_spacing_mm"] + + row * wellplate_settings["well_spacing_y_mm"] + control._def.WELLPLATE_OFFSET_Y_mm ) self.region_centers[index_to_row(row) + str(col + 1)] = (x_mm, y_mm) else: x_mm = ( wellplate_settings["a1_x_mm"] - + start_col_index * wellplate_settings["well_spacing_mm"] + + start_col_index * wellplate_settings["well_spacing_x_mm"] + control._def.WELLPLATE_OFFSET_X_mm ) y_mm = ( wellplate_settings["a1_y_mm"] - + start_row_index * wellplate_settings["well_spacing_mm"] + + start_row_index * wellplate_settings["well_spacing_y_mm"] + control._def.WELLPLATE_OFFSET_Y_mm ) self.region_centers[start_row + start_col] = (x_mm, y_mm) else: raise ValueError(f"Invalid well format: {desc}. Expected format is 'A1' or 'A1:B2' for ranges.") - def create_region_coordinates(self, center_x, center_y, scan_size_mm, overlap_percent=10, shape="Square"): + def create_region_coordinates( + self, center_x, center_y, scan_size_mm, overlap_percent=10, shape="Square", scan_size_y_mm=None + ): fov_size_mm = self.camera.get_fov_size_mm() # We are not taking software cropping into account here. Need to fix it when we merge this into ScanCoordinates. step_size_mm = fov_size_mm * (1 - overlap_percent / 100) + # Per-axis scan for rectangular wells + if scan_size_y_mm is not None and scan_size_y_mm != scan_size_mm: + steps_x = max(1, math.floor(scan_size_mm / step_size_mm)) + steps_y = max(1, math.floor(scan_size_y_mm / step_size_mm)) + half_steps_x = (steps_x - 1) / 2 + half_steps_y = (steps_y - 1) / 2 + + scan_coordinates = [] + for i in range(steps_y): + row = [] + y = center_y + (i - half_steps_y) * step_size_mm + for j in range(steps_x): + x = center_x + (j - half_steps_x) * step_size_mm + row.append((x, y)) + if control._def.FOV_PATTERN == "S-Pattern" and i % 2 == 1: + row.reverse() + scan_coordinates.extend(row) + return scan_coordinates if scan_coordinates else [(center_x, center_y)] + steps = math.floor(scan_size_mm / step_size_mm) if shape == "Circle": tile_diagonal = math.sqrt(2) * fov_size_mm diff --git a/software/control/microscope_control_server.py b/software/control/microscope_control_server.py index 3f01cb2bf..52290650a 100644 --- a/software/control/microscope_control_server.py +++ b/software/control/microscope_control_server.py @@ -813,7 +813,9 @@ def _parse_wells(self, wells: str, wellplate_settings: dict) -> Dict[str, tuple] Args: wells: Well selection string (e.g., 'A1:B3' or 'A1,A2,B1'). - wellplate_settings: Dict with 'a1_x_mm', 'a1_y_mm', 'well_spacing_mm'. + wellplate_settings: Dict with 'a1_x_mm', 'a1_y_mm', 'well_spacing_x_mm', + 'well_spacing_y_mm'. Old keys 'well_spacing_mm' and 'well_size_mm' are + accepted for backward compatibility. Returns: Dict mapping well IDs to (x_mm, y_mm) coordinates. @@ -835,9 +837,18 @@ def index_to_row(index: int) -> str: index //= 26 return row + # Backward compat for old API clients + if "well_spacing_mm" in wellplate_settings and "well_spacing_x_mm" not in wellplate_settings: + wellplate_settings["well_spacing_x_mm"] = wellplate_settings["well_spacing_mm"] + wellplate_settings["well_spacing_y_mm"] = wellplate_settings["well_spacing_mm"] + if "well_size_mm" in wellplate_settings and "well_size_x_mm" not in wellplate_settings: + wellplate_settings["well_size_x_mm"] = wellplate_settings["well_size_mm"] + wellplate_settings["well_size_y_mm"] = wellplate_settings["well_size_mm"] + a1_x = wellplate_settings.get("a1_x_mm", 0) a1_y = wellplate_settings.get("a1_y_mm", 0) - spacing = wellplate_settings.get("well_spacing_mm", 9) + spacing_x = wellplate_settings.get("well_spacing_x_mm", 9) + spacing_y = wellplate_settings.get("well_spacing_y_mm", 9) well_coords = {} pattern = r"([A-Za-z]+)(\d+):?([A-Za-z]*)(\d*)" @@ -859,14 +870,14 @@ def index_to_row(index: int) -> str: for row_idx in range(start_row_idx, end_row_idx + 1): for col_idx in range(start_col_idx, end_col_idx + 1): well_id = index_to_row(row_idx) + str(col_idx + 1) - x_mm = a1_x + col_idx * spacing - y_mm = a1_y + row_idx * spacing + x_mm = a1_x + col_idx * spacing_x + y_mm = a1_y + row_idx * spacing_y well_coords[well_id] = (x_mm, y_mm) else: # Single well like A1 well_id = start_row.upper() + start_col - x_mm = a1_x + start_col_idx * spacing - y_mm = a1_y + start_row_idx * spacing + x_mm = a1_x + start_col_idx * spacing_x + y_mm = a1_y + start_row_idx * spacing_y well_coords[well_id] = (x_mm, y_mm) return well_coords diff --git a/software/control/ndviewer_light b/software/control/ndviewer_light index fbff5e405..46575caea 160000 --- a/software/control/ndviewer_light +++ b/software/control/ndviewer_light @@ -1 +1 @@ -Subproject commit fbff5e405654b6e5f59eaf74cbbab85a4780432f +Subproject commit 46575caead5f51352444b5017904e811c27b1d6c diff --git a/software/control/widgets.py b/software/control/widgets.py index 52323c74e..cc749babd 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -5129,13 +5129,16 @@ def setFormat(self, format_): settings = self.wellplateFormatWidget.getWellplateSettings(self.format) self.rows = settings["rows"] self.columns = settings["cols"] - self.spacing_mm = settings["well_spacing_mm"] + self.spacing_x_mm = settings["well_spacing_x_mm"] + self.spacing_y_mm = settings["well_spacing_y_mm"] self.number_of_skip = settings["number_of_skip"] self.a1_x_mm = settings["a1_x_mm"] self.a1_y_mm = settings["a1_y_mm"] self.a1_x_pixel = settings["a1_x_pixel"] self.a1_y_pixel = settings["a1_y_pixel"] - self.well_size_mm = settings["well_size_mm"] + self.well_size_x_mm = settings["well_size_x_mm"] + self.well_size_y_mm = settings["well_size_y_mm"] + self.well_shape = settings["well_shape"] self.setRowCount(self.rows) self.setColumnCount(self.columns) @@ -5245,8 +5248,8 @@ def onDoubleClick(self, row, col): if (row >= 0 + self.number_of_skip and row <= self.rows - 1 - self.number_of_skip) and ( col >= 0 + self.number_of_skip and col <= self.columns - 1 - self.number_of_skip ): - x_mm = col * self.spacing_mm + self.a1_x_mm + WELLPLATE_OFFSET_X_mm - y_mm = row * self.spacing_mm + self.a1_y_mm + WELLPLATE_OFFSET_Y_mm + x_mm = col * self.spacing_x_mm + self.a1_x_mm + WELLPLATE_OFFSET_X_mm + y_mm = row * self.spacing_y_mm + self.a1_y_mm + WELLPLATE_OFFSET_Y_mm self.signal_wellSelectedPos.emit(x_mm, y_mm) print("well location:", (x_mm, y_mm)) self.signal_wellSelected.emit(True) @@ -6901,6 +6904,13 @@ def add_components(self): self.entry_scan_size.setValue(0.1) self.entry_scan_size.setSuffix(" mm") + self.entry_scan_size_y = QDoubleSpinBox() + self.entry_scan_size_y.setKeyboardTracking(False) + self.entry_scan_size_y.setRange(0.1, 100) + self.entry_scan_size_y.setValue(0.1) + self.entry_scan_size_y.setSuffix(" mm") + self.entry_scan_size_y.setVisible(False) # Hidden by default; shown for rectangular wells with different X/Y + self.entry_overlap = QDoubleSpinBox() self.entry_overlap.setKeyboardTracking(False) self.entry_overlap.setRange(0, 99) @@ -7128,11 +7138,15 @@ def add_components(self): self.row_2_layout.addWidget(self.combobox_shape, 0, 1) self.row_2_layout.addWidget(self.scan_size_label, 0, 2) self.row_2_layout.addWidget(self.entry_scan_size, 0, 3) - self.row_2_layout.addWidget(self.coverage_label, 0, 4) - self.row_2_layout.addWidget(self.entry_well_coverage, 0, 5) + self.scan_size_y_label = QLabel("Y:") + self.scan_size_y_label.setVisible(False) + self.row_2_layout.addWidget(self.scan_size_y_label, 0, 4) + self.row_2_layout.addWidget(self.entry_scan_size_y, 0, 5) + self.row_2_layout.addWidget(self.coverage_label, 0, 6) + self.row_2_layout.addWidget(self.entry_well_coverage, 0, 7) self.row_2_layout.addWidget(self.fov_overlap_label, 1, 0) self.row_2_layout.addWidget(self.entry_overlap, 1, 1) - self.row_2_layout.addWidget(self.btn_save_scan_coordinates, 1, 2, 1, 4) + self.row_2_layout.addWidget(self.btn_save_scan_coordinates, 1, 2, 1, 6) self.xy_controls_frame.setLayout(self.row_2_layout) main_layout.addWidget(self.xy_controls_frame) @@ -7311,6 +7325,8 @@ def add_components(self): self.entry_overlap.valueChanged.connect(self.update_coverage_from_scan_size) self.entry_scan_size.valueChanged.connect(self.update_coordinates) self.entry_scan_size.valueChanged.connect(self.update_coverage_from_scan_size) + self.entry_scan_size_y.valueChanged.connect(self.update_coordinates) + self.entry_scan_size_y.valueChanged.connect(self.update_coverage_from_scan_size) # Coverage is read-only, derived from scan_size, FOV, and overlap self.combobox_shape.currentTextChanged.connect(self.on_shape_changed) self.checkbox_withAutofocus.toggled.connect(self.multipointController.set_af_flag) @@ -7742,6 +7758,7 @@ def update_scan_control_ui(self): self.combobox_shape.setVisible(False) self.scan_size_label.setVisible(False) self.entry_scan_size.setVisible(False) + self._show_per_axis_scan_size(False) self.coverage_label.setVisible(False) self.entry_well_coverage.setVisible(False) elif xy_mode == "Current Position": @@ -7750,6 +7767,7 @@ def update_scan_control_ui(self): self.combobox_shape.setVisible(True) self.scan_size_label.setVisible(True) self.entry_scan_size.setVisible(True) + self._show_per_axis_scan_size(False) # Per-axis not used in Current Position mode self.coverage_label.setVisible(True) self.entry_well_coverage.setVisible(True) elif xy_mode == "Select Wells": @@ -8142,6 +8160,7 @@ def set_default_scan_size(self): self._log.debug(f"Sample Format: {self.navigationViewer.sample}") self.combobox_shape.blockSignals(True) self.entry_scan_size.blockSignals(True) + self.entry_scan_size_y.blockSignals(True) self.set_default_shape() @@ -8152,8 +8171,12 @@ def set_default_scan_size(self): self.entry_scan_size.setEnabled(True) else: # Set scan_size to effective well size (100% coverage) - effective_well_size = self.get_effective_well_size() - self.entry_scan_size.setValue(round(effective_well_size, 3)) + effective = self.get_effective_well_size() + if isinstance(effective, tuple): + self.entry_scan_size.setValue(round(effective[0], 3)) + self.entry_scan_size_y.setValue(round(effective[1], 3)) + else: + self.entry_scan_size.setValue(round(effective, 3)) # Coverage is read-only, derive it from scan_size self.update_coverage_from_scan_size() @@ -8161,39 +8184,54 @@ def set_default_scan_size(self): self.combobox_shape.blockSignals(False) self.entry_scan_size.blockSignals(False) + self.entry_scan_size_y.blockSignals(False) else: # Update stored settings for "Select Wells" mode for use later. # Coverage is derived from scan_size, so we only store scan_size and shape. if "glass slide" not in self.navigationViewer.sample: - effective_well_size = self.get_effective_well_size() - scan_size = round(effective_well_size, 3) + effective = self.get_effective_well_size() + if isinstance(effective, tuple): + scan_size = round(effective[0], 3) + else: + scan_size = round(effective, 3) self.stored_xy_params["Select Wells"]["scan_size"] = scan_size else: # For glass slide, use default scan size self.stored_xy_params["Select Wells"]["scan_size"] = 0.1 self.stored_xy_params["Select Wells"]["scan_shape"] = ( - "Square" if self.scanCoordinates.format in ["384 well plate", "1536 well plate"] else "Circle" + "Square" if not self.scanCoordinates.well_shape.is_round else "Circle" ) # change scan size to single FOV if XY is checked and mode is "Current Position" if self.checkbox_xy.isChecked() and self.combobox_xy_mode.currentText() == "Current Position": self.entry_scan_size.setValue(0.1) + def _show_per_axis_scan_size(self, show): + """Show or hide the per-axis Y scan size spinbox and label.""" + self.entry_scan_size_y.setVisible(show) + self.scan_size_y_label.setVisible(show) + def set_default_shape(self): - if self.scanCoordinates.format in ["384 well plate", "1536 well plate"]: + is_non_circular = not self.scanCoordinates.well_shape.is_round + if is_non_circular: self.combobox_shape.setCurrentText("Square") - # elif self.scanCoordinates.format in ["4 slide"]: - # self.combobox_shape.setCurrentText("Rectangle") elif self.scanCoordinates.format != 0: self.combobox_shape.setCurrentText("Circle") + # Show per-axis scan size only for rectangular wells with different X/Y + show_per_axis = ( + self.scanCoordinates.well_shape == WellShape.RECTANGULAR + and self.scanCoordinates.well_size_x_mm != self.scanCoordinates.well_size_y_mm + ) + self._show_per_axis_scan_size(show_per_axis) def get_effective_well_size(self): - well_size = self.scanCoordinates.well_size_mm + well_size_x = self.scanCoordinates.well_size_x_mm + well_size_y = self.scanCoordinates.well_size_y_mm shape = self.combobox_shape.currentText() - is_round_well = self.scanCoordinates.format not in ["384 well plate", "1536 well plate"] + is_round_well = self.scanCoordinates.well_shape.is_round fov_size_mm = self.navigationViewer.camera.get_fov_size_mm() * self.objectiveStore.get_pixel_size_factor() - return get_effective_well_size(well_size, fov_size_mm, shape, is_round_well) + return get_effective_well_size(well_size_x, well_size_y, fov_size_mm, shape, is_round_well) def reset_coordinates(self): # Called after acquisition - preserve scan_size, update coverage display @@ -8231,15 +8269,16 @@ def convert_pixel_to_mm(self, pixel_coords): def update_coverage_from_scan_size(self): self.entry_well_coverage.blockSignals(True) if "glass slide" not in self.navigationViewer.sample: - well_size_mm = self.scanCoordinates.well_size_mm + well_size_x_mm = self.scanCoordinates.well_size_x_mm + well_size_y_mm = self.scanCoordinates.well_size_y_mm scan_size = self.entry_scan_size.value() overlap_percent = self.entry_overlap.value() fov_size_mm = self.navigationViewer.camera.get_fov_size_mm() * self.objectiveStore.get_pixel_size_factor() shape = self.combobox_shape.currentText() - is_round_well = self.scanCoordinates.format not in ["384 well plate", "1536 well plate"] + is_round_well = self.scanCoordinates.well_shape.is_round coverage = calculate_well_coverage( - scan_size, fov_size_mm, overlap_percent, shape, well_size_mm, is_round_well + scan_size, fov_size_mm, overlap_percent, shape, well_size_x_mm, well_size_y_mm, is_round_well ) self.entry_well_coverage.setValue(coverage) @@ -8327,6 +8366,7 @@ def update_coordinates(self): return scan_size_mm = self.entry_scan_size.value() + scan_size_y_mm = self.entry_scan_size_y.value() if self.entry_scan_size_y.isVisible() else None overlap_percent = self.entry_overlap.value() shape = self.combobox_shape.currentText() @@ -8339,7 +8379,9 @@ def update_coordinates(self): else: if self.scanCoordinates.has_regions(): self.scanCoordinates.clear_regions() - self.scanCoordinates.set_well_coordinates(scan_size_mm, overlap_percent, shape) + self.scanCoordinates.set_well_coordinates( + scan_size_mm, overlap_percent, shape, scan_size_y_mm=scan_size_y_mm + ) def handle_objective_change(self): """Handle objective change - update coverage and coordinates. @@ -8370,9 +8412,12 @@ def update_well_coordinates(self, selected): if selected: scan_size_mm = self.entry_scan_size.value() + scan_size_y_mm = self.entry_scan_size_y.value() if self.entry_scan_size_y.isVisible() else None overlap_percent = self.entry_overlap.value() shape = self.combobox_shape.currentText() - self.scanCoordinates.set_well_coordinates(scan_size_mm, overlap_percent, shape) + self.scanCoordinates.set_well_coordinates( + scan_size_mm, overlap_percent, shape, scan_size_y_mm=scan_size_y_mm + ) elif self.scanCoordinates.has_regions(): self.scanCoordinates.clear_regions() @@ -8664,6 +8709,7 @@ def toggle_coordinate_controls(self, has_coordinates: bool): # Disable scan controls when coordinates are loaded self.combobox_shape.setEnabled(False) self.entry_scan_size.setEnabled(False) + self.entry_scan_size_y.setEnabled(False) self.entry_well_coverage.setEnabled(False) self.entry_overlap.setEnabled(False) # Disable well selector @@ -13005,7 +13051,7 @@ def on_measure_displacement_clicked(self): class WellplateFormatWidget(QWidget): - signalWellplateSettings = Signal(str, float, float, int, int, float, float, int, int, int) + signalWellplateSettings = Signal(str, float, float, int, int, float, float, float, float, str, int, int, int) def __init__(self, stage: AbstractStage, navigationViewer, streamHandler, liveController): super().__init__() @@ -13043,24 +13089,32 @@ def populate_combo_box(self): self.comboBox.setItemData(index, font, Qt.FontRole) def wellplateChanged(self, index): - self.wellplate_format = self.comboBox.itemData(index) - if self.wellplate_format == "custom": + selected = self.comboBox.itemData(index) + if selected == "custom": + prev_format = self.wellplate_format # Remember current format before dialog calibration_dialog = WellplateCalibration( self, self.stage, self.navigationViewer, self.streamHandler, self.liveController ) result = calibration_dialog.exec_() - if result == QDialog.Rejected: - # If the dialog was closed without adding a new format, revert to the previous selection - prev_index = self.comboBox.findData(self.wellplate_format) - self.comboBox.setCurrentIndex(prev_index) + if result == QDialog.Accepted: + # Dialog updated the combo box — read the new selection + self.wellplate_format = self.comboBox.itemData(self.comboBox.currentIndex()) + if self.wellplate_format and self.wellplate_format != "custom": + self.setWellplateSettings(self.wellplate_format) + else: + # Cancelled — revert to previous format + prev_index = self.comboBox.findData(prev_format) + if prev_index >= 0: + self.comboBox.setCurrentIndex(prev_index) else: + self.wellplate_format = selected self.setWellplateSettings(self.wellplate_format) def setWellplateSettings(self, wellplate_format): if wellplate_format in WELLPLATE_FORMAT_SETTINGS: settings = WELLPLATE_FORMAT_SETTINGS[wellplate_format] elif wellplate_format == "glass slide": - self.signalWellplateSettings.emit("glass slide", 0, 0, 0, 0, 0, 0, 0, 1, 1) + self.signalWellplateSettings.emit("glass slide", 0, 0, 0, 0, 0, 0, 0, 0, WellShape.CIRCULAR.value, 0, 1, 1) return else: print(f"Wellplate format {wellplate_format} not recognized") @@ -13072,8 +13126,11 @@ def setWellplateSettings(self, wellplate_format): settings["a1_y_mm"], settings["a1_x_pixel"], settings["a1_y_pixel"], - settings["well_size_mm"], - settings["well_spacing_mm"], + settings["well_size_x_mm"], + settings["well_size_y_mm"], + settings["well_spacing_x_mm"], + settings["well_spacing_y_mm"], + settings["well_shape"].value if hasattr(settings["well_shape"], "value") else settings["well_shape"], settings["number_of_skip"], settings["rows"], settings["cols"], @@ -13089,8 +13146,11 @@ def getWellplateSettings(self, wellplate_format): "a1_y_mm": 0, "a1_x_pixel": 0, "a1_y_pixel": 0, - "well_size_mm": 0, - "well_spacing_mm": 0, + "well_size_x_mm": 0, + "well_size_y_mm": 0, + "well_spacing_x_mm": 0, + "well_spacing_y_mm": 0, + "well_shape": WellShape.CIRCULAR, "number_of_skip": 0, "rows": 1, "cols": 1, @@ -13117,8 +13177,11 @@ def save_formats_to_csv(self): "a1_y_mm", "a1_x_pixel", "a1_y_pixel", - "well_size_mm", - "well_spacing_mm", + "well_size_x_mm", + "well_size_y_mm", + "well_spacing_x_mm", + "well_spacing_y_mm", + "well_shape", "number_of_skip", "rows", "cols", @@ -13127,7 +13190,11 @@ def save_formats_to_csv(self): writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() for format_, settings in WELLPLATE_FORMAT_SETTINGS.items(): - writer.writerow({**{"format": format_}, **settings}) + row = {**{"format": format_}, **settings} + # Convert enum to string value for CSV + if hasattr(row.get("well_shape"), "value"): + row["well_shape"] = row["well_shape"].value + writer.writerow(row) @staticmethod def parse_csv_row(row): @@ -13136,8 +13203,11 @@ def parse_csv_row(row): "a1_y_mm": float(row["a1_y_mm"]), "a1_x_pixel": int(row["a1_x_pixel"]), "a1_y_pixel": int(row["a1_y_pixel"]), - "well_size_mm": float(row["well_size_mm"]), - "well_spacing_mm": float(row["well_spacing_mm"]), + "well_size_x_mm": float(row["well_size_x_mm"]), + "well_size_y_mm": float(row["well_size_y_mm"]), + "well_spacing_x_mm": float(row["well_spacing_x_mm"]), + "well_spacing_y_mm": float(row["well_spacing_y_mm"]), + "well_shape": WellShape.from_str(row["well_shape"]), "number_of_skip": int(row["number_of_skip"]), "rows": int(row["rows"]), "cols": int(row["cols"]), @@ -13162,9 +13232,22 @@ def __init__(self, wellplateFormatWidget, stage: AbstractStage, navigationViewer # Initially allow click-to-move and hide the joystick controls self.clickToMoveCheckbox.setChecked(True) self.toggleVirtualJoystick(False) + # Set initial calibration method visibility based on default well shape (circular) + self._on_well_shape_changed(WellShape.CIRCULAR.value) # Set minimum height to accommodate all UI configurations self.setMinimumHeight(580) + def _make_mm_spinbox(self, range_min, range_max, value, decimals=2, step=0.1): + """Create a QDoubleSpinBox configured for mm values.""" + sb = QDoubleSpinBox(self) + sb.setKeyboardTracking(False) + sb.setRange(range_min, range_max) + sb.setValue(value) + sb.setSingleStep(step) + sb.setDecimals(decimals) + sb.setSuffix(" mm") + return sb + def initUI(self): layout = QHBoxLayout(self) # Change to QHBoxLayout to have two columns @@ -13229,14 +13312,16 @@ def initUI(self): self.plateHeightInput.setSuffix(" mm") self.form_layout.addRow("Plate Height:", self.plateHeightInput) - self.wellSpacingInput = QDoubleSpinBox(self) - self.wellSpacingInput.setKeyboardTracking(False) - self.wellSpacingInput.setRange(0.1, 100) - self.wellSpacingInput.setValue(9) - self.wellSpacingInput.setSingleStep(0.1) - self.wellSpacingInput.setDecimals(2) - self.wellSpacingInput.setSuffix(" mm") - self.form_layout.addRow("Well Spacing:", self.wellSpacingInput) + self.wellSpacingXInput = self._make_mm_spinbox(0.1, 100, 9) + self.form_layout.addRow("Well Spacing X:", self.wellSpacingXInput) + + self.wellSpacingYInput = self._make_mm_spinbox(0.1, 100, 9) + self.form_layout.addRow("Well Spacing Y:", self.wellSpacingYInput) + + self.wellShapeCombo = QComboBox(self) + self.wellShapeCombo.addItems([s.value for s in WellShape]) + self.form_layout.addRow("Well Shape:", self.wellShapeCombo) + self.wellShapeCombo.currentTextChanged.connect(self._on_well_shape_changed) left_layout.addWidget(self.new_format_widget) @@ -13244,21 +13329,22 @@ def initUI(self): self.existing_params_group = QGroupBox("Format Parameters") existing_params_layout = QFormLayout() - self.existing_spacing_input = QDoubleSpinBox(self) - self.existing_spacing_input.setKeyboardTracking(False) - self.existing_spacing_input.setRange(0.1, 100) - self.existing_spacing_input.setSingleStep(0.1) - self.existing_spacing_input.setDecimals(3) - self.existing_spacing_input.setSuffix(" mm") - existing_params_layout.addRow("Well Spacing:", self.existing_spacing_input) - - self.existing_well_size_input = QDoubleSpinBox(self) - self.existing_well_size_input.setKeyboardTracking(False) - self.existing_well_size_input.setRange(0.1, 50) - self.existing_well_size_input.setSingleStep(0.1) - self.existing_well_size_input.setDecimals(3) - self.existing_well_size_input.setSuffix(" mm") - existing_params_layout.addRow("Well Size:", self.existing_well_size_input) + self.existing_spacing_x_input = self._make_mm_spinbox(0.1, 100, 9, decimals=3) + existing_params_layout.addRow("Well Spacing X:", self.existing_spacing_x_input) + + self.existing_spacing_y_input = self._make_mm_spinbox(0.1, 100, 9, decimals=3) + existing_params_layout.addRow("Well Spacing Y:", self.existing_spacing_y_input) + + self.existing_well_size_x_input = self._make_mm_spinbox(0.1, 50, 6.0, decimals=3) + existing_params_layout.addRow("Well Size X:", self.existing_well_size_x_input) + + self.existing_well_size_y_input = self._make_mm_spinbox(0.1, 50, 6.0, decimals=3) + existing_params_layout.addRow("Well Size Y:", self.existing_well_size_y_input) + + self.existing_well_shape_combo = QComboBox(self) + self.existing_well_shape_combo.addItems([s.value for s in WellShape]) + self.existing_well_shape_combo.currentTextChanged.connect(self._on_well_shape_changed) + existing_params_layout.addRow("Well Shape:", self.existing_well_shape_combo) self.existing_params_group.setLayout(existing_params_layout) @@ -13275,19 +13361,23 @@ def initUI(self): calibration_method_layout = QVBoxLayout() self.method_button_group = QButtonGroup(self) - self.edge_points_radio = QRadioButton("3 Edge Points (recommended for large wells)") + self.edge_points_radio = QRadioButton("3 Edge Points (recommended for large circular wells)") self.center_point_radio = QRadioButton("Center Point (recommended for small wells)") + self.diagonal_corners_radio = QRadioButton("2 Diagonal Corners (rectangular wells)") self.method_button_group.addButton(self.edge_points_radio) self.method_button_group.addButton(self.center_point_radio) + self.method_button_group.addButton(self.diagonal_corners_radio) self.edge_points_radio.setChecked(True) calibration_method_layout.addWidget(self.edge_points_radio) calibration_method_layout.addWidget(self.center_point_radio) + calibration_method_layout.addWidget(self.diagonal_corners_radio) self.calibration_method_group.setLayout(calibration_method_layout) left_layout.addWidget(self.calibration_method_group) - # Only connect one radio button to avoid double-calls (both emit toggled when selection changes) + # Connect radio buttons to toggle visibility self.edge_points_radio.toggled.connect(self.toggle_calibration_method) + self.diagonal_corners_radio.toggled.connect(self.toggle_calibration_method) # 3 Edge Points UI self.points_widget = QWidget() @@ -13327,23 +13417,48 @@ def initUI(self): center_point_layout.addWidget(self.center_point_status_label, 1, 0) center_point_layout.addWidget(self.set_center_button, 1, 1) - # Well size input for center point method (since we can't calculate it) + # Well size inputs for center point method (since we can't calculate it) # Hidden when calibrating existing formats (Format Parameters section has well size) - self.center_well_size_label = QLabel("Well Size:") - self.center_well_size_input = QDoubleSpinBox(self) - self.center_well_size_input.setKeyboardTracking(False) - self.center_well_size_input.setRange(0.1, 50) - self.center_well_size_input.setSingleStep(0.1) - self.center_well_size_input.setDecimals(3) - self.center_well_size_input.setValue(3.0) # Default for small wells - self.center_well_size_input.setSuffix(" mm") - center_point_layout.addWidget(self.center_well_size_label, 2, 0) - center_point_layout.addWidget(self.center_well_size_input, 2, 1) + self.center_well_size_x_label = QLabel("Well Size X:") + self.center_well_size_x_input = self._make_mm_spinbox(0.1, 50, 3.0, decimals=3) + center_point_layout.addWidget(self.center_well_size_x_label, 2, 0) + center_point_layout.addWidget(self.center_well_size_x_input, 2, 1) + + self.center_well_size_y_label = QLabel("Well Size Y:") + self.center_well_size_y_input = self._make_mm_spinbox(0.1, 50, 3.0, decimals=3) + center_point_layout.addWidget(self.center_well_size_y_label, 3, 0) + center_point_layout.addWidget(self.center_well_size_y_input, 3, 1) center_point_layout.setColumnStretch(0, 1) self.center_point_widget.hide() # Initially hidden left_layout.addWidget(self.center_point_widget) + # 2 Diagonal Corners UI + self.diagonal_corners_widget = QWidget() + diagonal_corners_layout = QGridLayout(self.diagonal_corners_widget) + diagonal_corners_layout.setContentsMargins(0, 0, 0, 0) + + diagonal_label = QLabel("Navigate to and Select\n2 Opposite Corners of Well A1") + diagonal_label.setAlignment(Qt.AlignCenter) + diagonal_corners_layout.addWidget(diagonal_label, 0, 0, 1, 2) + + self.diagonal_corner_labels = [] + self.diagonal_corner_buttons = [] + self.diagonal_corners = [None, None] + for i in range(2): + label = QLabel(f"Corner {i+1}: N/A") + button = QPushButton("Set Point") + button.setFixedWidth(button.sizeHint().width()) + button.clicked.connect(lambda checked, index=i: self.setDiagonalCorner(index)) + diagonal_corners_layout.addWidget(label, i + 1, 0) + diagonal_corners_layout.addWidget(button, i + 1, 1) + self.diagonal_corner_labels.append(label) + self.diagonal_corner_buttons.append(button) + + diagonal_corners_layout.setColumnStretch(0, 1) + self.diagonal_corners_widget.hide() # Initially hidden + left_layout.addWidget(self.diagonal_corners_widget) + # Add 'Click to Move' checkbox self.clickToMoveCheckbox = QCheckBox("Click to Move") self.clickToMoveCheckbox.stateChanged.connect(self.toggleClickToMove) @@ -13502,8 +13617,10 @@ def toggle_input_mode(self): is_new_format = self.new_format_radio.isChecked() self.new_format_widget.setVisible(is_new_format) - self.center_well_size_label.setVisible(is_new_format) - self.center_well_size_input.setVisible(is_new_format) + self.center_well_size_x_label.setVisible(is_new_format) + self.center_well_size_x_input.setVisible(is_new_format) + self.center_well_size_y_label.setVisible(is_new_format) + self.center_well_size_y_input.setVisible(is_new_format) self.existing_format_combo.setVisible(not is_new_format) self.existing_params_group.setVisible(not is_new_format) @@ -13519,12 +13636,23 @@ def load_existing_format_values(self): return settings = WELLPLATE_FORMAT_SETTINGS.get(selected_format, {}) - self.existing_spacing_input.setValue(settings.get("well_spacing_mm", 9.0)) + self.existing_spacing_x_input.setValue(settings.get("well_spacing_x_mm", 9.0)) + self.existing_spacing_y_input.setValue(settings.get("well_spacing_y_mm", 9.0)) # Use consistent well size for both inputs - well_size = settings.get("well_size_mm", 6.0) - self.existing_well_size_input.setValue(well_size) - self.center_well_size_input.setValue(well_size) + well_size_x = settings.get("well_size_x_mm", 6.0) + well_size_y = settings.get("well_size_y_mm", 6.0) + self.existing_well_size_x_input.setValue(well_size_x) + self.existing_well_size_y_input.setValue(well_size_y) + self.center_well_size_x_input.setValue(well_size_x) + self.center_well_size_y_input.setValue(well_size_y) + + # Set well shape combo + well_shape = settings.get("well_shape", WellShape.CIRCULAR) + well_shape_str = well_shape.value if hasattr(well_shape, "value") else well_shape + idx = self.existing_well_shape_combo.findText(well_shape_str) + if idx >= 0: + self.existing_well_shape_combo.setCurrentIndex(idx) # Auto-select center point method for 384 and 1536 well plates because their # small well diameters make it difficult to reliably set 3 distinct points @@ -13554,16 +13682,35 @@ def reset_calibration_points(self): self.center_point_status_label.setText("Center: Not set") self.set_center_button.setText("Set Center") + # Reset diagonal corners + for i in range(2): + self.diagonal_corners[i] = None + self.diagonal_corner_labels[i].setText(f"Corner {i+1}: N/A") + self.diagonal_corner_buttons[i].setText("Set Point") + self.update_calibrate_button_state() + def _on_well_shape_changed(self, shape): + """Update calibration method visibility based on well shape selection.""" + is_circular = shape == WellShape.CIRCULAR.value + # Circular: show 3 edge points + center point, hide diagonal corners + # Square/Rectangular: show center point + diagonal corners, hide 3 edge points + self.edge_points_radio.setVisible(is_circular) + self.diagonal_corners_radio.setVisible(not is_circular) + + # Auto-select an appropriate method if the current one is hidden + if is_circular and self.diagonal_corners_radio.isChecked(): + self.edge_points_radio.setChecked(True) + elif not is_circular and self.edge_points_radio.isChecked(): + self.diagonal_corners_radio.setChecked(True) + + self.toggle_calibration_method() + def toggle_calibration_method(self): - """Toggle between 3 edge points and center point calibration methods.""" - if self.edge_points_radio.isChecked(): - self.points_widget.show() - self.center_point_widget.hide() - else: - self.points_widget.hide() - self.center_point_widget.show() + """Toggle between 3 edge points, center point, and diagonal corners calibration methods.""" + self.points_widget.setVisible(self.edge_points_radio.isChecked()) + self.center_point_widget.setVisible(self.center_point_radio.isChecked()) + self.diagonal_corners_widget.setVisible(self.diagonal_corners_radio.isChecked()) self.update_calibrate_button_state() def setCenterPoint(self): @@ -13581,10 +13728,39 @@ def setCenterPoint(self): self.set_center_button.setText("Set Center") self.update_calibrate_button_state() + def setDiagonalCorner(self, index): + """Set or clear a diagonal corner point for rectangular well calibration.""" + if self.diagonal_corners[index] is None: + pos = self.stage.get_pos() + x = pos.x_mm + y = pos.y_mm + + # Check if the new point is the same as the other corner + other = self.diagonal_corners[1 - index] + if other is not None and np.allclose([x, y], other): + QMessageBox.warning( + self, + "Duplicate Point", + "This point is too close to the other corner. Please choose a different location.", + ) + return + + self.diagonal_corners[index] = (x, y) + self.diagonal_corner_labels[index].setText(f"Corner {index+1}: ({x:.3f}, {y:.3f})") + self.diagonal_corner_buttons[index].setText("Clear Point") + else: + self.diagonal_corners[index] = None + self.diagonal_corner_labels[index].setText(f"Corner {index+1}: N/A") + self.diagonal_corner_buttons[index].setText("Set Point") + + self.update_calibrate_button_state() + def update_calibrate_button_state(self): """Update the calibrate button enabled state based on current calibration method.""" if self.center_point_radio.isChecked(): self.calibrateButton.setEnabled(self.center_point is not None) + elif self.diagonal_corners_radio.isChecked(): + self.calibrateButton.setEnabled(all(c is not None for c in self.diagonal_corners)) else: self.calibrateButton.setEnabled(all(corner is not None for corner in self.corners)) @@ -13592,27 +13768,41 @@ def _get_calibration_data(self): """Extract calibration data based on current calibration method. Returns: - tuple: (a1_x_mm, a1_y_mm, well_size_mm) or None if validation fails. + tuple: (a1_x_mm, a1_y_mm, well_size_x_mm, well_size_y_mm) or None if validation fails. Displays appropriate warning message if validation fails. """ - if self.center_point_radio.isChecked(): + if self.diagonal_corners_radio.isChecked(): + if not all(self.diagonal_corners): + QMessageBox.warning( + self, "Incomplete Information", "Please set both diagonal corner points before calibrating." + ) + return None + (x1, y1), (x2, y2) = self.diagonal_corners + a1_x_mm = (x1 + x2) / 2.0 + a1_y_mm = (y1 + y2) / 2.0 + well_size_x_mm = abs(x2 - x1) + well_size_y_mm = abs(y2 - y1) + elif self.center_point_radio.isChecked(): if self.center_point is None: QMessageBox.warning(self, "Incomplete Information", "Please set the center point before calibrating.") return None a1_x_mm, a1_y_mm = self.center_point # Use appropriate well size input based on mode if self.calibrate_format_radio.isChecked(): - well_size_mm = self.existing_well_size_input.value() + well_size_x_mm = self.existing_well_size_x_input.value() + well_size_y_mm = self.existing_well_size_y_input.value() else: - well_size_mm = self.center_well_size_input.value() + well_size_x_mm = self.center_well_size_x_input.value() + well_size_y_mm = self.center_well_size_y_input.value() else: if not all(self.corners): QMessageBox.warning(self, "Incomplete Information", "Please set 3 corner points before calibrating.") return None center, radius = self.calculate_circle(self.corners) - well_size_mm = radius * 2 + well_size_x_mm = radius * 2 + well_size_y_mm = radius * 2 a1_x_mm, a1_y_mm = center - return a1_x_mm, a1_y_mm, well_size_mm + return a1_x_mm, a1_y_mm, well_size_x_mm, well_size_y_mm def update_existing_parameters(self): """Update parameters for an existing format without recalibrating the position.""" @@ -13623,8 +13813,11 @@ def update_existing_parameters(self): try: # Get the new values - new_spacing = self.existing_spacing_input.value() - new_well_size = self.existing_well_size_input.value() + new_spacing_x = self.existing_spacing_x_input.value() + new_spacing_y = self.existing_spacing_y_input.value() + new_well_size_x = self.existing_well_size_x_input.value() + new_well_size_y = self.existing_well_size_y_input.value() + new_well_shape = WellShape.from_str(self.existing_well_shape_combo.currentText()) # Get existing settings existing_settings = WELLPLATE_FORMAT_SETTINGS.get(selected_format) @@ -13634,15 +13827,26 @@ def update_existing_parameters(self): print(f"Updating parameters for {self._format_display_name(selected_format)}") print( - f"OLD: spacing={existing_settings.get('well_spacing_mm')}, well_size={existing_settings.get('well_size_mm')}" + f"OLD: spacing_x={existing_settings.get('well_spacing_x_mm')}, " + f"spacing_y={existing_settings.get('well_spacing_y_mm')}, " + f"well_size_x={existing_settings.get('well_size_x_mm')}, " + f"well_size_y={existing_settings.get('well_size_y_mm')}, " + f"well_shape={existing_settings.get('well_shape')}" + ) + print( + f"NEW: spacing_x={new_spacing_x}, spacing_y={new_spacing_y}, " + f"well_size_x={new_well_size_x}, well_size_y={new_well_size_y}, " + f"well_shape={new_well_shape}" ) - print(f"NEW: spacing={new_spacing}, well_size={new_well_size}") # Update the settings WELLPLATE_FORMAT_SETTINGS[selected_format].update( { - "well_spacing_mm": new_spacing, - "well_size_mm": new_well_size, + "well_spacing_x_mm": new_spacing_x, + "well_spacing_y_mm": new_spacing_y, + "well_size_x_mm": new_well_size_x, + "well_size_y_mm": new_well_size_y, + "well_shape": new_well_shape, } ) @@ -13672,7 +13876,7 @@ def calibrate(self): Supports two modes: - New format: Creates a new custom wellplate format with all parameters - - Existing format: Updates position calibration (a1_x_mm, a1_y_mm) and well_size_mm + - Existing format: Updates position calibration (a1_x_mm, a1_y_mm) and well_size_x/y_mm Supports two calibration methods: - 3 Edge Points: Calculates well center and diameter from 3 points on well edge @@ -13709,7 +13913,7 @@ def _calibrate_new_format(self): calibration_data = self._get_calibration_data() if calibration_data is None: return - a1_x_mm, a1_y_mm, well_size_mm = calibration_data + a1_x_mm, a1_y_mm, well_size_x_mm, well_size_y_mm = calibration_data name = self.nameInput.text() plate_width_mm = self.plateWidthInput.value() @@ -13721,8 +13925,11 @@ def _calibrate_new_format(self): "a1_y_mm": a1_y_mm, "a1_x_pixel": round(a1_x_mm * scale), "a1_y_pixel": round(a1_y_mm * scale), - "well_size_mm": well_size_mm, - "well_spacing_mm": self.wellSpacingInput.value(), + "well_size_x_mm": well_size_x_mm, + "well_size_y_mm": well_size_y_mm, + "well_spacing_x_mm": self.wellSpacingXInput.value(), + "well_spacing_y_mm": self.wellSpacingYInput.value(), + "well_shape": WellShape.from_str(self.wellShapeCombo.currentText()), "number_of_skip": 0, "rows": self.rowsInput.value(), "cols": self.colsInput.value(), @@ -13741,7 +13948,7 @@ def _calibrate_existing_format(self): calibration_data = self._get_calibration_data() if calibration_data is None: return - a1_x_mm, a1_y_mm, well_size_mm = calibration_data + a1_x_mm, a1_y_mm, well_size_x_mm, well_size_y_mm = calibration_data existing_settings = WELLPLATE_FORMAT_SETTINGS[selected_format] display_name = self._format_display_name(selected_format) @@ -13749,15 +13956,20 @@ def _calibrate_existing_format(self): print(f"Updating existing format {display_name}") print( f"OLD: 'a1_x_mm': {existing_settings['a1_x_mm']}, 'a1_y_mm': {existing_settings['a1_y_mm']}, " - f"'well_size_mm': {existing_settings['well_size_mm']}" + f"'well_size_x_mm': {existing_settings['well_size_x_mm']}, " + f"'well_size_y_mm': {existing_settings['well_size_y_mm']}" + ) + print( + f"NEW: 'a1_x_mm': {a1_x_mm}, 'a1_y_mm': {a1_y_mm}, " + f"'well_size_x_mm': {well_size_x_mm}, 'well_size_y_mm': {well_size_y_mm}" ) - print(f"NEW: 'a1_x_mm': {a1_x_mm}, 'a1_y_mm': {a1_y_mm}, 'well_size_mm': {well_size_mm}") WELLPLATE_FORMAT_SETTINGS[selected_format].update( { "a1_x_mm": a1_x_mm, "a1_y_mm": a1_y_mm, - "well_size_mm": well_size_mm, + "well_size_x_mm": well_size_x_mm, + "well_size_y_mm": well_size_y_mm, } ) @@ -13788,8 +14000,11 @@ def mm_to_px(mm): draw = ImageDraw.Draw(image) rows, cols = format_data["rows"], format_data["cols"] - well_spacing_mm = format_data["well_spacing_mm"] - well_size_mm = format_data["well_size_mm"] + well_spacing_x_mm = format_data["well_spacing_x_mm"] + well_spacing_y_mm = format_data["well_spacing_y_mm"] + well_size_x_mm = format_data["well_size_x_mm"] + well_size_y_mm = format_data["well_size_y_mm"] + well_shape = format_data.get("well_shape", "circular") a1_x_mm, a1_y_mm = format_data["a1_x_mm"], format_data["a1_y_mm"] def draw_left_slanted_rectangle(draw, xy, slant, width=4, outline="black", fill=None): @@ -13821,17 +14036,25 @@ def draw_left_slanted_rectangle(draw, xy, slant, width=4, outline="black", fill= draw, [margin, margin, width - margin, height - margin], slant, width=4, outline="black", fill="lightgrey" ) - # Function to draw a circle - def draw_circle(x, y, diameter): - radius = diameter / 2 - draw.ellipse([x - radius, y - radius, x + radius, y + radius], outline="black", width=4, fill="white") + # Function to draw a well (circle or rectangle based on shape) + def draw_well(x, y, size_x_px, size_y_px): + if well_shape != WellShape.CIRCULAR.value: + half_x = size_x_px / 2 + half_y = size_y_px / 2 + draw.rectangle([x - half_x, y - half_y, x + half_x, y + half_y], outline="black", width=4, fill="white") + else: + # Circular: use average of x/y size as diameter for backward compatibility + radius = max(size_x_px, size_y_px) / 2 + draw.ellipse([x - radius, y - radius, x + radius, y + radius], outline="black", width=4, fill="white") # Draw the wells + well_size_x_px = mm_to_px(well_size_x_mm) + well_size_y_px = mm_to_px(well_size_y_mm) for row in range(rows): for col in range(cols): - x = mm_to_px(a1_x_mm + col * well_spacing_mm) - y = mm_to_px(a1_y_mm + row * well_spacing_mm) - draw_circle(x, y, mm_to_px(well_size_mm)) + x = mm_to_px(a1_x_mm + col * well_spacing_x_mm) + y = mm_to_px(a1_y_mm + row * well_spacing_y_mm) + draw_well(x, y, well_size_x_px, well_size_y_px) # Load a default font font_size = 30 @@ -13840,8 +14063,8 @@ def draw_circle(x, y, diameter): # Add column labels for col in range(cols): label = str(col + 1) - x = mm_to_px(a1_x_mm + col * well_spacing_mm) - y = mm_to_px((a1_y_mm - well_size_mm / 2) / 2) + x = mm_to_px(a1_x_mm + col * well_spacing_x_mm) + y = mm_to_px((a1_y_mm - well_size_y_mm / 2) / 2) bbox = font.getbbox(label) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] @@ -13850,8 +14073,8 @@ def draw_circle(x, y, diameter): # Add row labels for row in range(rows): label = chr(65 + row) if row < 26 else chr(65 + row // 26 - 1) + chr(65 + row % 26) - x = mm_to_px((a1_x_mm - well_size_mm / 2) / 2) - y = mm_to_px(a1_y_mm + row * well_spacing_mm) + x = mm_to_px((a1_x_mm - well_size_x_mm / 2) / 2) + y = mm_to_px(a1_y_mm + row * well_spacing_y_mm) bbox = font.getbbox(label) text_height = bbox[3] - bbox[1] text_width = bbox[2] - bbox[0] @@ -14135,9 +14358,11 @@ def __init__(self, wellplateFormatWidget): # defaults self.rows = 32 self.columns = 48 - self.spacing_mm = 2.25 + self.spacing_x_mm = 2.25 + self.spacing_y_mm = 2.25 self.number_of_skip = 0 - self.well_size_mm = 1.5 + self.well_size_x_mm = 1.5 + self.well_size_y_mm = 1.5 self.a1_x_mm = 11.0 # measured stage position - to update self.a1_y_mm = 7.86 # measured stage position - to update self.a1_x_pixel = 144 # coordinate on the png - to update @@ -14147,13 +14372,15 @@ def __init__(self, wellplateFormatWidget): s = self.wellplateFormatWidget.getWellplateSettings(self.format) self.rows = s["rows"] self.columns = s["cols"] - self.spacing_mm = s["well_spacing_mm"] + self.spacing_x_mm = s["well_spacing_x_mm"] + self.spacing_y_mm = s["well_spacing_y_mm"] self.number_of_skip = s["number_of_skip"] self.a1_x_mm = s["a1_x_mm"] self.a1_y_mm = s["a1_y_mm"] self.a1_x_pixel = s["a1_x_pixel"] self.a1_y_pixel = s["a1_y_pixel"] - self.well_size_mm = s["well_size_mm"] + self.well_size_x_mm = s["well_size_x_mm"] + self.well_size_y_mm = s["well_size_y_mm"] self.initUI() @@ -14513,8 +14740,8 @@ def update_current_cell(self): # Update cell_input with the correct label (e.g., A1, B2, AA1, etc.) self.cell_input.setText(self._cell_name(row, col)) - x_mm = col * self.spacing_mm + self.a1_x_mm + WELLPLATE_OFFSET_X_mm - y_mm = row * self.spacing_mm + self.a1_y_mm + WELLPLATE_OFFSET_Y_mm + x_mm = col * self.spacing_x_mm + self.a1_x_mm + WELLPLATE_OFFSET_X_mm + y_mm = row * self.spacing_y_mm + self.a1_y_mm + WELLPLATE_OFFSET_Y_mm self.signal_wellSelectedPos.emit(x_mm, y_mm) def redraw_wells(self): diff --git a/software/objective_and_sample_formats/sample_formats.csv b/software/objective_and_sample_formats/sample_formats.csv index 781de84d7..9be0e8b5e 100644 --- a/software/objective_and_sample_formats/sample_formats.csv +++ b/software/objective_and_sample_formats/sample_formats.csv @@ -1,8 +1,8 @@ -format,a1_x_mm,a1_y_mm,a1_x_pixel,a1_y_pixel,well_size_mm,well_spacing_mm,number_of_skip,rows,cols -glass slide,0,0,0,0,0,0,0,1,1 -6,24.55,23.01,290,272,34.94,39.2,0,2,3 -12,24.75,16.86,293,198,22.05,26.0,0,3,4 -24,24.45,22.07,233,210,15.54,19.3,0,4,6 -96,11.31,10.75,171,135,6.21,9.0,0,8,12 -384,12.05,9.05,143,106,3.3,4.5,1,16,24 -1536,11.01,7.87,130,93,1.53,2.25,0,32,48 +format,a1_x_mm,a1_y_mm,a1_x_pixel,a1_y_pixel,well_size_x_mm,well_size_y_mm,well_spacing_x_mm,well_spacing_y_mm,well_shape,number_of_skip,rows,cols +glass slide,0,0,0,0,0,0,0,0,circular,0,1,1 +6,24.55,23.01,290,272,34.94,34.94,39.2,39.2,circular,0,2,3 +12,24.75,16.86,293,198,22.05,22.05,26.0,26.0,circular,0,3,4 +24,24.45,22.07,233,210,15.54,15.54,19.3,19.3,circular,0,4,6 +96,11.31,10.75,171,135,6.21,6.21,9.0,9.0,circular,0,8,12 +384,12.05,9.05,143,106,3.3,3.3,4.5,4.5,square,1,16,24 +1536,11.01,7.87,130,93,1.53,1.53,2.25,2.25,square,0,32,48 diff --git a/software/tests/control/test_rectangular_format.py b/software/tests/control/test_rectangular_format.py new file mode 100644 index 000000000..092f315a7 --- /dev/null +++ b/software/tests/control/test_rectangular_format.py @@ -0,0 +1,268 @@ +"""Tests for rectangular sample format support.""" + +import csv +import os +import tempfile + +from control._def import read_sample_formats_csv, WellShape + + +class TestReadSampleFormatsCSV: + """Tests for read_sample_formats_csv with new column format.""" + + def test_new_format_csv(self, tmp_path): + """New CSV with well_size_x_mm, well_size_y_mm, well_spacing_x_mm, well_spacing_y_mm, well_shape.""" + csv_path = tmp_path / "sample_formats.csv" + csv_path.write_text( + "format,a1_x_mm,a1_y_mm,a1_x_pixel,a1_y_pixel,well_size_x_mm,well_size_y_mm," + "well_spacing_x_mm,well_spacing_y_mm,well_shape,number_of_skip,rows,cols\n" + "96,11.31,10.75,171,135,6.21,6.21,9.0,9.0,circular,0,8,12\n" + "custom_chip,5.0,5.0,60,60,2.0,1.5,4.0,3.0,rectangular,0,10,20\n" + ) + formats = read_sample_formats_csv(str(csv_path)) + + assert "96 well plate" in formats + assert formats["96 well plate"]["well_spacing_x_mm"] == 9.0 + assert formats["96 well plate"]["well_spacing_y_mm"] == 9.0 + assert formats["96 well plate"]["well_size_x_mm"] == 6.21 + assert formats["96 well plate"]["well_size_y_mm"] == 6.21 + assert formats["96 well plate"]["well_shape"] == WellShape.CIRCULAR + + assert "custom_chip" in formats + assert formats["custom_chip"]["well_spacing_x_mm"] == 4.0 + assert formats["custom_chip"]["well_spacing_y_mm"] == 3.0 + assert formats["custom_chip"]["well_size_x_mm"] == 2.0 + assert formats["custom_chip"]["well_size_y_mm"] == 1.5 + assert formats["custom_chip"]["well_shape"] == WellShape.RECTANGULAR + + def test_backward_compat_old_csv(self, tmp_path): + """Old CSV with single well_size_mm and well_spacing_mm should be auto-upgraded.""" + csv_path = tmp_path / "sample_formats.csv" + csv_path.write_text( + "format,a1_x_mm,a1_y_mm,a1_x_pixel,a1_y_pixel,well_size_mm,well_spacing_mm," + "number_of_skip,rows,cols\n" + "96,11.31,10.75,171,135,6.21,9.0,0,8,12\n" + ) + formats = read_sample_formats_csv(str(csv_path)) + + assert formats["96 well plate"]["well_spacing_x_mm"] == 9.0 + assert formats["96 well plate"]["well_spacing_y_mm"] == 9.0 + assert formats["96 well plate"]["well_size_x_mm"] == 6.21 + assert formats["96 well plate"]["well_size_y_mm"] == 6.21 + assert formats["96 well plate"]["well_shape"] == WellShape.CIRCULAR + + def test_glass_slide_format(self, tmp_path): + """Glass slide should have all zeros and circular shape.""" + csv_path = tmp_path / "sample_formats.csv" + csv_path.write_text( + "format,a1_x_mm,a1_y_mm,a1_x_pixel,a1_y_pixel,well_size_x_mm,well_size_y_mm," + "well_spacing_x_mm,well_spacing_y_mm,well_shape,number_of_skip,rows,cols\n" + "glass slide,0,0,0,0,0,0,0,0,circular,0,1,1\n" + ) + formats = read_sample_formats_csv(str(csv_path)) + assert "glass slide" in formats + assert formats["glass slide"]["well_spacing_x_mm"] == 0 + assert formats["glass slide"]["well_spacing_y_mm"] == 0 + + +class TestGetWellplateSettings: + """Tests for get_wellplate_settings with new format.""" + + def test_settings_have_xy_keys(self): + """Settings dict should contain X/Y keys and well_shape.""" + from control._def import get_wellplate_settings + + settings = get_wellplate_settings("96 well plate") + assert "well_spacing_x_mm" in settings + assert "well_spacing_y_mm" in settings + assert "well_size_x_mm" in settings + assert "well_size_y_mm" in settings + assert "well_shape" in settings + + def test_zero_format_has_xy_keys(self): + """The '0' format should also have X/Y keys.""" + from control._def import get_wellplate_settings + + settings = get_wellplate_settings("0") + assert "well_spacing_x_mm" in settings + assert "well_spacing_y_mm" in settings + assert "well_size_x_mm" in settings + assert "well_size_y_mm" in settings + assert settings["well_shape"] == WellShape.CIRCULAR + + +import math + +from control.core.geometry_utils import get_effective_well_size, calculate_well_coverage + + +class TestRectangularEffectiveWellSize: + """Tests for get_effective_well_size with rectangular wells.""" + + def test_rectangular_well_square_scan(self): + """Rectangular well with Square scan returns (size_x, size_y) tuple.""" + result = get_effective_well_size(2.0, 1.5, 0.5, "Square", is_round_well=False) + assert result == (2.0, 1.5) + + def test_rectangular_well_rectangle_scan(self): + """Rectangular well with Rectangle scan returns (size_x, size_y) tuple.""" + result = get_effective_well_size(2.0, 1.5, 0.5, "Rectangle", is_round_well=False) + assert result == (2.0, 1.5) + + def test_square_well_returns_tuple(self): + """Square wells (384/1536) return tuple with equal values.""" + result = get_effective_well_size(3.3, 3.3, 0.5, "Square", is_round_well=False) + assert result == (3.3, 3.3) + + def test_circular_well_unchanged(self): + """Circular well (equal X/Y) should work as before.""" + result = get_effective_well_size(6.21, 6.21, 0.5, "Square", is_round_well=True) + expected = 6.21 / math.sqrt(2) + assert abs(result - expected) < 0.001 + + def test_rectangular_well_circle_scan(self): + """Rectangular well with Circle scan returns circumscribing circle.""" + size_x, size_y, fov = 2.0, 1.5, 0.5 + result = get_effective_well_size(size_x, size_y, fov, "Circle", is_round_well=False) + expected = math.sqrt(size_x**2 + size_y**2) + fov * (1 + math.sqrt(2)) + assert abs(result - expected) < 0.001 + + +class TestRectangularWellCoverage: + """Tests for calculate_well_coverage with rectangular wells.""" + + def test_rectangular_well_full_coverage(self): + """Large scan over small rectangular well should give ~100% coverage.""" + coverage = calculate_well_coverage( + 5.0, 0.5, 10, "Square", well_size_x_mm=2.0, well_size_y_mm=1.5, is_round_well=False + ) + assert coverage > 90 + + def test_rectangular_well_partial_coverage(self): + """Small scan over rectangular well gives partial coverage.""" + coverage = calculate_well_coverage( + 1.0, 0.5, 10, "Square", well_size_x_mm=2.0, well_size_y_mm=1.5, is_round_well=False + ) + assert 0 < coverage < 100 + + def test_round_well_backward_compat(self): + """Round well with positional well_size_mm should still work.""" + coverage = calculate_well_coverage(15.0, 3.9, 10, "Circle", 15.54) + assert coverage > 0 + + +class TestSquareWellShape: + """Tests for 'square' well_shape (384/1536 plates).""" + + def test_square_shape_in_csv(self, tmp_path): + """CSV with well_shape='square' should be parsed correctly.""" + csv_path = tmp_path / "sample_formats.csv" + csv_path.write_text( + "format,a1_x_mm,a1_y_mm,a1_x_pixel,a1_y_pixel,well_size_x_mm,well_size_y_mm," + "well_spacing_x_mm,well_spacing_y_mm,well_shape,number_of_skip,rows,cols\n" + "custom_square,10.0,10.0,100,100,2.0,2.0,4.0,4.0,square,0,8,12\n" + ) + formats = read_sample_formats_csv(str(csv_path)) + assert formats["custom_square"]["well_shape"] == WellShape.SQUARE + assert formats["custom_square"]["well_size_x_mm"] == 2.0 + assert formats["custom_square"]["well_size_y_mm"] == 2.0 + + def test_square_well_effective_size(self): + """Square wells should return tuple from get_effective_well_size.""" + result = get_effective_well_size(2.0, 2.0, 0.5, "Square", is_round_well=False) + assert result == (2.0, 2.0) + + def test_square_well_coverage(self): + """Square wells should use rectangular bounds for coverage.""" + coverage = calculate_well_coverage( + 5.0, 0.5, 10, "Square", well_size_x_mm=2.0, well_size_y_mm=2.0, is_round_well=False + ) + assert coverage > 90 + + +class TestPerAxisAddRegion: + """Tests for add_region with per-axis scan sizes.""" + + def test_asymmetric_scan_generates_rectangular_grid(self): + """add_region with scan_size_y_mm != scan_size_mm should produce rectangular grid.""" + from unittest.mock import MagicMock + from control.core.scan_coordinates import ScanCoordinates + + sc = ScanCoordinates(MagicMock(), MagicMock(), MagicMock()) + # Mock FOV size to 1.0mm + sc.objectiveStore = MagicMock() + sc.objectiveStore.get_pixel_size_factor.return_value = 1.0 + sc.camera = MagicMock() + sc.camera.get_fov_size_mm.return_value = 1.0 + + # Use center at 50,50 to be within typical stage limits + sc.add_region("test", 50, 50, scan_size_mm=3.0, overlap_percent=0, shape="Square", scan_size_y_mm=2.0) + + coords = sc.region_fov_coordinates.get("test", []) + assert len(coords) > 0 + + # X should span ~3mm (3 steps), Y should span ~2mm (2 steps) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + x_unique = sorted(set(round(x, 3) for x in xs)) + y_unique = sorted(set(round(y, 3) for y in ys)) + assert len(x_unique) == 3, f"Expected 3 X positions, got {len(x_unique)}: {x_unique}" + assert len(y_unique) == 2, f"Expected 2 Y positions, got {len(y_unique)}: {y_unique}" + + def test_equal_scan_sizes_no_per_axis(self): + """When scan_size_y_mm == scan_size_mm, should behave like scalar scan.""" + from unittest.mock import MagicMock + from control.core.scan_coordinates import ScanCoordinates + + sc = ScanCoordinates(MagicMock(), MagicMock(), MagicMock()) + sc.objectiveStore = MagicMock() + sc.objectiveStore.get_pixel_size_factor.return_value = 1.0 + sc.camera = MagicMock() + sc.camera.get_fov_size_mm.return_value = 1.0 + + # Use center at 50,50 to be within typical stage limits + sc.add_region("a", 50, 50, scan_size_mm=3.0, overlap_percent=0, shape="Square", scan_size_y_mm=3.0) + sc.add_region("b", 50, 50, scan_size_mm=3.0, overlap_percent=0, shape="Square", scan_size_y_mm=None) + + coords_a = sc.region_fov_coordinates["a"] + coords_b = sc.region_fov_coordinates["b"] + assert len(coords_a) == len(coords_b) + + +class TestScanCoordinatesRectangular: + """Tests for ScanCoordinates with asymmetric X/Y spacing.""" + + def test_well_position_asymmetric_spacing(self): + """Wells should use separate X and Y spacing.""" + from unittest.mock import MagicMock + from control.core.scan_coordinates import ScanCoordinates + + import control._def + + original_x = control._def.WELL_SPACING_X_MM + original_y = control._def.WELL_SPACING_Y_MM + try: + control._def.WELL_SPACING_X_MM = 4.0 + control._def.WELL_SPACING_Y_MM = 3.0 + + sc = ScanCoordinates(MagicMock(), MagicMock(), MagicMock()) + sc.well_spacing_x_mm = 4.0 + sc.well_spacing_y_mm = 3.0 + sc.a1_x_mm = 5.0 + sc.a1_y_mm = 5.0 + sc.wellplate_offset_x_mm = 0 + sc.wellplate_offset_y_mm = 0 + sc.format = "custom" + + mock_selector = MagicMock() + mock_selector.get_selected_cells.return_value = [[1, 2]] + sc.well_selector = mock_selector + + wells = sc.get_selected_wells() + # x = 5.0 + 2 * 4.0 = 13.0 + # y = 5.0 + 1 * 3.0 = 8.0 + well_id = list(wells.keys())[0] + assert wells[well_id] == (13.0, 8.0), f"Expected (13.0, 8.0), got {wells[well_id]}" + finally: + control._def.WELL_SPACING_X_MM = original_x + control._def.WELL_SPACING_Y_MM = original_y diff --git a/software/tests/control/test_scan_size_consistency.py b/software/tests/control/test_scan_size_consistency.py index ce8b08c1d..168c32705 100644 --- a/software/tests/control/test_scan_size_consistency.py +++ b/software/tests/control/test_scan_size_consistency.py @@ -79,20 +79,20 @@ class TestEffectiveWellSize: def test_square_on_round_well_inscribed(self): well_size = 6.21 - effective = get_effective_well_size(well_size, 0.5, "Square", is_round_well=True) + effective = get_effective_well_size(well_size, well_size, 0.5, "Square", is_round_well=True) expected = well_size / math.sqrt(2) assert abs(effective - expected) < 0.001 def test_circle_includes_fov_adjustment(self): well_size = 6.21 fov_size = 0.5 - effective = get_effective_well_size(well_size, fov_size, "Circle") + effective = get_effective_well_size(well_size, well_size, fov_size, "Circle") expected = well_size + fov_size * (1 + math.sqrt(2)) assert effective == expected def test_rectangle_on_round_well(self): well_size = 6.21 - effective = get_effective_well_size(well_size, 0.5, "Rectangle", is_round_well=True) + effective = get_effective_well_size(well_size, well_size, 0.5, "Rectangle", is_round_well=True) expected = well_size / math.sqrt(1.36) assert abs(effective - expected) < 0.001