Source code for riverine.locations

from __future__ import annotations

import enum
from math import isnan
from typing import Iterable, Literal, cast, overload

import attrs

__all__ = ["WellPos", "PlateType"]


ROW_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWX"


_96WELL_PLATE_ROWS: list[str] = ["A", "B", "C", "D", "E", "F", "G", "H"]
_96WELL_PLATE_COLS: list[int] = list(range(1, 13))

_384WELL_PLATE_ROWS: list[str] = [
    "A",
    "B",
    "C",
    "D",
    "E",
    "F",
    "G",
    "H",
    "I",
    "J",
    "K",
    "L",
    "M",
    "N",
    "O",
    "P",
]
_384WELL_PLATE_COLS: list[int] = list(range(1, 25))


@enum.unique
[docs] class PlateType(enum.Enum): """Represents two different types of plates in which DNA sequences can be ordered."""
[docs] wells96 = 96
"""96-well plate."""
[docs] wells384 = 384
"""384-well plate."""
[docs] def rows(self) -> list[str]: """ :return: list of all rows in this plate (as letters 'A', 'B', ...) """ return _96WELL_PLATE_ROWS if self is PlateType.wells96 else _384WELL_PLATE_ROWS
[docs] def cols(self) -> list[int]: """ :return: list of all columns in this plate (as integers 1, 2, ...) """ return _96WELL_PLATE_COLS if self is PlateType.wells96 else _384WELL_PLATE_COLS
[docs] def num_wells_per_plate(self) -> int: """ :return: number of wells in this plate type """ if self is PlateType.wells96: return 96 elif self is PlateType.wells384: return 384 else: raise AssertionError("unreachable")
[docs] def min_wells_per_plate(self) -> int: """ :return: minimum number of wells in this plate type to avoid extra charge by IDT """ if self is PlateType.wells96: return 24 elif self is PlateType.wells384: return 96 else: raise AssertionError("unreachable")
@attrs.define(init=False, frozen=True, order=True, hash=True)
[docs] class WellPos: """A Well reference, allowing movement in various directions and bounds checking. This uses 1-indexed row and col, in order to match usual practice. It can take either a standard well reference as a string, or two integers for the row and column. """
[docs] row: int = attrs.field()
[docs] col: int = attrs.field()
[docs] platesize: Literal[96, 384] = 384 # FIXME
@row.validator
[docs] def _validate_row(self, v: int) -> None: rmax = 8 if self.platesize == 96 else 16 if (v <= 0) or (v > rmax): raise ValueError( f"Row {ROW_ALPHABET[v - 1]} ({v}) out of bounds for plate size {self.platesize}" )
@col.validator
[docs] def _validate_col(self, v: int) -> None: cmax = 12 if self.platesize == 96 else 24 if (v <= 0) or (v > cmax): raise ValueError( f"Column {v} out of bounds for plate size {self.platesize}" )
@overload def __init__( self, ref_or_row: int, col: int, /, *, platesize: Literal[96, 384] = 384 ) -> None: # pragma: no cover ... @overload def __init__( self, ref_or_row: str, col: None = None, /, *, platesize: Literal[96, 384] = 384 ) -> None: # pragma: no cover ... def __init__( self, ref_or_row: str | int, col: int | None = None, /, *, platesize: Literal[96, 384] = 384, ) -> None: if isinstance(ref_or_row, str) and (col is None): row: int = ROW_ALPHABET.index(ref_or_row[0]) + 1 col = int(ref_or_row[1:]) elif isinstance(ref_or_row, WellPos) and (col is None): row = ref_or_row.row col = ref_or_row.col platesize = ref_or_row.platesize elif isinstance(ref_or_row, int) and isinstance(col, int): row = ref_or_row col = col else: raise TypeError if platesize not in (96, 384): raise ValueError(f"Plate size {platesize} not supported.") object.__setattr__(self, "platesize", platesize) self._validate_col(cast(int, col)) self._validate_row(row) object.__setattr__(self, "row", row) object.__setattr__(self, "col", col)
[docs] def __str__(self) -> str: return f"{ROW_ALPHABET[self.row - 1]}{self.col}"
[docs] def __repr__(self) -> str: return f'WellPos("{self}")'
[docs] def __eq__(self, other: object) -> bool: if isinstance(other, WellPos): return (other.row == self.row) and (other.col == self.col) elif isinstance(other, str): return self == WellPos(other, platesize=self.platesize) return False
[docs] def key_byrow(self) -> tuple[int, int]: "Get a tuple (row, col) key that can be used for ordering by row." try: return (self.row, self.col) except AttributeError: return (-1, -1)
[docs] def key_bycol(self) -> tuple[int, int]: "Get a tuple (col, row) key that can be used for ordering by column." try: return (self.col, self.row) except AttributeError: return (-1, -1)
[docs] def next_byrow(self) -> WellPos: "Get the next well, moving right along rows, then down." CMAX = 12 if self.platesize == 96 else 24 return WellPos( self.row + (self.col + 1) // (CMAX + 1), (self.col) % CMAX + 1, platesize=self.platesize, )
[docs] def next_bycol(self) -> WellPos: "Get the next well, moving down along columns, and then to the right." RMAX = 8 if self.platesize == 96 else 16 return WellPos( (self.row) % RMAX + 1, self.col + (self.row + 1) // (RMAX + 1), platesize=self.platesize, )
[docs] def is_last(self) -> bool: """ :return: whether WellPos is the last well on this type of plate """ rows = _96WELL_PLATE_ROWS if self.platesize == 96 else _384WELL_PLATE_ROWS cols = _96WELL_PLATE_COLS if self.platesize == 96 else _384WELL_PLATE_COLS return self.row == len(rows) and self.col == len(cols)
[docs] def advance(self, order: Literal["row", "col"] = "col") -> WellPos: """ Advances to the "next" well position. Default is column-major order, i.e., A1, B1, C1, D1, E1, F1, G1, H1, A2, B2, ... To switch to row-major order, select `order` as `'row'`, i.e., A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, B1, B2, ... :return: new WellPos representing the next well position """ rows = _96WELL_PLATE_ROWS if self.platesize == 96 else _384WELL_PLATE_ROWS cols = _96WELL_PLATE_COLS if self.platesize == 96 else _384WELL_PLATE_COLS next_row = self.row next_col = self.col if order == "col": next_row += 1 if next_row == len(rows) + 1: next_row = 1 next_col += 1 if next_col == len(cols) + 1: raise ValueError("cannot advance WellPos; already on last well") else: next_col += 1 if next_col == len(cols) + 1: next_col = 1 next_row += 1 if next_row == len(rows) + 1: raise ValueError("cannot advance WellPos; already on last well") return WellPos(next_row, next_col, platesize=self.platesize)
def mixgaps(wl: Iterable[WellPos], by: Literal["row", "col"]) -> int: score = 0 wli = iter(wl) getnextpos = WellPos.next_bycol if by == "col" else WellPos.next_byrow prevpos = next(wli) for pos in wli: if getnextpos(prevpos) != pos: score += 1 prevpos = pos return score def _parse_wellpos_optional(v: str | WellPos | None) -> WellPos | None: """Parse a string (eg, "C7"), WellPos, or None as potentially a well position, returning either a WellPos or None.""" if isinstance(v, str): return WellPos(v) elif isinstance(v, WellPos): return v elif v is None: return None try: if v.isnan(): # type: ignore return None except Exception: pass try: if isnan(v): # type: ignore return None except Exception: pass raise ValueError(f"Can't interpret {v} as well position or None.")