"""Parse plate rows, columns, and wells through one validated vocabulary.
Supported forms include rows such as ``r1``, columns such as ``c1``, and
individual wells such as ``A01`` or ``r1_c1``. Every token is checked against
the selected plate layout so an invalid or out-of-range selection fails with
a useful message instead of silently selecting no wells.
"""
from __future__ import annotations
import re
import string
from typing import Iterable, Optional, Sequence, Set, Tuple
#: Settings whose documented values may contain plate rows, columns, or wells.
#: The list is derived from setting descriptions rather than key-name patterns,
#: which ensures generic fields such as ``filter_value`` are included.
WELL_SETTINGS = (
"cell_loc", "cell_plate_metadata", "class_metadata", "classes",
"stain_baseline_wells", "analysis_excluded_wells",
"filter_value", "metadata_item_1_value", "mix", "neg",
"negative_control_id", "pathogen_loc", "pathogen_plate_metadata", "pos",
"positive_control_id", "treatment_loc", "treatment_plate_metadata",
"positive_control_wells", "negative_control_wells",
"mixed_control_wells", "viability_negative_wells",
"viability_positive_wells",
)
#: Settings whose complete value is a well specification and can therefore be
#: replaced safely by the plate-map picker. Mixed-vocabulary fields remain
#: outside this subset so non-well values are preserved.
WELL_ONLY_SETTINGS = (
"cell_loc", "stain_baseline_wells", "analysis_excluded_wells",
"filter_value", "pathogen_loc",
"treatment_loc",
"positive_control_wells", "negative_control_wells",
"mixed_control_wells", "viability_negative_wells",
"viability_positive_wells",
)
#: The three blocks of control wells, in the order a plate lays them out.
#: Named once so the regression, the calibration and the settings panel
#: cannot disagree about what "the control wells" means.
CONTROL_BLOCK_SETTINGS = ("negative_control_wells", "positive_control_wells",
"mixed_control_wells")
[docs]
def control_block_wells(settings) -> list:
"""Return the wells named by the three control-block settings.
:param settings: the run's settings mapping.
:returns: well/row/column names, in the order the blocks are declared,
with duplicates removed and order otherwise preserved.
Pure-control wells are excluded from regression because each contains a
guide fixed by design rather than a random draw from the screened library.
A strong control left in the model can otherwise become a high-leverage
observation for a gene under test.
"""
seen: list = []
for key in CONTROL_BLOCK_SETTINGS:
raw = (settings or {}).get(key)
if raw is None:
continue
values = raw if isinstance(raw, (list, tuple, set)) else [raw]
for value in values:
text = str(value).strip()
if text and text not in seen:
seen.append(text)
return seen
#: Supported well counts mapped to their standard row and column dimensions.
LAYOUTS = {
6: (2, 3),
12: (3, 4),
24: (4, 6),
96: (8, 12),
384: (16, 24),
1536: (32, 48),
}
#: What the picker opens on.
DEFAULT_LAYOUT = 384
#: CASE IS THE DISCRIMINATOR, and it has to be one.
#:
#: `C04` is row C column 4 on every plate map ever printed. `c4` is column 4
#: in spaCR's own vocabulary -- `control_wells` documents "a column ('c12')".
#: Those two collide the moment the row/column patterns are case-insensitive,
#: and they did: `C04` on a 12-well plate came back as the whole of column 4.
#:
#: So the LOWERCASE forms are spaCR's row and column, and anything else is a
#: plate-map well. `c4` is a column, `C4` is well C4. The rule is stated in
#: the refusal text, because it is the one thing here a user can get wrong
#: while typing something that looks perfectly reasonable.
_ROW = re.compile(r"^r(\d{1,2})$")
_COLUMN = re.compile(r"^c(\d{1,3})$")
_WELL_RC = re.compile(r"^r(\d{1,2})[_-]?c(\d{1,3})$", re.IGNORECASE)
#: ``A01``, ``A1``, and the two-letter rows a 1536 needs (``AA1``).
_WELL_ALPHA = re.compile(r"^([A-Za-z]{1,2})(\d{1,3})$")
[docs]
class WellSpecError(ValueError):
"""Raised when a well specification is invalid for its plate layout."""
[docs]
def shape(layout: int = DEFAULT_LAYOUT) -> Tuple[int, int]:
"""Return the row and column count for a supported plate layout.
Raises
------
WellSpecError
If ``layout`` is not one of the supported well counts.
"""
try:
return LAYOUTS[int(layout)]
except (KeyError, TypeError, ValueError):
raise WellSpecError(
f"{layout!r} is not a plate layout spaCR knows. It offers "
f"{sorted(LAYOUTS)}.") from None
[docs]
def row_label(row: int) -> str:
"""Convert a one-based row number to a plate-map label.
:param row: one-based plate row number.
"""
if row <= 26:
return string.ascii_uppercase[row - 1]
return "A" + string.ascii_uppercase[row - 27]
[docs]
def row_number(label: str) -> int:
"""Convert a plate-map row label to its one-based row number.
:param label: alphabetic plate-map row label.
"""
text = str(label).strip().upper()
if len(text) == 1:
return string.ascii_uppercase.index(text) + 1
return 26 + string.ascii_uppercase.index(text[1]) + 1
[docs]
def well_label(row: int, column: int) -> str:
"""Return the plate-map label for a one-based row and column.
:param row: one-based plate row number.
:param column: one-based plate column number.
"""
return f"{row_label(row)}{column:02d}"
[docs]
def parse_one(text: str, layout: int = DEFAULT_LAYOUT) -> Set[Tuple[int, int]]:
"""Resolve one row, column, or well token to one-based coordinates.
:param text: single row, column, or well token to resolve.
Raises
------
WellSpecError
If the token is malformed or outside the selected layout. The error
identifies both the token and plate dimensions.
"""
rows, columns = shape(layout)
token = str(text or "").strip()
if not token:
return set()
match = _WELL_RC.match(token)
if match:
row, column = int(match.group(1)), int(match.group(2))
_check(row, column, rows, columns, token, layout)
return {(row, column)}
match = _ROW.match(token)
if match:
row = int(match.group(1))
_check(row, 1, rows, columns, token, layout)
return {(row, c) for c in range(1, columns + 1)}
match = _COLUMN.match(token)
if match:
column = int(match.group(1))
_check(1, column, rows, columns, token, layout)
return {(r, column) for r in range(1, rows + 1)}
match = _WELL_ALPHA.match(token)
if match:
letters, digits = match.group(1), match.group(2)
try:
row = row_number(letters)
except ValueError:
raise WellSpecError(
f"{token!r} does not name a row: {letters!r} is not a plate "
f"row letter.") from None
column = int(digits)
_check(row, column, rows, columns, token, layout)
return {(row, column)}
raise WellSpecError(
f"{token!r} is not a row (r1), a column (c1) or a well (A01 or "
f"r1_c1). Note that case decides between the last two: 'c4' is "
f"column 4 and 'C4' is well C4, because a plate map is labelled "
f"C04 and spaCR writes c4.")
def _check(row: int, column: int, rows: int, columns: int, token: str,
layout: int) -> None:
"""Validate that one coordinate lies inside the selected plate layout.
:param row: One-based row coordinate to validate.
:param column: One-based column coordinate to validate.
:param rows: Number of rows in the selected plate shape.
:param columns: Number of columns in the selected plate shape.
:param token: Original selection token named in an error.
:param layout: Well count used to identify the selected layout.
:returns: ``None`` when the coordinate is in bounds.
:raises WellSpecError: The coordinate falls outside the plate shape.
"""
if not (1 <= row <= rows and 1 <= column <= columns):
raise WellSpecError(
f"{token!r} is outside a {layout}-well plate, which has {rows} "
f"row(s) and {columns} column(s) (A01 to "
f"{well_label(rows, columns)}). Choose the layout the plate "
f"actually is.")
[docs]
def parse(text, layout: int = DEFAULT_LAYOUT) -> Set[Tuple[int, int]]:
"""Resolve a mixed well specification to unique one-based coordinates.
:param text: delimited string or iterable of row, column, and well tokens.
The value ``"r1, c1, A01"`` selects the union of the named row, column,
and individual well.
"""
if text is None:
return set()
tokens: Iterable
if isinstance(text, str):
tokens = re.split(r"[,\s;]+", text)
else:
tokens = text
out: Set[Tuple[int, int]] = set()
for token in tokens:
out |= parse_one(token, layout)
return out
[docs]
def to_text(cells: Iterable[Tuple[int, int]],
layout: int = DEFAULT_LAYOUT) -> str:
"""Serialize selected coordinates using compact supported tokens.
:param cells: iterable of one-based ``(row, column)`` coordinates.
Complete rows and columns become ``rN`` and ``cN`` tokens. Remaining
coordinates are written as individual well labels, ensuring the result
can be parsed again without introducing an unsupported range syntax.
"""
rows, columns = shape(layout)
chosen = {(int(r), int(c)) for r, c in cells}
if not chosen:
return ""
parts = []
covered: Set[Tuple[int, int]] = set()
for row in range(1, rows + 1):
whole = {(row, c) for c in range(1, columns + 1)}
if whole <= chosen:
parts.append(f"r{row}")
covered |= whole
for column in range(1, columns + 1):
whole = {(r, column) for r in range(1, rows + 1)}
if whole <= chosen and not whole <= covered:
parts.append(f"c{column}")
covered |= whole
rest = sorted(chosen - covered)
parts.extend(well_label(r, c) for r, c in rest)
return ",".join(parts)