Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions software/control/_def.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

###########################################################
Expand Down Expand Up @@ -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"]),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions software/control/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
63 changes: 39 additions & 24 deletions software/control/core/geometry_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading