From 651f5475d6db300a91db6a2e2a4dcd6c4da77968 Mon Sep 17 00:00:00 2001 From: Michael Osthege Date: Sat, 3 Oct 2026 22:14:14 +0200 Subject: [PATCH 1/9] Autoupdate pre-commit --- .pre-commit-config.yaml | 8 ++++---- robotools/evotools/commands.py | 18 +++++++++++++++--- robotools/evotools/types.py | 1 + robotools/evotools/utils.py | 1 + robotools/evotools/worklist.py | 5 ++--- robotools/fluenttools/utils.py | 1 + robotools/liquidhandling/labware.py | 1 - robotools/utils.py | 3 ++- robotools/worklists/base.py | 4 ++-- robotools/worklists/test_base.py | 6 ++---- robotools/worklists/utils.py | 1 + 11 files changed, 31 insertions(+), 18 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5e7639..e5f6b0e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: \.(ipynb|nblink)$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v6.0.0 hooks: - id: check-merge-conflict - id: check-toml @@ -10,16 +10,16 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 9.0.2 hooks: - id: isort name: isort - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 26.5.1 hooks: - id: black - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.3.0 + rev: v2.4.0 hooks: - id: mypy exclude: 'test_.*?\.py$' diff --git a/robotools/evotools/commands.py b/robotools/evotools/commands.py index 85f7379..3b0526b 100644 --- a/robotools/evotools/commands.py +++ b/robotools/evotools/commands.py @@ -1,5 +1,5 @@ """This module implements functions to create advanced worklist commands.""" -from typing import Iterable, List, Optional, Sequence, Tuple, Union + import numpy as np @@ -257,7 +257,13 @@ def evo_aspirate( max_volume = MAX_DILUTOR_VOLUME # perform consistency checks - (wells, labware_position, volume, liquid_class, tips,) = prepare_evo_aspirate_dispense_parameters( + ( + wells, + labware_position, + volume, + liquid_class, + tips, + ) = prepare_evo_aspirate_dispense_parameters( wells=wells, labware_position=labware_position, volume=volume, @@ -334,7 +340,13 @@ def evo_dispense( max_volume = MAX_DILUTOR_VOLUME # perform consistency checks - (wells, labware_position, volume, liquid_class, tips,) = prepare_evo_aspirate_dispense_parameters( + ( + wells, + labware_position, + volume, + liquid_class, + tips, + ) = prepare_evo_aspirate_dispense_parameters( wells=wells, labware_position=labware_position, volume=volume, diff --git a/robotools/evotools/types.py b/robotools/evotools/types.py index 697a42d..ac1fc9d 100644 --- a/robotools/evotools/types.py +++ b/robotools/evotools/types.py @@ -1,4 +1,5 @@ """Enums and classes, and related helper functions.""" + import enum __all__ = ( diff --git a/robotools/evotools/utils.py b/robotools/evotools/utils.py index 8b20a50..364e664 100644 --- a/robotools/evotools/utils.py +++ b/robotools/evotools/utils.py @@ -1,4 +1,5 @@ """Generic utility functions.""" + import re from robotools.liquidhandling import Labware diff --git a/robotools/evotools/worklist.py b/robotools/evotools/worklist.py index 7c01a24..6bad7ed 100644 --- a/robotools/evotools/worklist.py +++ b/robotools/evotools/worklist.py @@ -1,7 +1,6 @@ -""" Creating worklist files for the Tecan Freedom EVO. -""" +"""Creating worklist files for the Tecan Freedom EVO.""" + import logging -import textwrap import warnings from typing import Dict, List, Literal, Optional, Sequence, Tuple, Union diff --git a/robotools/fluenttools/utils.py b/robotools/fluenttools/utils.py index dffa647..6214fab 100644 --- a/robotools/fluenttools/utils.py +++ b/robotools/fluenttools/utils.py @@ -1,4 +1,5 @@ """Generic utility functions.""" + import re from robotools.liquidhandling import Labware diff --git a/robotools/liquidhandling/labware.py b/robotools/liquidhandling/labware.py index ebc71b3..ecee5aa 100644 --- a/robotools/liquidhandling/labware.py +++ b/robotools/liquidhandling/labware.py @@ -1,6 +1,5 @@ """Object-oriented, stateful labware representations.""" - import logging import warnings from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Tuple, Union diff --git a/robotools/utils.py b/robotools/utils.py index 0cfbafe..143b4da 100644 --- a/robotools/utils.py +++ b/robotools/utils.py @@ -1,6 +1,7 @@ """Module with robot-agnostic utilities.""" + import collections -from typing import Callable, Iterable, List, Literal, Optional, Sequence, Tuple, Union +from typing import Callable, List, Literal, Optional, Sequence, Tuple, Union import numpy diff --git a/robotools/worklists/base.py b/robotools/worklists/base.py index c06a715..7b40acb 100644 --- a/robotools/worklists/base.py +++ b/robotools/worklists/base.py @@ -1,5 +1,5 @@ -""" Creating worklist files for the Tecan Freedom EVO. -""" +"""Creating worklist files for the Tecan Freedom EVO.""" + import logging import math from pathlib import Path diff --git a/robotools/worklists/test_base.py b/robotools/worklists/test_base.py index dcbe210..d53d50e 100644 --- a/robotools/worklists/test_base.py +++ b/robotools/worklists/test_base.py @@ -172,12 +172,10 @@ def test_comment(self) -> None: wl.comment("This is a simple comment") with pytest.raises(ValueError): wl.comment("It must not contain ; semicolons") - wl.comment( - """ + wl.comment(""" But it may very well be a multiline comment - """ - ) + """) exp = ["C;This is a simple comment", "C;But it may very well be", "C;a multiline comment"] assert wl == exp return diff --git a/robotools/worklists/utils.py b/robotools/worklists/utils.py index eca8b87..ea52a3d 100644 --- a/robotools/worklists/utils.py +++ b/robotools/worklists/utils.py @@ -1,4 +1,5 @@ """Utility functions that are relevant for worklist commands.""" + import collections import logging import math From 6a4da3c634567bbbb21f26c9bd72dbe58525f68d Mon Sep 17 00:00:00 2001 From: Michael Osthege Date: Sat, 3 Oct 2026 22:14:55 +0200 Subject: [PATCH 2/9] Specify `__all__` in all submodules --- robotools/evotools/__init__.py | 9 +++++++++ robotools/fluenttools/__init__.py | 5 +++++ robotools/liquidhandling/__init__.py | 10 ++++++++++ robotools/worklists/__init__.py | 6 ++++++ 4 files changed, 30 insertions(+) diff --git a/robotools/evotools/__init__.py b/robotools/evotools/__init__.py index c687bf1..84f04f0 100644 --- a/robotools/evotools/__init__.py +++ b/robotools/evotools/__init__.py @@ -2,3 +2,12 @@ from robotools.evotools.utils import get_well_position from robotools.evotools.worklist import EvoWorklist from robotools.worklists.exceptions import InvalidOperationError + +__all__ = ( + "Labwares", + "Tip", + "int_to_tip", + "get_well_position", + "EvoWorklist", + "InvalidOperationError", +) diff --git a/robotools/fluenttools/__init__.py b/robotools/fluenttools/__init__.py index 0f0b254..5ae0300 100644 --- a/robotools/fluenttools/__init__.py +++ b/robotools/fluenttools/__init__.py @@ -1,2 +1,7 @@ from robotools.fluenttools.utils import get_well_position from robotools.fluenttools.worklist import FluentWorklist + +__all__ = ( + "get_well_position", + "FluentWorklist", +) diff --git a/robotools/liquidhandling/__init__.py b/robotools/liquidhandling/__init__.py index cda82d2..aca87d6 100644 --- a/robotools/liquidhandling/__init__.py +++ b/robotools/liquidhandling/__init__.py @@ -6,3 +6,13 @@ VolumeViolationWarning, ) from robotools.liquidhandling.labware import Labware, Trough + +__all__ = ( + "Labware", + "Trough", + "VolumeOverflowError", + "VolumeUnderflowError", + "VolumeUnderflowWarning", + "VolumeViolationException", + "VolumeViolationWarning", +) diff --git a/robotools/worklists/__init__.py b/robotools/worklists/__init__.py index 6346e7d..f2b0a03 100644 --- a/robotools/worklists/__init__.py +++ b/robotools/worklists/__init__.py @@ -1,2 +1,8 @@ from robotools.worklists.base import BaseWorklist from robotools.worklists.exceptions import CompatibilityError, InvalidOperationError + +__all__ = ( + "BaseWorklist", + "CompatibilityError", + "InvalidOperationError", +) From b40eb86f98ed5633670c94ac8f60b14fc0fbcb53 Mon Sep 17 00:00:00 2001 From: Michael Osthege Date: Sat, 3 Oct 2026 22:26:28 +0200 Subject: [PATCH 3/9] Don't reassign with different types --- robotools/liquidhandling/labware.py | 20 ++++++++++---------- robotools/worklists/base.py | 10 +++++----- robotools/worklists/utils.py | 14 ++++++++++++-- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/robotools/liquidhandling/labware.py b/robotools/liquidhandling/labware.py index ecee5aa..f1d5472 100644 --- a/robotools/liquidhandling/labware.py +++ b/robotools/liquidhandling/labware.py @@ -138,18 +138,18 @@ def __init__( # explode convenience parameters if initial_volumes is None: initial_volumes = 0 - initial_volumes = np.array(initial_volumes) - if initial_volumes.shape == (): - initial_volumes = np.full((rows, columns), initial_volumes) + ivols = np.array(initial_volumes) + if ivols.shape == (): + ivols = np.full((rows, columns), ivols) else: - initial_volumes = initial_volumes.reshape((rows, columns)) - assert initial_volumes.shape == ( + ivols = ivols.reshape((rows, columns)) + assert ivols.shape == ( rows, columns, - ), f"Invalid shape of initial_volumes: {initial_volumes.shape}" - if np.any(initial_volumes < 0): + ), f"Invalid shape of initial_volumes: {ivols.shape}" + if np.any(ivols < 0): raise ValueError("initial_volume cannot be negative") - if np.any(initial_volumes > max_volume): + if np.any(ivols > max_volume): raise ValueError("initial_volume cannot be above max_volume") # initialize properties @@ -176,14 +176,14 @@ def __init__( } # initialize state variables - self._volumes = initial_volumes.copy().astype(float) + self._volumes = ivols.copy().astype(float) self._history: List[np.ndarray] = [self.volumes] self._labels: List[Optional[str]] = ["initial"] self._composition = get_initial_composition( name, real_wells=self.wells[[0], :] if virtual_rows else self.wells, component_names=component_names or {}, - initial_volumes=initial_volumes, + initial_volumes=ivols, ) super().__init__() diff --git a/robotools/worklists/base.py b/robotools/worklists/base.py index 7b40acb..d5a457d 100644 --- a/robotools/worklists/base.py +++ b/robotools/worklists/base.py @@ -229,7 +229,7 @@ def aspirate_well( position, volume_s, liquid_class, - tip, + tipv, rack_id, tube_id, rack_type, @@ -248,7 +248,7 @@ def aspirate_well( ) tip_type = "" self.append( - f"A;{rack_label};{rack_id};{rack_type};{position};{tube_id};{volume_s};{liquid_class};{tip_type};{tip};{forced_rack_type}" + f"A;{rack_label};{rack_id};{rack_type};{position};{tube_id};{volume_s};{liquid_class};{tip_type};{tipv};{forced_rack_type}" ) return @@ -537,9 +537,9 @@ def dispense( if vtrack is None: vtrack = volumes else: - vtrack = numpy.array(vtrack).flatten("F") - if len(vtrack) == 1: - vtrack = numpy.repeat(vtrack, len(wells)) + vtrack_arr = numpy.array(vtrack).flatten("F") + if len(vtrack_arr) == 1: + vtrack = numpy.repeat(vtrack_arr, len(wells)) labware.add(wells, vtrack, label, compositions=compositions) self.comment(label) diff --git a/robotools/worklists/utils.py b/robotools/worklists/utils.py index ea52a3d..5182587 100644 --- a/robotools/worklists/utils.py +++ b/robotools/worklists/utils.py @@ -140,8 +140,18 @@ def prepare_aspirate_dispense_parameters( # apply rounding and corrections for the right string formatting volume_str = f"{numpy.round(volume, decimals=2):.2f}" - tip = "" if tip == -1 else tip - return rack_label, position, volume_str, liquid_class, tip, rack_id, tube_id, rack_type, forced_rack_type + outtip: Literal[""] | int = "" if tip == -1 else int(tip) + return ( + rack_label, + position, + volume_str, + liquid_class, + outtip, + rack_id, + tube_id, + rack_type, + forced_rack_type, + ) def optimize_partition_by( From c1b22f59ef012b181747f4cb68964b54da387839 Mon Sep 17 00:00:00 2001 From: Michael Osthege Date: Sat, 3 Oct 2026 22:43:21 +0200 Subject: [PATCH 4/9] Use built-ins, Literal etc., prefer covariant types and hint with `numpy.typing` --- robotools/evotools/commands.py | 55 ++++++++++++----------- robotools/evotools/worklist.py | 39 +++++++++-------- robotools/fluenttools/worklist.py | 15 ++++--- robotools/liquidhandling/composition.py | 20 ++++----- robotools/liquidhandling/exceptions.py | 6 +-- robotools/liquidhandling/labware.py | 58 +++++++++++++------------ robotools/transform.py | 16 ++++--- robotools/typing.py | 12 +++++ robotools/utils.py | 22 ++++++---- robotools/worklists/base.py | 51 +++++++++++----------- robotools/worklists/utils.py | 18 ++++---- 11 files changed, 168 insertions(+), 144 deletions(-) create mode 100644 robotools/typing.py diff --git a/robotools/evotools/commands.py b/robotools/evotools/commands.py index 3b0526b..a84e06d 100644 --- a/robotools/evotools/commands.py +++ b/robotools/evotools/commands.py @@ -1,10 +1,13 @@ """This module implements functions to create advanced worklist commands.""" +from typing import Iterable, Sequence import numpy as np +import numpy.typing as npt from robotools.evotools.types import Tip, int_to_tip from robotools.evotools.utils import to_hex +from robotools.typing import Strings2D from robotools.worklists.exceptions import InvalidOperationError from .. import transform @@ -21,7 +24,7 @@ """"Maximum dilutor volume in µL""" -def evo_make_selection_array(rows: int, columns: int, wells: Union[Iterable[str], np.ndarray]) -> np.ndarray: +def evo_make_selection_array(rows: int, columns: int, wells: Iterable[str] | npt.NDArray[np.str_]) -> np.ndarray: """Translate well IDs to a numpy array with 1s (selected) and 0s (not selected). Parameters @@ -87,15 +90,15 @@ def evo_get_selection(rows: int, cols: int, selected: np.ndarray) -> str: def prepare_evo_aspirate_dispense_parameters( - wells: Union[str, Sequence[str], np.ndarray], + wells: str | Sequence[str] | Strings2D, *, - labware_position: Tuple[int, int], - volume: Union[float, Sequence[float], int], + labware_position: tuple[int, int], + volume: float | Sequence[float] | int, liquid_class: str, - tips: Union[Sequence[Tip], Sequence[int]], + tips: Sequence[Tip] | Sequence[int], arm: int, - max_volume: Optional[Union[int, float]] = None, -) -> Tuple[List[str], Tuple[int, int], List[float], str, List[Tip]]: + max_volume: int | float | None = None, +) -> tuple[list[str], tuple[int, int], list[float], str, list[Tip]]: # wells, labware_position, volume, liquid_class, tecan_tips """Validates and prepares aspirate/dispense parameters. @@ -175,7 +178,7 @@ def prepare_evo_aspirate_dispense_parameters( raise ValueError(f"Invalid volume: {volume}") # apply rounding and corrections for the right string formatting - volume_list: List[float] = np.round(volume, decimals=2).tolist() + volume_list: list[float] = np.round(volume, decimals=2).tolist() if liquid_class is None: raise ValueError(f"Missing required parameter: liquid_class") @@ -216,13 +219,13 @@ def evo_aspirate( *, n_rows: int, n_columns: int, - wells: Union[str, Sequence[str]], - labware_position: Tuple[int, int], - volume: Union[float, Sequence[float], int], + wells: str | Sequence[str], + labware_position: tuple[int, int], + volume: float | Sequence[float] | int, liquid_class: str, - tips: Union[Sequence[Tip], Sequence[int]], + tips: Sequence[Tip] | Sequence[int], arm: int = 0, - max_volume: Optional[Union[int, float]] = np.nan, + max_volume: int | float | None = np.nan, ) -> str: """Command for aspirating with the EvoWARE aspirate command WITHOUT digital volume tracking. @@ -299,13 +302,13 @@ def evo_dispense( *, n_rows: int, n_columns: int, - wells: Union[str, Sequence[str]], - labware_position: Tuple[int, int], - volume: Union[float, Sequence[float], int], + wells: str | Sequence[str], + labware_position: tuple[int, int], + volume: float | Sequence[float] | int, liquid_class: str, - tips: Union[Sequence[Tip], Sequence[int]], + tips: Sequence[Tip] | Sequence[int], arm: int = 0, - max_volume: Optional[Union[int, float]] = np.nan, + max_volume: int | float | None = np.nan, ) -> str: """Command for dispensing using the EvoWARE dispense command WITHOUT digital volume tracking. @@ -380,9 +383,9 @@ def evo_dispense( def prepare_evo_wash_parameters( *, - tips: Union[List[Tip], List[int]], - waste_location: Tuple[int, int], - cleaner_location: Tuple[int, int], + tips: Sequence[Tip] | Sequence[int], + waste_location: tuple[int, int], + cleaner_location: tuple[int, int], arm: int = 0, waste_vol: float = 3.0, waste_delay: int = 500, @@ -393,7 +396,7 @@ def prepare_evo_wash_parameters( retract_speed: int = 30, fastwash: int = 1, low_volume: int = 0, -) -> Tuple[List[Tip], Tuple[int, int], Tuple[int, int], int, float, int, float, int, int, int, int, int, int]: +) -> tuple[list[Tip], tuple[int, int], tuple[int, int], int, float, int, float, int, int, int, int, int, int]: """Validates and prepares aspirate/dispense parameters. Parameters @@ -457,7 +460,7 @@ def prepare_evo_wash_parameters( if tips is None: raise ValueError("Missing required parameter: tips") - tecan_tips: List[Tip] = [] + tecan_tips: list[Tip] = [] for tip in tips: if isinstance(tip, int) and not isinstance(tip, Tip): # User-specified integers from 1-8 need to be converted to Tecan logic @@ -559,9 +562,9 @@ def prepare_evo_wash_parameters( def evo_wash( *, - tips: Union[List[Tip], List[int]], - waste_location: Tuple[int, int], - cleaner_location: Tuple[int, int], + tips: Sequence[Tip] | Sequence[int], + waste_location: tuple[int, int], + cleaner_location: tuple[int, int], arm: int = 0, waste_vol: float = 3.0, waste_delay: int = 500, diff --git a/robotools/evotools/worklist.py b/robotools/evotools/worklist.py index 6bad7ed..29147cd 100644 --- a/robotools/evotools/worklist.py +++ b/robotools/evotools/worklist.py @@ -2,9 +2,10 @@ import logging import warnings -from typing import Dict, List, Literal, Optional, Sequence, Tuple, Union +from typing import Literal, Mapping, Sequence import numpy as np +import numpy.typing as npt from robotools import liquidhandling from robotools.evotools import commands @@ -31,14 +32,14 @@ def _get_well_position(self, labware: liquidhandling.Labware, well: str) -> int: def evo_aspirate( self, labware: liquidhandling.Labware, - wells: Union[str, List[str]], - labware_position: Tuple[int, int], - tips: Union[List[Tip], List[int]], - volumes: Union[float, List[float]], + wells: str | Sequence[str], + labware_position: tuple[int, int], + tips: Sequence[Tip] | Sequence[int], + volumes: float | list[float], liquid_class: str, *, arm: int = 0, - label: Optional[str] = None, + label: str | None = None, on_underflow: Literal["debug", "warn", "raise"] = "raise", ) -> list[float]: """Performs aspiration from the provided labware. Is identical to the aspirate command inside the EvoWARE. @@ -100,15 +101,15 @@ def evo_aspirate( def evo_dispense( self, labware: liquidhandling.Labware, - wells: Union[str, List[str]], - labware_position: Tuple[int, int], - tips: Union[List[Tip], List[int]], - volumes: Union[float, List[float]], + wells: str | Sequence[str], + labware_position: tuple[int, int], + tips: Sequence[Tip] | Sequence[int], + volumes: float | Sequence[float], liquid_class: str, *, arm: int = 0, - label: Optional[str] = None, - compositions: Optional[List[Optional[Dict[str, float]]]] = None, + label: str | None = None, + compositions: Sequence[Mapping[str, float] | None] | None = None, ) -> None: """Performs dispensation from the provided labware. Is identical to the dispense command inside the EvoWARE. Thus, several wells in a single column can be targeted. @@ -158,9 +159,9 @@ def evo_dispense( def evo_wash( self, *, - tips: Union[List[Tip], List[int]], - waste_location: Tuple[int, int], - cleaner_location: Tuple[int, int], + tips: Sequence[Tip] | Sequence[int], + waste_location: tuple[int, int], + cleaner_location: tuple[int, int], arm: int = 0, waste_vol: float = 3.0, waste_delay: int = 500, @@ -226,12 +227,12 @@ def evo_wash( def transfer( self, source: liquidhandling.Labware, - source_wells: Union[str, Sequence[str], np.ndarray], + source_wells: str | Sequence[str] | npt.NDArray[np.str_], destination: liquidhandling.Labware, - destination_wells: Union[str, Sequence[str], np.ndarray], - volumes: Union[float, Sequence[float], np.ndarray], + destination_wells: str | Sequence[str] | npt.NDArray[np.str_], + volumes: float | Sequence[float] | npt.NDArray[np.float64], *, - label: Optional[str] = None, + label: str | None = None, wash_scheme: Literal[1, 2, 3, 4, "flush", "reuse"] = 1, partition_by: str = "auto", on_underflow: Literal["debug", "warn", "raise"] = "raise", diff --git a/robotools/fluenttools/worklist.py b/robotools/fluenttools/worklist.py index 8f4775e..44b4bb0 100644 --- a/robotools/fluenttools/worklist.py +++ b/robotools/fluenttools/worklist.py @@ -1,9 +1,10 @@ import warnings from collections.abc import Sequence from pathlib import Path -from typing import Literal, Optional, Union +from typing import Literal import numpy as np +import numpy.typing as npt from robotools.fluenttools.utils import get_well_position from robotools.liquidhandling.labware import Labware @@ -22,8 +23,8 @@ class FluentWorklist(BaseWorklist): def __init__( self, - filepath: Optional[Union[str, Path]] = None, - max_volume: Union[int, float] = 950, + filepath: str | Path | None = None, + max_volume: int | float = 950, auto_split: bool = True, diti_mode: bool = False, ) -> None: @@ -35,12 +36,12 @@ def _get_well_position(self, labware: Labware, well: str) -> int: def transfer( self, source: Labware, - source_wells: Union[str, Sequence[str], np.ndarray], + source_wells: str | Sequence[str] | np.ndarray[np.str_], destination: Labware, - destination_wells: Union[str, Sequence[str], np.ndarray], - volumes: Union[float, Sequence[float], np.ndarray], + destination_wells: str | Sequence[str] | npt.NDArray[np.str_], + volumes: float | Sequence[float] | npt.NDArray[np.float64], *, - label: Optional[str] = None, + label: str | None = None, wash_scheme: Literal[1, 2, 3, 4, "flush", "reuse"] = 1, partition_by: str = "auto", on_underflow: Literal["debug", "warn", "raise"] = "raise", diff --git a/robotools/liquidhandling/composition.py b/robotools/liquidhandling/composition.py index 5cd2c2b..dd3f3a3 100644 --- a/robotools/liquidhandling/composition.py +++ b/robotools/liquidhandling/composition.py @@ -1,16 +1,16 @@ """Functions for tracking fluid composition through liquid handling operations.""" -from typing import Dict, Mapping, Optional, Sequence, Union +from typing import Mapping, Sequence import numpy as np def combine_composition( volume_A: float, - composition_A: Optional[Mapping[str, float]], + composition_A: Mapping[str, float] | None, volume_B: float, - composition_B: Optional[Mapping[str, float]], -) -> Optional[Dict[str, float]]: + composition_B: Mapping[str, float] | None, +) -> dict[str, float] | None: """Computes the composition of a liquid, created by the mixing of two liquids (A and B). Parameters @@ -46,9 +46,9 @@ def combine_composition( def get_initial_composition( name: str, real_wells: np.ndarray, - component_names: Mapping[str, Optional[str]], + component_names: Mapping[str, str | None], initial_volumes: np.ndarray, -) -> Dict[str, np.ndarray]: +) -> dict[str, np.ndarray]: """Creates a dictionary of initial composition arrays. Parameters @@ -73,7 +73,7 @@ def get_initial_composition( raise ValueError(f"Invalid component name keys: {illegal_component_wells}") is_multiwell = len(real_wells) > 1 - composition: Dict[str, np.ndarray] = {} + composition: dict[str, np.ndarray] = {} for idx, w in np.ndenumerate(real_wells): # Ignore None-valued component names, but don't allow naming of empty wells. if initial_volumes[idx] == 0: @@ -101,9 +101,9 @@ def get_initial_composition( def get_trough_component_names( name: str, columns: int, - column_names: Sequence[Optional[str]], - initial_volumes: Sequence[Union[int, float]], -) -> Dict[str, Optional[str]]: + column_names: Sequence[str | None], + initial_volumes: Sequence[int | float], +) -> dict[str, str | None]: """Determines a fully-specified component name dictionary for a trough. This helper function exists to provide a different default naming pattern for troughs. diff --git a/robotools/liquidhandling/exceptions.py b/robotools/liquidhandling/exceptions.py index 8dc83d1..079ac33 100644 --- a/robotools/liquidhandling/exceptions.py +++ b/robotools/liquidhandling/exceptions.py @@ -1,7 +1,5 @@ """Exceptions that indicate problems in liquid handling.""" -from typing import Optional - __all__ = ( "VolumeOverflowError", "VolumeUnderflowError", @@ -29,7 +27,7 @@ def __init__( current: float, change: float, threshold: float, - label: Optional[str] = None, + label: str | None = None, ) -> None: if label: super().__init__( @@ -53,7 +51,7 @@ def __init__( current: float, change: float, threshold: float, - label: Optional[str] = None, + label: str | None = None, ) -> None: if label: super().__init__( diff --git a/robotools/liquidhandling/labware.py b/robotools/liquidhandling/labware.py index f1d5472..64f220a 100644 --- a/robotools/liquidhandling/labware.py +++ b/robotools/liquidhandling/labware.py @@ -2,9 +2,10 @@ import logging import warnings -from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Literal, Mapping, Sequence import numpy as np +import numpy.typing as npt from robotools.liquidhandling.composition import ( combine_composition, @@ -16,6 +17,7 @@ VolumeUnderflowError, VolumeUnderflowWarning, ) +from robotools.typing import Floats2D, Strings2D _log = logging.getLogger(__name__) @@ -24,7 +26,7 @@ class Labware: """Represents an array of liquid cavities.""" @property - def history(self) -> List[Tuple[Optional[str], np.ndarray]]: + def history(self) -> list[tuple[str | None, Floats2D]]: """List of label/volumes history.""" return list(zip(self._labels, self._history)) @@ -40,17 +42,17 @@ def report(self) -> str: return report @property - def volumes(self) -> np.ndarray: + def volumes(self) -> Floats2D: """Current volumes in the labware.""" return self._volumes.copy() @property - def wells(self) -> np.ndarray: + def wells(self) -> Strings2D: """Array of well ids.""" return self._wells @property - def indices(self) -> Dict[str, Tuple[int, int]]: + def indices(self) -> dict[str, tuple[int, int]]: """Mapping of well-ids to numpy indices.""" return self._indices @@ -63,7 +65,7 @@ def n_columns(self) -> int: return len(self.column_ids) @property - def shape(self) -> Tuple[int, int]: + def shape(self) -> tuple[int, int]: """Number of rows and columns.""" return self.wells.shape @@ -72,7 +74,7 @@ def is_trough(self) -> bool: return self.virtual_rows != None @property - def composition(self) -> Dict[str, np.ndarray]: + def composition(self) -> dict[str, Floats2D]: """Relative composition of the liquids. This dictionary maps liquid names (keys) to arrays of relative amounts in each well. @@ -87,9 +89,9 @@ def __init__( *, min_volume: float, max_volume: float, - initial_volumes: Optional[Union[float, np.ndarray]] = None, - virtual_rows: Optional[int] = None, - component_names: Optional[Mapping[str, Optional[str]]] = None, + initial_volumes: float | Floats2D | None = None, + virtual_rows: int | None = None, + component_names: Mapping[str, str | None] | None = None, ) -> None: """Creates a `Labware` object. @@ -177,8 +179,8 @@ def __init__( # initialize state variables self._volumes = ivols.copy().astype(float) - self._history: List[np.ndarray] = [self.volumes] - self._labels: List[Optional[str]] = ["initial"] + self._history: list[np.ndarray] = [self.volumes] + self._labels: list[str | None] = ["initial"] self._composition = get_initial_composition( name, real_wells=self.wells[[0], :] if virtual_rows else self.wells, @@ -187,7 +189,7 @@ def __init__( ) super().__init__() - def get_well_composition(self, well: str) -> Dict[str, float]: + def get_well_composition(self, well: str) -> dict[str, float]: """Retrieves the relative composition of a well. Parameters @@ -209,10 +211,10 @@ def get_well_composition(self, well: str) -> Dict[str, float]: def add( self, - wells: Union[str, Sequence[str], np.ndarray], - volumes: Union[float, Sequence[float], np.ndarray], - label: Optional[str] = None, - compositions: Optional[Sequence[Optional[Mapping[str, float]]]] = None, + wells: str | Sequence[str] | npt.NDArray[np.str_], + volumes: float | Sequence[float] | npt.NDArray[np.float64], + label: str | None = None, + compositions: Sequence[Mapping[str, float] | None] | None = None, ) -> None: """Adds volumes to wells. @@ -268,9 +270,9 @@ def add( def remove( self, - wells: Union[str, Sequence[str], np.ndarray], - volumes: Union[float, Sequence[float], np.ndarray], - label: Optional[str] = None, + wells: str | Sequence[str] | npt.NDArray[np.str_], + volumes: float | Sequence[float] | npt.NDArray[np.float64], + label: str | None = None, *, on_underflow: Literal["debug", "warn", "raise"] = "raise", ) -> list[float]: @@ -304,7 +306,7 @@ def remove( volumes = np.repeat(volumes, len(wells)) assert len(volumes) == len(wells), "Number of volumes must number of wells" assert np.all(volumes >= 0), "Volumes must be positive or zero." - vaspirated = [] + vaspirated: list[float] = [] for well, volume in zip(wells, volumes): idx = self.indices[well] v_original = self._volumes[idx] @@ -327,7 +329,7 @@ def remove( self.log(label) return vaspirated - def log(self, label: Optional[str]) -> None: + def log(self, label: str | None) -> None: """Logs the current volumes to the history. Parameters @@ -339,7 +341,7 @@ def log(self, label: Optional[str]) -> None: self._labels.append(label) return - def condense_log(self, n: int, label: Optional[str] = "last") -> None: + def condense_log(self, n: int, label: Literal["first", "last"] | str | None = "last") -> None: """Condense the last n log entries. Parameters @@ -388,7 +390,7 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def _from_dict_init_kwargs(cls, data: Mapping[str, Any]) -> Dict[str, Any]: + def _from_dict_init_kwargs(cls, data: Mapping[str, Any]) -> dict[str, Any]: """Extract initializer parameters from a dict created by :meth:`Labware.to_dict()`.""" return { "name": data["name"], @@ -399,7 +401,7 @@ def _from_dict_init_kwargs(cls, data: Mapping[str, Any]) -> Dict[str, Any]: } @classmethod - def _from_dict_attributes(cls, data: Mapping[str, Any], vshape: tuple[int, int]) -> Dict[str, Any]: + def _from_dict_attributes(cls, data: Mapping[str, Any], vshape: tuple[int, int]) -> dict[str, Any]: """Extracts state attributes from a dict created by :meth:`Labware.to_dict()`. Parameters @@ -479,8 +481,8 @@ def __init__( *, min_volume: float, max_volume: float, - initial_volumes: Union[float, Sequence[float], np.ndarray] = 0, - column_names: Optional[Sequence[Optional[str]]] = None, + initial_volumes: float | Sequence[float] | Floats2D = 0, + column_names: Sequence[str | None] | None = None, ) -> None: """Creates a `Labware` object. @@ -528,7 +530,7 @@ def __init__( ) @classmethod - def _from_dict_init_kwargs(cls, data: Mapping[str, Any]) -> Dict[str, Any]: + def _from_dict_init_kwargs(cls, data: Mapping[str, Any]) -> dict[str, Any]: """Extract initializer parameters from a dict created by :meth:`Labware.to_dict()`. Overrides the superclass method to account for the different initializer signature of troughs. diff --git a/robotools/transform.py b/robotools/transform.py index a86e7a5..143cd86 100644 --- a/robotools/transform.py +++ b/robotools/transform.py @@ -1,10 +1,12 @@ -from typing import Dict, Literal, Tuple +from typing import Literal import numpy from numpy.typing import ArrayLike +from robotools.typing import Strings2D -def make_well_index_dict(R: int, C: int) -> Dict[str, Tuple[int, int]]: + +def make_well_index_dict(R: int, C: int) -> dict[str, tuple[int, int]]: """Create a dictionary mapping well IDs to their numpy indices. Parameters @@ -26,7 +28,7 @@ def make_well_index_dict(R: int, C: int) -> Dict[str, Tuple[int, int]]: } -def make_well_array(R: int, C: int) -> numpy.ndarray: +def make_well_array(R: int, C: int) -> Strings2D: """Create a numpy array of well IDs. Parameters @@ -49,7 +51,7 @@ def make_well_array(R: int, C: int) -> numpy.ndarray: class WellShifter: """Helper object to shift a set of well IDs within a MTP.""" - def __init__(self, shape_A: Tuple[int, int], shape_B: Tuple[int, int], shifted_A01: str) -> None: + def __init__(self, shape_A: tuple[int, int], shape_B: tuple[int, int], shifted_A01: str) -> None: """Create a helper object for shifting wells around. Parameters @@ -123,7 +125,7 @@ def unshift(self, wells: ArrayLike) -> numpy.ndarray: class WellRotator: """Helper object to rotate a set of well IDs within a MTP.""" - def __init__(self, original_shape: Tuple[int, int]) -> None: + def __init__(self, original_shape: tuple[int, int]) -> None: """Create a helper object for shifting wells around. Parameters @@ -189,7 +191,7 @@ class WellRandomizer: def __init__( self, - original_shape: Tuple[int, int], + original_shape: tuple[int, int], random_seed: int, *, mode: Literal["full", "row", "column"] = "full", @@ -210,7 +212,7 @@ def __init__( self.original_shape = original_shape self.random_seed = random_seed self.rng = numpy.random.RandomState(self.random_seed) - self.lookup: Dict[str, str] = {} + self.lookup: dict[str, str] = {} full = make_well_array(*self.original_shape) if mode == "full": self.original_wells = full.flatten() diff --git a/robotools/typing.py b/robotools/typing.py new file mode 100644 index 0000000..3e26727 --- /dev/null +++ b/robotools/typing.py @@ -0,0 +1,12 @@ +"""Type hints for internal use, including aliases for NumPy arrays. + +Note: NumPy 2.6 adds npt.Array2D, but we like to keep some backwards-compat. +""" + +from typing import TypeAlias + +import numpy as np + +Bools2D: TypeAlias = np.ndarray[tuple[int, int], np.bool] +Floats2D: TypeAlias = np.ndarray[tuple[int, int], np.float64] +Strings2D: TypeAlias = np.ndarray[tuple[int, int], np.str_] diff --git a/robotools/utils.py b/robotools/utils.py index 143b4da..412bfea 100644 --- a/robotools/utils.py +++ b/robotools/utils.py @@ -1,15 +1,19 @@ """Module with robot-agnostic utilities.""" import collections -from typing import Callable, List, Literal, Optional, Sequence, Tuple, Union +from typing import Callable, Literal, Sequence import numpy +import numpy.typing as npt from robotools.liquidhandling import Labware from robotools.worklists.base import BaseWorklist -def get_trough_wells(n: int, trough_wells: Union[collections.abc.Iterable[str], numpy.ndarray]) -> List[str]: +def get_trough_wells( + n: int, + trough_wells: collections.abc.Iterable[str] | npt.NDArray[numpy.str_], +) -> list[str]: """Creates a list that re-uses trough wells if needed. When n > trough.virtual_rows, the available wells are repeated. @@ -50,8 +54,8 @@ def __init__( R: int, C: int, stock: float, - mode: str, - vmax: Union[float, Sequence[float]], + mode: Literal["log", "linear"], + vmax: float | Sequence[float], min_transfer: float, ) -> None: """Plans a regularly-spaced dilution series with in very few steps. @@ -98,7 +102,7 @@ def __init__( # collect preparation instructions for each columns # (column, dilution steps, prepared from, transfer volumes) - instructions: List[Tuple[int, int, Union[int, str], numpy.ndarray]] = [] + instructions: list[tuple[int, int, int | str, numpy.ndarray]] = [] actual_targets = [] # transfer from stock until the volume is too low @@ -174,10 +178,10 @@ def to_worklist( diluent: Labware, diluent_column: int = 0, dilution_plate: Labware, - destination_plate: Optional[Labware] = None, - v_destination: Optional[float] = None, - pre_mix_hook: Optional[Callable[[int, BaseWorklist], Optional[BaseWorklist]]] = None, - post_mix_hook: Optional[Callable[[int, BaseWorklist], Optional[BaseWorklist]]] = None, + destination_plate: Labware | None = None, + v_destination: float | None = None, + pre_mix_hook: Callable[[int, BaseWorklist], BaseWorklist | None] | None = None, + post_mix_hook: Callable[[int, BaseWorklist], BaseWorklist | None] | None = None, mix_threshold: float = 0.05, mix_wash: Literal[1, 2, 3, 4, "flush", "reuse"] = 2, mix_repeat: int = 2, diff --git a/robotools/worklists/base.py b/robotools/worklists/base.py index d5a457d..afe2570 100644 --- a/robotools/worklists/base.py +++ b/robotools/worklists/base.py @@ -3,9 +3,10 @@ import logging import math from pathlib import Path -from typing import Dict, Iterable, List, Literal, Optional, Sequence, Union +from typing import Iterable, Literal, Mapping, Sequence import numpy +import numpy.typing as npt from typing_extensions import Self from robotools import liquidhandling @@ -24,8 +25,8 @@ class BaseWorklist(list[str]): def __init__( self, - filepath: Optional[Union[str, Path]] = None, - max_volume: Union[int, float] = 950, + filepath: str | Path | None = None, + max_volume: int | float = 950, auto_split: bool = True, diti_mode: bool = False, ) -> None: @@ -44,7 +45,7 @@ def __init__( Activate this when using DiTis. Uses ``W;`` for all wash schemes and raises errors when using commands that are only for fixed tips. """ - self._filepath: Optional[Path] = None + self._filepath: Path | None = None if filepath is not None: self._filepath = Path(filepath) if max_volume is None: @@ -55,7 +56,7 @@ def __init__( super().__init__() @property - def filepath(self) -> Optional[Path]: + def filepath(self) -> Path | None: """Path to which the worklist will write, if specified.""" if self._filepath is not None: return Path(self._filepath) @@ -76,7 +77,7 @@ def _get_well_position(self, labware: Labware, well: str) -> int: "The use of a specific worklist type (typically EvoWorklist or FluentWorklist) is required for this operation." ) - def save(self, filepath: Union[str, Path]) -> None: + def save(self, filepath: str | Path) -> None: """Writes the worklist to the filepath. Parameters @@ -91,7 +92,7 @@ def save(self, filepath: Union[str, Path]) -> None: file.write("\n".join(self)) return - def comment(self, comment: Optional[str]) -> None: + def comment(self, comment: str | None) -> None: """Adds a comment. Parameters @@ -192,7 +193,7 @@ def aspirate_well( volume: float, *, liquid_class: str = "", - tip: Union[Tip, int, Iterable] = Tip.Any, + tip: Tip | int | Iterable[Tip | int] = Tip.Any, rack_id: str = "", tube_id: str = "", rack_type: str = "", @@ -259,7 +260,7 @@ def dispense_well( volume: float, *, liquid_class: str = "", - tip: Union[Tip, int] = Tip.Any, + tip: Tip | int = Tip.Any, rack_id: str = "", tube_id: str = "", rack_type: str = "", @@ -332,9 +333,9 @@ def reagent_distribution( volume: float, diti_reuse: int = 1, multi_disp: int = 1, - exclude_wells: Optional[Iterable[int]] = None, + exclude_wells: Iterable[int] | None = None, liquid_class: str = "", - direction: str = "left_to_right", + direction: Literal["left_to_right", "right_to_left"] = "left_to_right", src_rack_id: str = "", src_rack_type: str = "", dst_rack_id: str = "", @@ -445,10 +446,10 @@ def reagent_distribution( def aspirate( self, labware: liquidhandling.Labware, - wells: Union[str, Sequence[str], numpy.ndarray], - volumes: Union[float, Sequence[float], numpy.ndarray], + wells: str | Sequence[str] | npt.NDArray[numpy.str_], + volumes: float | Sequence[float] | npt.NDArray[numpy.float64], *, - label: Optional[str] = None, + label: str | None = None, on_underflow: Literal["debug", "warn", "raise"] = "raise", **kwargs, ) -> list[float]: @@ -496,12 +497,12 @@ def aspirate( def dispense( self, labware: liquidhandling.Labware, - wells: Union[str, Sequence[str], numpy.ndarray], - volumes: Union[float, Sequence[float], numpy.ndarray], + wells: str | Sequence[str] | npt.NDArray[numpy.str_], + volumes: float | Sequence[float] | npt.NDArray[numpy.float64], *, - label: Optional[str] = None, - compositions: Optional[List[Optional[Dict[str, float]]]] = None, - vtrack: Union[float, Sequence[float], numpy.ndarray] | None = None, + label: str | None = None, + compositions: Sequence[Mapping[str, float] | None] | None = None, + vtrack: float | Sequence[float] | npt.NDArray[numpy.float64] | None = None, **kwargs, ) -> None: """Performs dispensing into the provided labware. @@ -551,12 +552,12 @@ def dispense( def transfer( self, source: liquidhandling.Labware, - source_wells: Union[str, Sequence[str], numpy.ndarray], + source_wells: str | Sequence[str] | npt.NDArray[numpy.str_], destination: liquidhandling.Labware, - destination_wells: Union[str, Sequence[str], numpy.ndarray], - volumes: Union[float, Sequence[float], numpy.ndarray], + destination_wells: str | Sequence[str] | npt.NDArray[numpy.str_], + volumes: float | Sequence[float] | npt.NDArray[numpy.float64], *, - label: Optional[str] = None, + label: str | None = None, wash_scheme: Literal[1, 2, 3, 4, "flush", "reuse"] = 1, partition_by: str = "auto", **kwargs, @@ -571,14 +572,14 @@ def distribute( source: liquidhandling.Labware, source_column: int, destination: liquidhandling.Labware, - destination_wells: Union[str, Sequence[str], numpy.ndarray], + destination_wells: str | Sequence[str] | npt.NDArray[numpy.str_], *, volume: float, diti_reuse: int = 1, multi_disp: int = 1, liquid_class: str = "", label: str = "", - direction: str = "left_to_right", + direction: Literal["left_to_right", "right_to_left"] = "left_to_right", src_rack_id: str = "", src_rack_type: str = "", dst_rack_id: str = "", diff --git a/robotools/worklists/utils.py b/robotools/worklists/utils.py index 5182587..2931358 100644 --- a/robotools/worklists/utils.py +++ b/robotools/worklists/utils.py @@ -3,7 +3,7 @@ import collections import logging import math -from typing import Dict, Iterable, List, Literal, Optional, Sequence, Tuple, Union +from typing import Iterable, Literal, Sequence import numpy @@ -27,13 +27,13 @@ def prepare_aspirate_dispense_parameters( position: int, volume: float, liquid_class: str = "", - tip: Union[Tip, int, collections.abc.Iterable] = Tip.Any, + tip: Tip | int | Iterable[int | Tip] = Tip.Any, rack_id: str = "", tube_id: str = "", rack_type: str = "", forced_rack_type: str = "", - max_volume: Optional[Union[int, float]] = None, -) -> Tuple[str, int, str, str, Union[Tip, int, collections.abc.Iterable], str, str, str, str]: + max_volume: int | float | None = None, +) -> tuple[str, int, str, str, int | Literal[""], str, str, str, str]: """Validates and prepares aspirate/dispense parameters. Parameters @@ -158,7 +158,7 @@ def optimize_partition_by( source: liquidhandling.Labware, destination: liquidhandling.Labware, partition_by: str, - label: Optional[str] = None, + label: str | None = None, ) -> Literal["source", "destination"]: """Determines optimal partitioning settings. @@ -218,7 +218,7 @@ def optimize_partition_by( raise ValueError(f"Invalid partition_by argument: {partition_by}") -def partition_volume(volume: float, *, max_volume: Union[int, float]) -> List[float]: +def partition_volume(volume: float, *, max_volume: int | float) -> list[float]: """Partitions a pipetting volume into zero or more integer-valued volumes that are <= max_volume. Parameters @@ -239,7 +239,7 @@ def partition_volume(volume: float, *, max_volume: Union[int, float]) -> List[fl return [volume] isteps = math.ceil(volume / max_volume) step_volume = math.ceil(volume / isteps) - volumes: List[float] = [step_volume] * (isteps - 1) + volumes: list[float] = [step_volume] * (isteps - 1) volumes.append(volume - numpy.sum(volumes)) return volumes @@ -270,7 +270,7 @@ def partition_by_column( destinations: Iterable[str], volumes: Iterable[float], partition_by: Literal["source", "destination"], -) -> List[Tuple[List[str], List[str], List[float]]]: +) -> list[tuple[list[str], list[str], list[float]]]: """Partitions sources/destinations/volumes by the source column and sorts within those columns. Parameters @@ -290,7 +290,7 @@ def partition_by_column( A list of (sources, destinations, volumes) """ # first partition the wells into columns - column_groups_dd: Dict[str, Tuple[List[str], List[str], List[float]]] = collections.defaultdict( + column_groups_dd: dict[str, tuple[list[str], list[str], list[float]]] = collections.defaultdict( lambda: ([], [], []) ) for s, d, v in zip(sources, destinations, volumes): From e909bd124d3f09ba371d501898f10a6855bf4a78 Mon Sep 17 00:00:00 2001 From: Michael Osthege Date: Sat, 3 Oct 2026 22:44:38 +0200 Subject: [PATCH 5/9] Change `evo_make_selection_array` to return bool array using `np.isin` --- robotools/evotools/commands.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/robotools/evotools/commands.py b/robotools/evotools/commands.py index a84e06d..9af139a 100644 --- a/robotools/evotools/commands.py +++ b/robotools/evotools/commands.py @@ -7,7 +7,7 @@ from robotools.evotools.types import Tip, int_to_tip from robotools.evotools.utils import to_hex -from robotools.typing import Strings2D +from robotools.typing import Bools2D, Strings2D from robotools.worklists.exceptions import InvalidOperationError from .. import transform @@ -24,7 +24,7 @@ """"Maximum dilutor volume in µL""" -def evo_make_selection_array(rows: int, columns: int, wells: Iterable[str] | npt.NDArray[np.str_]) -> np.ndarray: +def evo_make_selection_array(rows: int, columns: int, wells: Iterable[str] | npt.NDArray[np.str_]) -> Bools2D: """Translate well IDs to a numpy array with 1s (selected) and 0s (not selected). Parameters @@ -41,17 +41,10 @@ def evo_make_selection_array(rows: int, columns: int, wells: Iterable[str] | npt selection_array : np.ndarray Numpy array in labware dimensions with selected wells as 1 and others as 0 """ - # create array with a shape beffiting the labware dimensions - selection_array = np.zeros((rows, columns)) - # get a dictionary with the "coordinates" of well IDs (A01, B01 etc) as tuples - well_index_dict = transform.make_well_index_dict(rows, columns) - # insert 1s for all selected wells - for well in np.asarray(wells).flatten(): - selection_array[well_index_dict[well]] = 1 - return selection_array + return np.isin(transform.make_well_array(rows, columns), wells) -def evo_get_selection(rows: int, cols: int, selected: np.ndarray) -> str: +def evo_get_selection(rows: int, cols: int, selected: Bools2D) -> str: """Function to generate the code string for the well selection of pipetting actions in EvoWare scripts (.esc). Adapted from the C++ function detailed in the EvoWare manual to Python by Martin Beyß (except the test at the end). @@ -76,7 +69,7 @@ def evo_get_selection(rows: int, cols: int, selected: np.ndarray) -> str: bit_mask = 0 for x in range(cols): for y in range(rows): - if selected[y, x] == 1: + if selected[y, x]: bit_mask |= 1 << bit_counter bit_counter += 1 if bit_counter > 6: @@ -205,9 +198,9 @@ def prepare_evo_aspirate_dispense_parameters( return wells_list, labware_position, volume_list, liquid_class, tecan_tips -def require_single_column_selection(selection: np.ndarray): +def require_single_column_selection(selection: Bools2D): """Raises an error if wells from more than one column are selected.""" - ncols = np.any(selection > 0, axis=0).sum() + ncols = np.any(selection, axis=0).sum() if ncols >= 2: raise ValueError( "Wells from more than one column are selected.\nSelect only wells from one column per pipetting action." From 45fc45edba4cd798c484af35544ce6417f884abb Mon Sep 17 00:00:00 2001 From: Michael Osthege Date: Sat, 3 Oct 2026 22:54:16 +0200 Subject: [PATCH 6/9] A bit of docstring formatting --- robotools/evotools/commands.py | 4 +++- robotools/evotools/utils.py | 1 + robotools/evotools/worklist.py | 12 +++++++++--- robotools/liquidhandling/labware.py | 4 ++-- robotools/utils.py | 4 ++-- robotools/worklists/base.py | 2 +- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/robotools/evotools/commands.py b/robotools/evotools/commands.py index 9af139a..a741220 100644 --- a/robotools/evotools/commands.py +++ b/robotools/evotools/commands.py @@ -569,7 +569,9 @@ def evo_wash( fastwash: int = 1, low_volume: int = 0, ) -> str: - """Command for aspirating with the EvoWARE aspirate command. As many wells in one column may be selected as your liquid handling arm has pipettes. + """Command for aspirating with the EvoWARE aspirate command. + + As many wells in one column may be selected as your liquid handling arm has pipettes. This method generates the full command (as can be observed when opening a .esc file with an editor) and calls upon other functions to create the code string specifying the target wells. diff --git a/robotools/evotools/utils.py b/robotools/evotools/utils.py index 364e664..97a467b 100644 --- a/robotools/evotools/utils.py +++ b/robotools/evotools/utils.py @@ -7,6 +7,7 @@ def to_hex(dec: int): """Method from stackoverflow to convert decimal to hex. + Link: https://stackoverflow.com/questions/5796238/python-convert-decimal-to-hex Solution posted by user "Chunghee Kim" on 21.11.2020. """ diff --git a/robotools/evotools/worklist.py b/robotools/evotools/worklist.py index 29147cd..2a304e8 100644 --- a/robotools/evotools/worklist.py +++ b/robotools/evotools/worklist.py @@ -42,7 +42,9 @@ def evo_aspirate( label: str | None = None, on_underflow: Literal["debug", "warn", "raise"] = "raise", ) -> list[float]: - """Performs aspiration from the provided labware. Is identical to the aspirate command inside the EvoWARE. + """Performs aspiration from the provided labware. + + Is identical to the aspirate command inside the EvoWARE. Thus, several wells in a single column can be targeted. Parameters @@ -111,7 +113,9 @@ def evo_dispense( label: str | None = None, compositions: Sequence[Mapping[str, float] | None] | None = None, ) -> None: - """Performs dispensation from the provided labware. Is identical to the dispense command inside the EvoWARE. + """Performs dispensation from the provided labware. + + Is identical to the dispense command inside the EvoWARE. Thus, several wells in a single column can be targeted. Parameters @@ -173,7 +177,9 @@ def evo_wash( fastwash: int = 1, low_volume: int = 0, ) -> None: - """Command for aspirating with the EvoWARE aspirate command. As many wells in one column may be selected as your liquid handling arm has pipettes. + """Command for aspirating with the EvoWARE aspirate command. + + As many wells in one column may be selected as your liquid handling arm has pipettes. This method generates the full command (as can be observed when opening a .esc file with an editor) and calls upon other functions to create the code string specifying the target wells. diff --git a/robotools/liquidhandling/labware.py b/robotools/liquidhandling/labware.py index 64f220a..6ab60d2 100644 --- a/robotools/liquidhandling/labware.py +++ b/robotools/liquidhandling/labware.py @@ -373,7 +373,7 @@ def __str__(self) -> str: def to_dict(self) -> dict[str, Any]: """Serialize to a dict of only atomic data types. - Use :meth:`Labware.from_dict()` to recreate a labware from the dict produced by this method. + Use :meth:`Labware.from_dict` to recreate a labware from the dict produced by this method. """ return { "name": self.name, @@ -433,7 +433,7 @@ def from_dict(cls, data: Mapping[str, Any], cls_trough: type | None = None) -> " Parameters ---------- data - Dict created by :meth:`Labware.to_dict()`. + Dict created by :meth:`Labware.to_dict`. cls_trough Optional trough type to use in case the data dict corresponds to a trough labware. Defaults to :class:`Trough` with a warning if the main class is not a `Trough` itself. diff --git a/robotools/utils.py b/robotools/utils.py index 412bfea..042eaba 100644 --- a/robotools/utils.py +++ b/robotools/utils.py @@ -16,7 +16,7 @@ def get_trough_wells( ) -> list[str]: """Creates a list that re-uses trough wells if needed. - When n > trough.virtual_rows, the available wells are repeated. + When ``n > trough.virtual_rows``, the available wells are repeated. Parameters ---------- @@ -191,7 +191,7 @@ def to_worklist( lc_mix: str = "Water_DispZmax-3_AspZmax-5", lc_transfer: str = "Water_FD_AspZmax-1", ) -> None: - """Writes the `DilutionPlan` to a `Worklist`. + """Writes the `DilutionPlan` to a :class:`BaseWorklist`. The stock is assumed to be non-sedimenting (e.g. by stirring), but all aspirations from freshly diluted wells are done right away. diff --git a/robotools/worklists/base.py b/robotools/worklists/base.py index afe2570..8329bb1 100644 --- a/robotools/worklists/base.py +++ b/robotools/worklists/base.py @@ -1,4 +1,4 @@ -"""Creating worklist files for the Tecan Freedom EVO.""" +"""Creating worklist files for the Tecan Freedom EVO or Fluent liquid handlers.""" import logging import math From 83a76df7ab498ca82f0aae4044872468cd4dfd36 Mon Sep 17 00:00:00 2001 From: Michael Osthege Date: Sat, 3 Oct 2026 22:57:29 +0200 Subject: [PATCH 7/9] Bump minor, drop Python 3.10 (EOL 2 days ago), test up to 3.14 --- .github/workflows/pipeline.yml | 2 +- .github/workflows/release.yml | 2 +- .gitignore | 1 + pyproject.toml | 8 ++++---- robotools/worklists/base.py | 3 +-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 381f573..0fe115e 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 096fa00..4d71ddc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: "3.11" + python-version: "3.14" - name: Install dependencies run: | pip install -e . diff --git a/.gitignore b/.gitignore index 3934c3e..8897d0a 100644 --- a/.gitignore +++ b/.gitignore @@ -477,3 +477,4 @@ healthchecksdb .vscode *.gwl *Sandbox*.ipynb +uv.lock diff --git a/pyproject.toml b/pyproject.toml index 8b03a2f..908843f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "robotools" -version = "1.15.0" +version = "1.16.0" description = "Pythonic in-silico liquid handling and creation of Tecan FreedomEVO worklists." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = {text = "GNU Affero General Public License v3"} authors = [ {name = "Michael Osthege", email = "m.osthege@fz-juelich.de"}, @@ -15,16 +15,16 @@ authors = [ classifiers = [ "Programming Language :: Python", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "License :: OSI Approved :: GNU Affero General Public License v3", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering", ] dependencies = [ "numpy", - "typing_extensions", ] [project.urls] diff --git a/robotools/worklists/base.py b/robotools/worklists/base.py index 8329bb1..a550850 100644 --- a/robotools/worklists/base.py +++ b/robotools/worklists/base.py @@ -3,11 +3,10 @@ import logging import math from pathlib import Path -from typing import Iterable, Literal, Mapping, Sequence +from typing import Iterable, Literal, Mapping, Self, Sequence import numpy import numpy.typing as npt -from typing_extensions import Self from robotools import liquidhandling from robotools.evotools.types import Tip From fc48d69f2a77113f4eec70419a9c7db96da2dc60 Mon Sep 17 00:00:00 2001 From: Michael Osthege Date: Sat, 3 Oct 2026 23:10:23 +0200 Subject: [PATCH 8/9] Support Python 3.15 --- .github/workflows/pipeline.yml | 3 ++- pyproject.toml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 0fe115e..04f0c64 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -10,13 +10,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install dependencies run: | pip install -e . diff --git a/pyproject.toml b/pyproject.toml index 908843f..ca38219 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "License :: OSI Approved :: GNU Affero General Public License v3", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering", From 15ef5386648e1e9a197184959c89fd4d700f627b Mon Sep 17 00:00:00 2001 From: Michael Osthege Date: Sat, 3 Oct 2026 23:19:23 +0200 Subject: [PATCH 9/9] Fix build warning about deprecated license classifiers --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ca38219..002721f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "1.16.0" description = "Pythonic in-silico liquid handling and creation of Tecan FreedomEVO worklists." readme = "README.md" requires-python = ">=3.11" -license = {text = "GNU Affero General Public License v3"} +license = "AGPL-3.0-or-later" authors = [ {name = "Michael Osthege", email = "m.osthege@fz-juelich.de"}, ] @@ -20,7 +20,6 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", - "License :: OSI Approved :: GNU Affero General Public License v3", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering", ]