Repository: JuBiotech/robotools (language: python)

Issue:
Python 3.11 compatibility
+ [x] A small change to make tests Python 3.11 compatible - but still running the CI with Python 3.1
+ [x] Removed the old GitLab CI definition
+ [x] Updating the CI and the `setup.py` up to Python 3.11

Can be squash-merged.
Use `from typing import ...` imports to reduce code verbosity

Split `tests.py` to align with submodule structure
depends on #20 
Move code from `__init__.py` files into proper submodules



Discussion / hints:
## [Codecov](https://app.codecov.io/gh/JuBiotech/robotools/pull/33?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuBiotech) Report
> Merging [#33](https://app.codecov.io/gh/JuBiotech/robotools/pull/33?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuBiotech) (8d6787b) into [master](https://app.codecov.io/gh/JuBiotech/robotools/commit/86f13003b7873e666c65495d777f2094c9ecf4f8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuBiotech) (86f1300) will **not change** coverage.
> The diff coverage is `n/a`.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuBiotech)

```diff
@@           Coverage Diff           @@
##           master      #33   +/-   ##
=======================================
  Coverage   94.84%   94.84%           
=======================================
  Files           5        5           
  Lines         969      969           
=======================================
  Hits          919      919           
  Misses         50       50           
```








Fix patch:
diff --git a/.coveragerc b/.coveragerc
index 5d389af..7a73e40 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,4 +1,4 @@
 [run]
 omit =
     # exclude tests files from coverage calculation
-    robotools/test*.py
+    **/test*.py
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 8ce0686..9eff323 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -29,7 +29,7 @@ jobs:
         flake8 . --count --exit-zero --statistics
     - name: Test with pytest
       run: |
-        pytest --cov=./robotools --cov-report xml --cov-report term-missing robotools/tests.py
+        pytest --cov=./robotools --cov-report xml --cov-report term-missing robotools
     - name: Upload coverage
       uses: codecov/codecov-action@v3.1.3
       with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index da030ec..4c4bfff 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,7 +23,7 @@ jobs:
         pip install flake8 pytest pytest-cov twine wheel
     - name: Test with pytest
       run: |
-        pytest --cov=./robotools --cov-report xml --cov-report term-missing robotools/tests.py
+        pytest --cov=./robotools --cov-report xml --cov-report term-missing robotools
     - name: Build package
       run: |
         python setup.py sdist bdist_wheel
diff --git a/docs/source/index.rst b/docs/source/index.rst
index c307800..8610bf3 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -7,7 +7,7 @@ Welcome to the ``robotools`` documentation!
 .. image:: https://zenodo.org/badge/358629210.svg
    :target: https://zenodo.org/badge/latestdoi/358629210
 
-``robotools`` is a Python package for planning _in-silico_ liquid handling operations.
+``robotools`` is a Python package for planning *in silico* liquid handling operations.
 It can create Tecan Freedom EVO worklist files on the fly, making it possible to program complicated liquid handling operations
 from Python scripts or Jupyter notebooks.
 
diff --git a/docs/source/robotools_evotools.rst b/docs/source/robotools_evotools.rst
index 71f922c..56f2b86 100644
--- a/docs/source/robotools_evotools.rst
+++ b/docs/source/robotools_evotools.rst
@@ -1,7 +1,17 @@
 robotools.evotools
 ------------------
 
-.. automodule:: robotools.evotools
+.. automodule:: robotools.evotools.worklist
+	:members:
+	:undoc-members:
+	:show-inheritance:
+
+.. automodule:: robotools.evotools.types
+	:members:
+	:undoc-members:
+	:show-inheritance:
+
+.. automodule:: robotools.evotools.exceptions
 	:members:
 	:undoc-members:
 	:show-inheritance:
diff --git a/docs/source/robotools_liquidhandling.rst b/docs/source/robotools_liquidhandling.rst
index c21a86f..c239cdd 100644
--- a/docs/source/robotools_liquidhandling.rst
+++ b/docs/source/robotools_liquidhandling.rst
@@ -1,7 +1,12 @@
 robotools.liquidhandling
 ------------------------
 
-.. automodule:: robotools.liquidhandling
+.. automodule:: robotools.liquidhandling.labware
+	:members:
+	:undoc-members:
+	:show-inheritance:
+
+.. automodule:: robotools.liquidhandling.exceptions
 	:members:
 	:undoc-members:
 	:show-inheritance:
diff --git a/robotools/__init__.py b/robotools/__init__.py
index e728c90..1c3b352 100644
--- a/robotools/__init__.py
+++ b/robotools/__init__.py
@@ -1,7 +1,13 @@
 from . import evotools, liquidhandling
 from .evotools import InvalidOperationError, Labwares, Tip, Worklist
 from .liquidhandling import Labware, Trough, VolumeOverflowError, VolumeUnderflowError
-from .transform import WellRotator, WellShifter, make_well_array, make_well_index_dict
+from .transform import (
+    WellRandomizer,
+    WellRotator,
+    WellShifter,
+    make_well_array,
+    make_well_index_dict,
+)
 from .utils import DilutionPlan, get_trough_wells
 
-__version__ = "1.5.3"
+__version__ = "1.6.0"
diff --git a/robotools/evotools/__init__.py b/robotools/evotools/__init__.py
index 0d6c559..88b03a9 100644
--- a/robotools/evotools/__init__.py
+++ b/robotools/evotools/__init__.py
@@ -1,1703 +1,3 @@
-""" Creating worklist files for the Tecan Freedom EVO.
-"""
-import collections
-import enum
-import logging
-import math
-import os
-import typing
-
-import numpy
-
-from .. import liquidhandling, transform
-
-logger = logging.getLogger("evotools")
-
-
-class Labwares(str, enum.Enum):
-    """Built-in EVOware labware identifiers."""
-
-    SystemLiquid = "Systemliquid"
-
-
-class Tip(enum.IntEnum):
-    Any = -1
-    T1 = 1
-    T2 = 2
-    T3 = 4
-    T4 = 8
-    T5 = 16
-    T6 = 32
-    T7 = 64
-    T8 = 128
-
-
-class InvalidOperationError(Exception):
-    pass
-
-
-def _int_to_tip(tip_int: int):
-    """Asserts a Tecan Tip class to an int between 1 and 8."""
-    if not 1 <= tip_int <= 8:
-        raise ValueError(
-            f"Tip is {tip_int} with type {type(tip_int)}, but should be an int between 1 and 8 for _int_to_tip conversion."
-        )
-    if tip_int == 1:
-        return Tip.T1
-    elif tip_int == 2:
-        return Tip.T2
-    elif tip_int == 3:
-        return Tip.T3
-    elif tip_int == 4:
-        return Tip.T4
-    elif tip_int == 5:
-        return Tip.T5
-    elif tip_int == 6:
-        return Tip.T6
-    elif tip_int == 7:
-        return Tip.T7
-    elif tip_int == 8:
-        return Tip.T8
-
-
-def _prepare_aspirate_dispense_parameters(
-    rack_label: str,
-    position: int,
-    volume: float,
-    liquid_class: str = "",
-    tip: typing.Union[Tip, int, collections.abc.Iterable] = Tip.Any,
-    rack_id: str = "",
-    tube_id: str = "",
-    rack_type: str = "",
-    forced_rack_type: str = "",
-    max_volume: typing.Optional[int] = None,
-) -> typing.Tuple[str, int, float, str, typing.Union[Tip, int, collections.abc.Iterable], str, str, str, str]:
-    """Validates and prepares aspirate/dispense parameters.
-
-    Parameters
-    ----------
-    rack_label : str
-        User-defined labware name (max 32 characters)
-    position : int
-        Number of the well
-    volume : float
-        Volume in microliters (will be rounded to 2 decimal places)
-    liquid_class : str, optional
-        Overrides the liquid class for this step (max 32 characters)
-    tip : Tip, int or Iterable of Tip / int, optional
-        Tip that will be selected (Tip, 1-8 or Iterable of the former two)
-    rack_id : str, optional
-        Barcode of the labware (max 32 characters)
-    tube_id : str, optional
-        Barcode of the tube (max 32 characters)
-    rack_type : str, optional
-        Configuration name of the labware (max 32 characters).
-        An error is raised if it missmatches with the underlying worktable.
-    forced_rack_type : str, optional
-        Overrides rack_type from worktable
-    max_volume : int, optional
-        Maximum allowed volume
-
-    Returns
-    -------
-    rack_label : str
-        User-defined labware name (max 32 characters)
-    position : int
-        Number of the well
-    volume : float
-        Volume in microliters (will be rounded to 2 decimal places)
-    liquid_class : str
-        Overrides the liquid class for this step (max 32 characters)
-    tip : Tip, int or Iterable of Tip / int
-        Tip that will be selected (Tip, 1-8 or Iterable of the former two)
-    rack_id : str
-        Barcode of the labware (max 32 characters)
-    tube_id : str
-        Barcode of the tube (max 32 characters)
-    rack_type : str
-        Configuration name of the labware (max 32 characters).
-        An error is raised if it missmatches with the underlying worktable.
-    forced_rack_type : str
-        Overrides rack_type from worktable
-    """
-    # required parameters
-    if rack_label is None:
-        raise ValueError("Missing required parameter: rack_label")
-    if not isinstance(rack_label, str) or len(rack_label) > 32 or ";" in rack_label:
-        raise ValueError(f"Invalid rack_label: {rack_label}")
-
-    if position is None:
-        raise ValueError("Missing required parameter: position")
-    if not isinstance(position, int) or position < 0:
-        raise ValueError(f"Invalid position: {position}")
-
-    if volume is None:
-        raise ValueError("Missing required parameter: volume")
-    try:
-        volume = float(volume)
-    except:
-        raise ValueError(f"Invalid volume: {volume}")
-    if volume < 0 or volume > 7158278 or numpy.isnan(volume):
-        raise ValueError(f"Invalid volume: {volume}")
-    if max_volume is not None and volume > max_volume:
-        raise InvalidOperationError(f"Volume of {volume} exceeds max_volume.")
-
-    # optional parameters
-    if not isinstance(liquid_class, str) or ";" in liquid_class:
-        raise ValueError(f"Invalid liquid_class: {liquid_class}")
-
-    if isinstance(tip, int) and not isinstance(tip, Tip):
-        # User-specified integers from 1-8 need to be converted to Tecan logic
-        tip = _int_to_tip(tip)
-
-    if isinstance(tip, collections.abc.Iterable):
-        tips = []
-        for element in tip:
-            if isinstance(element, int) and not isinstance(element, Tip):
-                tips.append(_int_to_tip(element))
-            elif isinstance(element, Tip):
-                if element == -1:
-                    raise ValueError(
-                        "When Iterables are used, no Tip.Any elements are allowed. Pass just one Tip.Any instead."
-                    )
-                tips.append(element)
-            else:
-                raise ValueError(
-                    f"If tip is an Iterable, it may only contain int or Tip values, not {type(element)}."
-                )
-        tip = sum(set(tips))
-    elif not isinstance(tip, Tip):
-        raise ValueError(f"tip must be an int between 1 and 8, Tip or Iterable, but was {type(tip)}.")
-
-    if not isinstance(rack_id, str) or len(rack_id) > 32 or ";" in rack_id:
-        raise ValueError(f"Invalid rack_id: {rack_id}")
-    if not isinstance(rack_type, str) or len(rack_type) > 32 or ";" in rack_type:
-        raise ValueError(f"Invalid rack_type: {rack_type}")
-    if not isinstance(forced_rack_type, str) or len(forced_rack_type) > 32 or ";" in forced_rack_type:
-        raise ValueError(f"Invalid forced_rack_type: {forced_rack_type}")
-
-    # apply rounding and corrections for the right string formatting
-    volume = f"{numpy.round(volume, decimals=2):.2f}"
-    tip = "" if tip == -1 else tip
-    return rack_label, position, volume, liquid_class, tip, rack_id, tube_id, rack_type, forced_rack_type
-
-
-def _prepare_evo_aspirate_dispense_parameters(
-    labware: liquidhandling.Labware,
-    wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
-    *,
-    labware_position: typing.Tuple[int, int],
-    volume: typing.Union[float, typing.List[float], int],
-    liquid_class: str,
-    tips: typing.Union[typing.List[Tip], typing.List[int]],
-    max_volume: typing.Optional[int] = None,
-) -> typing.Tuple[str, list, tuple, float, str, list]:
-    """Validates and prepares aspirate/dispense parameters.
-
-    Parameters
-    ----------
-    labware : liquidhandling.Labware
-        Source labware
-    wells : list of str
-        List with target well ID(s)
-    labware_position : tuple
-        Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
-    volume : int, float or list
-        Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
-    liquid_class : str, optional
-        Overwrites the liquid class for this step (max 32 characters)
-    tips : list of int
-        Tip(s) that will be selected (out of tips 1-8)
-    max_volume : int, optional
-        Maximum allowed volume
-
-    Returns
-    -------
-    labware : liquidhandling.Labware
-        Source labware
-    wells : list of str
-        List with target well ID(s)
-    labware_position : tuple
-        Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
-    volume : list
-        Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
-    liquid_class : str, optional
-        Overwrites the liquid class for this step (max 32 characters)
-    tips : list of int
-        Tip(s) that will be selected (out of tips 1-8)
-    """
-    if labware is None:
-        raise ValueError("Missing required parameter: labware")
-    if not isinstance(labware, liquidhandling.Labware):
-        raise ValueError(f"Invalid labware: {labware}")
-
-    if wells is None:
-        raise ValueError("Missing required parameter: wells")
-    if not isinstance(wells, (str, list, tuple, numpy.ndarray)):
-        raise ValueError(f"Invalid wells: {wells}")
-    if not len(wells) == len(tips):
-        raise ValueError(f"Invalid wells: wells and tips need to have the same length.")
-    if labware_position is None:
-        raise ValueError("Missing required parameter: position")
-    if not all(isinstance(position, int) for position in labware_position) or any(
-        position < 0 for position in labware_position
-    ):
-        raise ValueError(f"Invalid position: {labware_position}")
-
-    if volume is None:
-        raise ValueError("Missing required parameter: volume")
-    if isinstance(volume, list):
-        for vol in volume:
-            try:
-                vol = float(vol)
-            except:
-                raise ValueError(f"Invalid volume: {vol}")
-            if vol < 0 or vol > 7158278 or numpy.isnan(vol):
-                raise ValueError(f"Invalid volume: {vol}")
-            if max_volume is not None and vol > max_volume:
-                raise InvalidOperationError(f"Invalid volume: volume of {vol} exceeds max_volume.")
-        if not len(volume) == len(tips) == len(wells):
-            raise Exception(
-                f"Invalid volume: Tips, wells, and volume lists have different lengths ({len(tips)}, {len(wells)} and {len(volume)}, respectively)."
-            )
-    elif isinstance(volume, (float, int)):
-        # test volume like in the list section
-        if volume < 0 or volume > 7158278 or numpy.isnan(volume):
-            raise ValueError(f"Invalid volume: {volume}")
-        if max_volume is not None and volume > max_volume:
-            raise InvalidOperationError(f"Invalid volume: volume of {volume} exceeds max_volume.")
-        # convert volume to list and multiply list to reach identical length as wells
-        volume = [float(volume)] * len(wells)
-    else:
-        raise ValueError(f"Invalid volume: {volume}")
-
-    # apply rounding and corrections for the right string formatting
-    volume = numpy.round(volume, decimals=2).tolist()
-
-    if liquid_class is None:
-        raise ValueError(f"Missing required parameter: liquid_class")
-    if not isinstance(liquid_class, str) or ";" in liquid_class:
-        raise ValueError(f"Invalid liquid_class: {liquid_class}")
-
-    if tips is None:
-        raise ValueError(f"Missing required parameter: tips")
-    for tip in tips:
-        if not isinstance(tip, (int, Tip)):
-            raise ValueError(f"Invalid type of tips: {type(tip)}. Has to be int or Tip.")
-    tecan_tips = []
-    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
-            tip = _int_to_tip(tip)
-        tecan_tips.append(tip)
-
-    return labware, wells, labware_position, volume, liquid_class, tecan_tips
-
-
-def _prepare_evo_wash_parameters(
-    *,
-    tips: typing.Union[typing.List[Tip], typing.List[int]],
-    waste_location: typing.Tuple[int, int],
-    cleaner_location: typing.Tuple[int, int],
-    arm: int = 0,
-    waste_vol: float = 3.0,
-    waste_delay: int = 500,
-    cleaner_vol: float = 4.0,
-    cleaner_delay: int = 500,
-    airgap: int = 10,
-    airgap_speed: int = 70,
-    retract_speed: int = 30,
-    fastwash: int = 1,
-    low_volume: int = 0,
-) -> typing.Tuple[list, tuple, tuple, int, float, int, float, int, int, int, int, int, int]:
-    """Validates and prepares aspirate/dispense parameters.
-
-    Parameters
-    ----------
-    tips : list
-        Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
-    waste_location : tuple
-        Tuple with grid position (1-67) and site number (0-127) of waste as integers
-    cleaner_location : tuple
-        Tuple with grid position (1-67) and site number (0-127) of cleaner as integers
-    arm : int
-        number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2
-    waste_vol: float
-        Volume in waste in mL (0-100)
-    waste_delay : int
-        Delay before closing valves in waste in ms (0-1000)
-    cleaner_vol: float
-        Volume in cleaner in mL (0-100)
-    cleaner_delay : int
-        Delay before closing valves in cleaner in ms (0-1000)
-    airgap : int
-        Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100)
-    airgap_speed : int
-        Speed of airgap aspiration in µL/s (1-1000)
-    retract_speed : int
-        Retract speed in mm/s (1-100)
-    fastwash : int
-        Use fast-wash module = 1, don't use it = 0
-    low_volume : int
-        Use pinch valves = 1, don't use them = 0
-
-    Returns
-    -------
-    tips : list
-        Tip(s) that will be selected; have been converted to tip.T1 - tip.T8 here if they weren't originally formatted that way
-    waste_location : tuple
-        Tuple with grid position (1-67) and site number (0-127) of waste as integers
-    cleaner_location : tuple
-        Tuple with grid position (1-67) and site number (0-127) of cleaner as integers
-    arm : int
-        number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2
-    waste_vol: float
-        Volume in waste in mL (0-100)
-    waste_delay : int
-        Delay before closing valves in waste in ms (0-1000)
-    cleaner_vol: float
-        Volume in cleaner in mL (0-100)
-    cleaner_delay : int
-        Delay before closing valves in cleaner in ms (0-1000)
-    airgap : int
-        Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100)
-    airgap_speed : int
-        Speed of airgap aspiration in µL/s (1-1000)
-    retract_speed : int
-        Retract speed in mm/s (1-100)
-    fastwash : int
-        Use fast-wash module = 1, don't use it = 0
-    low_volume : int
-        Use pinch valves = 1, don't use them = 0
-    """
-    if tips is None:
-        raise ValueError("Missing required parameter: tips")
-
-    tecan_tips = []
-    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
-            tip = _int_to_tip(tip)
-        tecan_tips.append(tip)
-
-    if waste_location is None:
-        raise ValueError("Missing required parameter: waste_location")
-    grid, site = waste_location
-    if not isinstance(grid, int) or not 1 <= grid <= 67:
-        raise ValueError("Grid (first number in waste_location tuple) has to be an int from 1 - 67.")
-    if not isinstance(site, int) or not 0 <= site <= 127:
-        raise ValueError("Site (second number in waste_location tuple) has to be an int from 0 - 127.")
-
-    if cleaner_location is None:
-        raise ValueError("Missing required parameter: cleaner_location")
-    grid, site = cleaner_location
-    if not isinstance(grid, int) or not 1 <= grid <= 67:
-        raise ValueError("Grid (first number in cleaner_location tuple) has to be an int from 1 - 67.")
-    if not isinstance(site, int) or not 0 <= site <= 127:
-        raise ValueError("Site (second number in cleaner_location tuple) has to be an int from 0 - 127.")
-
-    if arm is None:
-        raise ValueError("Missing required paramter: arm")
-    if not isinstance(arm, int):
-        raise ValueError("Parameter arm is not int.")
-    if not arm == 0 and not arm == 1:
-        raise ValueError("Parameter arm has to be 0 (LiHa 1) or 1 (LiHa 2).")
-
-    if waste_vol is None:
-        raise ValueError("Missing required parameter: waste_vol")
-    if not isinstance(waste_vol, float) or not 0 <= waste_vol <= 100:
-        raise ValueError("waste_vol has to be a float from 0 - 100.")
-    # round waste_vol to the first decimal (pre-requisite for Tecan's wash command)
-    waste_vol = numpy.round(waste_vol, 1)
-
-    if waste_delay is None:
-        raise ValueError("Missing required parameter: waste_delay")
-    if not isinstance(waste_delay, int) or not 0 <= waste_delay <= 1000:
-        raise ValueError("waste_delay has to be an int from 0 - 1000.")
-
-    if cleaner_vol is None:
-        raise ValueError("Missing required parameter: cleaner_vol")
-    if not isinstance(cleaner_vol, float) or not 0 <= cleaner_vol <= 100:
-        raise ValueError("cleaner_vol has to be a float from 0 - 100.")
-    # round cleaner_vol to the first decimal (pre-requisite for Tecan's wash command)
-    cleaner_vol = numpy.round(cleaner_vol, 1)
-
-    if cleaner_delay is None:
-        raise ValueError("Missing required parameter: cleaner_delay")
-    if not isinstance(cleaner_delay, int) or not 0 <= cleaner_delay <= 1000:
-        raise ValueError("cleaner_delay has to be an int from 0 - 1000.")
-
-    if airgap is None:
-        raise ValueError("Missing required parameter: airgap")
-    if not isinstance(airgap, int) or not 0 <= airgap <= 100:
-        raise ValueError("airgap has to be an int from 0 - 100.")
-
-    if airgap_speed is None:
-        raise ValueError("Missing required parameter: airgap_speed")
-    if not isinstance(airgap_speed, int) or not 1 <= airgap_speed <= 1000:
-        raise ValueError("airgap_speed has to be an int from 1 - 1000.")
-
-    if retract_speed is None:
-        raise ValueError("Missing required parameter: retract_speed")
-    if not isinstance(retract_speed, int) or not 1 <= retract_speed <= 100:
-        raise ValueError("retract_speed has to be an int from 1 - 100.")
-
-    if fastwash is None:
-        raise ValueError("Missing required paramter: fastwash")
-    if not isinstance(fastwash, int):
-        raise ValueError("Parameter fastwash is not int.")
-    if not fastwash == 0 and not fastwash == 1:
-        raise ValueError("Parameter fastwash has to be 0 (no fast-wash) or 1 (use fast-wash).")
-
-    if low_volume is None:
-        raise ValueError("Missing required paramter: low_volume")
-    if not isinstance(low_volume, int):
-        raise ValueError("Parameter low_volume is not int.")
-    if not low_volume == 0 and not low_volume == 1:
-        raise ValueError("Parameter low_volume has to be 0 (no fast-wash) or 1 (use fast-wash).")
-
-    return (
-        tecan_tips,
-        waste_location,
-        cleaner_location,
-        arm,
-        waste_vol,
-        waste_delay,
-        cleaner_vol,
-        cleaner_delay,
-        airgap,
-        airgap_speed,
-        retract_speed,
-        fastwash,
-        low_volume,
-    )
-
-
-def _optimize_partition_by(
-    source: liquidhandling.Labware,
-    destination: liquidhandling.Labware,
-    partition_by: str,
-    label: typing.Optional[str] = None,
-) -> str:
-    """Determines optimal partitioning settings.
-
-    Parameters
-    ----------
-    source (Labware): source labware object
-    destination (Labware): destination labware object
-    partition_by : str
-    user-provided partitioning settings
-    label : str
-    label of the operation (optional)
-
-    Returns
-    -------
-    partition_by : str
-        Either 'source' or 'destination'
-    """
-    if not partition_by in {"auto", "source", "destination"}:
-        raise ValueError(f"Invalid partition_by argument: {partition_by}")
-    # automatic partitioning decision
-    if partition_by == "auto":
-        if source.is_trough and not destination.is_trough:
-            partition_by = "destination"
-        else:
-            partition_by = "source"
-    else:
-        # log warnings about potentially inefficient partitioning settings
-        if partition_by == "source" and source.is_trough and not destination.is_trough:
-            logger.warning(
-                f'Partitioning by "source" ({source.name}), which is a Trough while destination ({destination.name}) is not a Trough.'
-                ' This is potentially inefficient. Consider using partition_by="destination".'
-                f" (label={label})"
-            )
-        elif partition_by == "destination" and destination.is_trough and not source.is_trough:
-            logger.warning(
-                f'Partitioning by "destination" ({destination.name}), which is a Trough while source ({source.name}) is not a Trough.'
-                ' This is potentially inefficient. Consider using partition_by="source"'
-                f" (label={label})"
-            )
-    return partition_by
-
-
-def _partition_volume(volume: float, *, max_volume: int) -> typing.List[float]:
-    """Partitions a pipetting volume into zero or more integer-valued volumes that are <= max_volume.
-
-    Parameters
-    ----------
-    volume : float
-        A volume to partition
-    max_volume : int
-        Maximum volume of a pipetting step
-
-    Returns
-    -------
-    volumes : list
-        Partitioned volumes
-    """
-    if volume == 0:
-        return []
-    if volume < max_volume:
-        return [volume]
-    isteps = math.ceil(volume / max_volume)
-    step_volume = math.ceil(volume / isteps)
-    volumes: typing.List[float] = [step_volume] * (isteps - 1)
-    volumes.append(volume - numpy.sum(volumes))
-    return volumes
-
-
-def _partition_by_column(
-    sources: typing.Iterable[str],
-    destinations: typing.Iterable[str],
-    volumes: typing.Iterable[float],
-    partition_by: str,
-) -> typing.Dict[int, typing.Tuple[typing.List[str], typing.List[str], typing.List[float]]]:
-    """Partitions sources/destinations/volumes by the source column and sorts within those columns.
-
-    Parameters
-    ----------
-    sources : list
-        The source well ids; same length as destinations and volumes
-    destinations : list
-        The destination well ids; same length as sources and volumes
-    volumes : list
-        The volumes; same length as sources and destinations
-    partition_by : str
-        Either 'source' or 'destination'
-
-    Returns
-    -------
-    column_groups : list
-        A list of (sources, destinations, volumes)
-    """
-    # first partition the wells into columns
-    column_groups = collections.defaultdict(lambda: ([], [], []))
-    for s, d, v in zip(sources, destinations, volumes):
-        if partition_by == "source":
-            group = s[1:]
-        elif partition_by == "destination":
-            group = d[1:]
-        else:
-            raise ValueError(f'Invalid `partition_by` parameter "{partition_by}""')
-        column_groups[group][0].append(s)
-        column_groups[group][1].append(d)
-        column_groups[group][2].append(v)
-    # bring columns in the right order
-    column_groups = [column_groups[col] for col in sorted(column_groups.keys())]
-    # sort the rows within the column
-    for c, (srcs, dsts, vols) in enumerate(column_groups):
-        if partition_by == "source":
-            order = numpy.argsort(srcs)
-        elif partition_by == "destination":
-            order = numpy.argsort(dsts)
-        else:
-            raise ValueError(f'Invalid `partition_by` parameter "{partition_by}""')
-        column_groups[c] = (
-            list(numpy.array(srcs)[order]),
-            list(numpy.array(dsts)[order]),
-            list(numpy.array(vols)[order]),
-        )
-    return column_groups
-
-
-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.
-    """
-    digits = "0123456789ABCDEF"
-    x = dec % 16
-    rest = dec // 16
-    if rest == 0:
-        return digits[x]
-    return to_hex(rest) + digits[x]
-
-
-def evo_make_selection_array(rows: int, columns: int, wells: numpy.ndarray):
-    """Translate well IDs to a numpy array with 1s (selected) and 0s (not selected).
-
-    Parameters
-    ----------
-    rows : int
-        Number of rows of target labware object
-    cols : int
-        Number of columns of target labware object
-    wells : List[str]
-        Selected wells by well IDs as strings (e.g. ["A01", "B01"])
-
-    Returns
-    -------
-    selection_array : numpy.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 = numpy.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 wells:
-        selection_array[well_index_dict[well]] = 1
-    return selection_array
-
-
-def evo_get_selection(rows: int, cols: int, selected: numpy.ndarray):
-    """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).
-
-    Parameters
-    ----------
-    rows : int
-        Number of rows of target labware object
-    cols : int
-        Number of columns of target labware object
-    selected : numpy.ndarray
-        Numpy array in labware dimensions with selected wells as 1 and others as 0 (from evo_make_selection_array)
-
-    Returns
-    -------
-    selection : str
-        Code string for well selection of pipetting actions in EvoWare scripts (.esc)
-    """
-    # apply bit mask with 7 bits, adapted from function detailed in EvoWare manual
-    selection = f"0{to_hex(cols)}{rows:02d}"
-    bit_counter = 0
-    bit_mask = 0
-    for x in range(cols):
-        for y in range(rows):
-            if selected[y, x] == 1:
-                bit_mask |= 1 << bit_counter
-            bit_counter += 1
-            if bit_counter > 6:
-                selection += chr(bit_mask + 48)
-                bit_counter = 0
-                bit_mask = 0
-    if bit_counter > 0:
-        selection += chr(bit_mask + 48)
-
-    # check if wells from more than one column are selected and raise Exception if so
-    check = 0
-    for column in selected.transpose():
-        if sum(column) >= 1:
-            check += 1
-    if check >= 2:
-        raise Exception(
-            "Wells from more than one column are selected.\nSelect only wells from one column per pipetting action."
-        )
-
-    return selection
-
-
-class Worklist(list):
-    """Context manager for the creation of Worklists."""
-
-    def __init__(self, filepath: str = None, max_volume: int = 950, auto_split: bool = True) -> None:
-        """Creates a worklist writer.
-
-        Parameters
-        ----------
-        filepath : str
-            Optional filename/filepath to write when the context is exited (must include a .gwl extension)
-        max_volume : int
-            Maximum aspiration volume in µL
-        auto_split : bool
-            If `True`, large volumes in transfer operations are automatically splitted.
-            If set to `False`, `InvalidOperationError` is raised when a pipetting volume exceeds `max_volume`.
-        """
-        self._filepath = filepath
-        if max_volume is None:
-            raise ValueError("The `max_volume` parameter is required.")
-        self.max_volume = max_volume
-        self.auto_split = auto_split
-        super().__init__()
-
-    def __enter__(self) -> None:
-        self.clear()
-        return self
-
-    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
-        if self._filepath:
-            self.save(self._filepath)
-        return
-
-    def save(self, filepath: str) -> None:
-        """Writes the worklist to the filepath.
-
-        Parameters
-        ----------
-        filepath : str
-            File name or path to write (must include a .gwl extension)
-        """
-        assert ".gwl" in filepath.lower(), "The filename did not contain the .gwl extension."
-        if os.path.exists(filepath):
-            os.remove(filepath)
-        with open(filepath, "w", newline="\r\n", encoding="latin_1") as file:
-            file.write("\n".join(self))
-        return
-
-    def comment(self, comment: typing.Optional[str]) -> None:
-        """Adds a comment.
-
-        Parameters
-        ----------
-        comment : str
-            A single- or multi-line comment. Be nice and avoid special characters.
-        """
-        if not comment:
-            return
-        if ";" in comment:
-            raise ValueError("Illegal semicolon in comment.")
-        for cline in comment.split("\n"):
-            cline = cline.strip()
-            if cline:
-                self.append(f"C;{cline}")
-        return
-
-    def wash(self, scheme: int = 1) -> None:
-        """Washes fixed tips or replaces DiTis.
-
-        Washes/replaces the tip that was used by the preceding aspirate record(s).
-
-        Parameters
-        ----------
-        scheme : int
-            Number indicating the wash scheme (default: 1)
-        """
-        if not scheme in {1, 2, 3, 4}:
-            raise ValueError("scheme must be either 1, 2, 3 or 4")
-        self.append(f"W{scheme};")
-        return
-
-    def decontaminate(self) -> None:
-        """Decontamination wash consists of a decontamination wash followed by a normal wash."""
-        self.append("WD;")
-        return
-
-    def flush(self) -> None:
-        """Discards the contents of the tips WITHOUT WASHING or DROPPING of tips."""
-        self.append("F;")
-        return
-
-    def commit(self) -> None:
-        """Inserts a 'break' that forces the execution of aspirate/dispense operations at this point.
-
-        WARNING: may be unreliable
-
-        If you don’t specify a Break record, Freedom EVOware normally executes
-        pipetting commands in groups to optimize the efficiency. For example, if
-        you have specified four tips in the Worklist command, Freedom EVOware
-        will queue Aspirate records until four of them are ready for execution.
-        This allows pipetting to take place using all four tips at the same time.
-        Specify the Break record if you want to execute all of the currently queued
-        commands without waiting. You can use the Break record e.g. to create a
-        worklist which pipettes using only one tip at a time (even if you chose
-        more than one tip in the tip selection).
-        """
-        self.append("B;")
-        return
-
-    def set_diti(self, diti_index: int) -> None:
-        """Switches the DiTi types within the worklist.
-
-        IMPORTANT: As the DiTi index in worklists is 1-based you have to increase the shown DiTi index by one.
-
-        Choose the required DiTi type by specifying the DiTi index.
-        Freedom EVOware automatically assigns a unique index to each DiTi type.
-        The DiTi index is shown in the Edit Labware dialog box for the DiTi labware (Well dimensions tab).
-
-        The Set DiTi Type record can only be used at the very beginning of the
-        worklist or directly after a Break record. A Break record always resets
-        the DiTi type to the type selected in the Worklist command. Accordingly,
-        if your worklist contains a Break record, you may need to specify the
-        Set DiTi Type record again.
-
-        Parameters
-        ----------
-        diti_index : int
-            Type of DiTis to use in subsequent steps
-        """
-        if not (len(self) == 0 or self[-1][0] == "B"):
-            raise InvalidOperationError(
-                "DiTi type can only be switched at the beginning or after a Break/commit step. Read the docstring."
-            )
-        self.append(f"S;{diti_index}")
-        return
-
-    def aspirate_well(
-        self,
-        rack_label: str,
-        position: int,
-        volume: float,
-        *,
-        liquid_class: str = "",
-        tip: typing.Union[Tip, int, typing.Iterable] = Tip.Any,
-        rack_id: str = "",
-        tube_id: str = "",
-        rack_type: str = "",
-        forced_rack_type: str = "",
-    ) -> None:
-        """Command for aspirating with a single tip.
-
-        Each Aspirate record specifies the aspiration parameters for a single tip (the next unused tip from the tip selection you have specified).
-
-        Parameters
-        ----------
-        rack_label : str
-            User-defined labware name (max 32 characters)
-        position : int
-            Number of the well
-        volume : float
-            Volume in microliters (will be rounded to 2 decimal places)
-        liquid_class : str, optional
-            Overwrites the liquid class for this step (max 32 characters)
-        tip : Tip, int or Iterable of Tip / int, optional
-            Tip that will be selected (Tip, 1-8 or Iterable of the former two)
-        rack_id : str, optional
-            Barcode of the labware (max 32 characters)
-        tube_id : str, optional
-            Barcode of the tube (max 32 characters)
-        rack_type : str
-            Configuration name of the labware (max 32 characters).
-            An error is raised if it missmatches with the underlying worktable.
-        forced_rack_type : str, optional
-            Overrides rack_type from worktable
-        """
-        args = (
-            rack_label,
-            position,
-            volume,
-            liquid_class,
-            tip,
-            rack_id,
-            tube_id,
-            rack_type,
-            forced_rack_type,
-        )
-        (
-            rack_label,
-            position,
-            volume,
-            liquid_class,
-            tip,
-            rack_id,
-            tube_id,
-            rack_type,
-            forced_rack_type,
-        ) = _prepare_aspirate_dispense_parameters(*args, max_volume=self.max_volume)
-        tip_type = ""
-        self.append(
-            f"A;{rack_label};{rack_id};{rack_type};{position};{tube_id};{volume};{liquid_class};{tip_type};{tip};{forced_rack_type}"
-        )
-        return
-
-    def evo_aspirate_well(
-        self,
-        *,
-        labware: liquidhandling.Labware,
-        wells: typing.Union[str, typing.List[str]],
-        labware_position: typing.Tuple[int, int],
-        volume: typing.Union[float, typing.List[float], int],
-        liquid_class: str,
-        tips: typing.Union[typing.List[Tip], typing.List[int]],
-    ) -> 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.
-        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.
-
-        Parameters
-        ----------
-        labware : liquidhandling.Labware
-            Source labware
-        wells : list of str
-            List with target well ID(s)
-        labware_position : tuple
-            Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
-        volume : int, float or list
-            Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
-        liquid_class : str, optional
-            Overwrites the liquid class for this step (max 32 characters)
-        tips : list
-            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
-        """
-        # perform consistency checks
-        kwargs = dict(
-            labware=labware,
-            wells=wells,
-            labware_position=labware_position,
-            volume=volume,
-            liquid_class=liquid_class,
-            tips=tips,
-        )
-        (
-            labware,
-            wells,
-            labware_position,
-            volume,
-            liquid_class,
-            tips,
-        ) = _prepare_evo_aspirate_dispense_parameters(**kwargs, max_volume=self.max_volume)
-
-        # calculate tip_selection based on tips argument (tips are converted to evotools.Tip in _prepare_evo_aspirate_dispense_parameters)
-        tip_selection = 0
-        for tip in tips:
-            tip_selection += tip.value
-
-        # prepare volume section (volume is converted to list in _prepare_evo_aspirate_dispense_parameters)
-        tip_volumes = ""
-        for tip in [1, 2, 4, 8, 16, 32, 64, 128]:
-            if tip in [tecantip.value for tecantip in tips]:
-                tip_volumes += f'"{volume[0]}",'
-                volume.pop(0)
-            else:
-                tip_volumes += "0,"
-
-        # convert selection from list of well ids to numpy array with same dimensions as target labware (1: well is selected, 0: well is not selected)
-        selected = evo_make_selection_array(labware.n_rows, labware.n_columns, wells)
-        # create code string containing information about target well(s)
-        code_string = evo_get_selection(labware.n_rows, labware.n_columns, selected)
-        self.append(
-            f'B;Aspirate({tip_selection},"{liquid_class}",{tip_volumes}0,0,0,0,{labware_position[0]},{labware_position[1]},1,"{code_string}",0,0);'
-        )
-        return
-
-    def dispense_well(
-        self,
-        rack_label: str,
-        position: int,
-        volume: float,
-        *,
-        liquid_class: str = "",
-        tip: typing.Union[Tip, int] = Tip.Any,
-        rack_id: str = "",
-        tube_id: str = "",
-        rack_type: str = "",
-        forced_rack_type: str = "",
-    ) -> None:
-        """Command for dispensing with a single tip.
-
-        Each Dispense record specifies the dispense parameters for a single tip.
-        It uses the same tip which was used by the preceding Aspirate record.
-
-        Parameters
-        ----------
-        rack_label : str
-            User-defined labware name (max 32 characters)
-        position : int
-            Number of the well
-        volume : float
-            Volume in microliters (will be rounded to 2 decimal places)
-        liquid_class : str, optional
-            Overwrites the liquid class for this step (max 32 characters)
-        tip : Tip, int or Iterable of Tip / int, optional
-            Tip that will be selected (Tip, 1-8 or Iterable of the former two)
-        rack_id : str, optional
-            Barcode of the labware (max 32 characters)
-        tube_id : str, optional
-            Barcode of the tube (max 32 characters)
-        rack_type : str, optional
-            Configuration name of the labware (max 32 characters).
-            An error is raised if it missmatches with the underlying worktable.
-        forced_rack_type : str, optional
-            Overrides rack_type from worktable
-        """
-        args = (
-            rack_label,
-            position,
-            volume,
-            liquid_class,
-            tip,
-            rack_id,
-            tube_id,
-            rack_type,
-            forced_rack_type,
-        )
-        (
-            rack_label,
-            position,
-            volume,
-            liquid_class,
-            tip,
-            rack_id,
-            tube_id,
-            rack_type,
-            forced_rack_type,
-        ) = _prepare_aspirate_dispense_parameters(*args, max_volume=self.max_volume)
-        tip_type = ""
-        self.append(
-            f"D;{rack_label};{rack_id};{rack_type};{position};{tube_id};{volume};{liquid_class};{tip_type};{tip};{forced_rack_type}"
-        )
-        return
-
-    def evo_dispense_well(
-        self,
-        *,
-        labware: liquidhandling.Labware,
-        wells: typing.Union[str, typing.List[str]],
-        labware_position: typing.Tuple[int, int],
-        volume: typing.Union[float, typing.List[float], int],
-        liquid_class: str,
-        tips: typing.Union[typing.List[Tip], typing.List[int]],
-    ) -> None:
-        """Command for dispensing using the EvoWARE dispense 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.
-
-        Parameters
-        ----------
-        labware : liquidhandling.Labware
-            Source labware
-        wells : list of str
-            List with target well ID(s)
-        labware_position : tuple
-            Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
-        volume : int, float or list
-            Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
-        liquid_class : str, optional
-            Overwrites the liquid class for this step (max 32 characters)
-        tips : list
-            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
-        """
-        # perform consistency checks
-        kwargs = dict(
-            labware=labware,
-            wells=wells,
-            labware_position=labware_position,
-            volume=volume,
-            liquid_class=liquid_class,
-            tips=tips,
-        )
-        (
-            labware,
-            wells,
-            labware_position,
-            volume,
-            liquid_class,
-            tips,
-        ) = _prepare_evo_aspirate_dispense_parameters(**kwargs, max_volume=self.max_volume)
-
-        # calculate tip_selection based on tips argument (tips are converted to evotools.Tip in _prepare_evo_aspirate_dispense_parameters)
-        tip_selection = 0
-        for tip in tips:
-            tip_selection += tip.value
-
-        # prepare volume section (volume is converted to list in _prepare_evo_aspirate_dispense_parameters)
-        tip_volumes = ""
-        for tip in [1, 2, 4, 8, 16, 32, 64, 128]:
-            if tip in [tecantip.value for tecantip in tips]:
-                tip_volumes += f'"{volume[0]}",'
-                volume.pop(0)
-            else:
-                tip_volumes += "0,"
-
-        # convert selection from list of well ids to numpy array with same dimensions as target labware (1: well is selected, 0: well is not selected)
-        selected = evo_make_selection_array(labware.n_rows, labware.n_columns, wells)
-        # create code string containing information about target well(s)
-        code_string = evo_get_selection(labware.n_rows, labware.n_columns, selected)
-        self.append(
-            f'B;Dispense({tip_selection},"{liquid_class}",{tip_volumes}0,0,0,0,{labware_position[0]},{labware_position[1]},1,"{code_string}",0,0);'
-        )
-        return
-
-    def evo_wash(
-        self,
-        *,
-        tips: typing.Union[typing.List[Tip], typing.List[int]],
-        waste_location: typing.Tuple[int, int],
-        cleaner_location: typing.Tuple[int, int],
-        arm: int = 0,
-        waste_vol: float = 3.0,
-        waste_delay: int = 500,
-        cleaner_vol: float = 4.0,
-        cleaner_delay: int = 500,
-        airgap: int = 10,
-        airgap_speed: int = 70,
-        retract_speed: int = 30,
-        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.
-        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.
-
-        Parameters
-        ----------
-        tips : list
-            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
-        waste_location : tuple
-            Tuple with grid position (1-67) and site number (0-127) of waste as integers
-        cleaner_location : tuple
-            Tuple with grid position (1-67) and site number (0-127) of cleaner as integers
-        arm : int
-            number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2
-        waste_vol: float
-            Volume in waste in mL (0-100)
-        waste_delay : int
-            Delay before closing valves in waste in ms (0-1000)
-        cleaner_vol: float
-            Volume in cleaner in mL (0-100)
-        cleaner_delay : int
-            Delay before closing valves in cleaner in ms (0-1000)
-        airgap : int
-            Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100)
-        airgap_speed : int
-            Speed of airgap aspiration in µL/s (1-1000)
-        retract_speed : int
-            Retract speed in mm/s (1-100)
-        fastwash : int
-            Use fast-wash module = 1, don't use it = 0
-        low_volume : int
-            Use pinch valves = 1, don't use them = 0
-        """
-
-        # perform consistency checks
-        kwargs = dict(
-            tips=tips,
-            waste_location=waste_location,
-            cleaner_location=cleaner_location,
-            arm=arm,
-            waste_vol=waste_vol,
-            waste_delay=waste_delay,
-            cleaner_vol=cleaner_vol,
-            cleaner_delay=cleaner_delay,
-            airgap=airgap,
-            airgap_speed=airgap_speed,
-            retract_speed=retract_speed,
-            fastwash=fastwash,
-            low_volume=low_volume,
-        )
-        (
-            tips,
-            waste_location,
-            cleaner_location,
-            arm,
-            waste_vol,
-            waste_delay,
-            cleaner_vol,
-            cleaner_delay,
-            airgap,
-            airgap_speed,
-            retract_speed,
-            fastwash,
-            low_volume,
-        ) = _prepare_evo_wash_parameters(**kwargs)
-        # calculate tip_selection based on tips argument
-        tip_selection = 0
-        for tip in tips:
-            tip_selection += tip.value
-
-        self.append(
-            f'B;Wash({tip_selection},{waste_location[0]},{waste_location[1]},{cleaner_location[0]},{cleaner_location[1]},"{waste_vol}",{waste_delay},"{cleaner_vol}",{cleaner_delay},{airgap},{airgap_speed},{retract_speed},{fastwash},{low_volume},1000,{arm});'
-        )
-        return
-
-    def reagent_distribution(
-        self,
-        src_rack_label: str,
-        src_start: int,
-        src_end: int,
-        dst_rack_label: str,
-        dst_start: int,
-        dst_end: int,
-        *,
-        volume: float,
-        diti_reuse: int = 1,
-        multi_disp: int = 1,
-        exclude_wells: typing.Optional[typing.Iterable[int]] = None,
-        liquid_class: str = "",
-        direction: str = "left_to_right",
-        src_rack_id: str = "",
-        src_rack_type: str = "",
-        dst_rack_id: str = "",
-        dst_rack_type: str = "",
-    ) -> None:
-        """Transfers from a Trough into many destination wells using multi-pipetting.
-
-        Parameters
-        ----------
-        src_rack_label : str
-            Name of the source labware on the worktable
-        src_start : int
-            First well to be used in the source labware
-        end_start : int
-            Last well to be used in the source labware
-        src_rack_label : str
-            Name of the destination labware on the worktable
-        src_start : int
-            First well to be used in the destination labware
-        end_start :int
-            Last well to be used in the destination labware
-        volume : float
-            Microliters to dispense into each destination
-        diti_reuse : int
-            Number of allowed re-uses for disposable tips
-        multi_disp : int
-            Maximum number of allowed multi-dispenses
-        exclude_wells : list
-            Numbers of destination wells to skip
-        liquid_class : str
-            Liquid class to use for the operation
-        direction : str
-            Moving direction on the destination ('left_to_right' or 'right_to_left')
-        src_rack_id : str, optional
-            Barcode of the source labware
-        src_rack_type : str, optional
-            Configuration name of the source labware
-        dst_rack_id : str, optional
-            Barcode of the destination labware
-        dst_rack_type : str, optional
-            Configuration name of the destination labware
-        """
-        # check & convert arguments
-        if not direction in {"left_to_right", "right_to_left"}:
-            raise ValueError(f'"direction" must be either "left_to_right" or "right_to_left"')
-        direction = 0 if direction == "left_to_right" else 1
-
-        if exclude_wells is None:
-            exclude_wells = []
-        if len(exclude_wells) > 0:
-            # check that all excluded wells fall in the range
-            dst_range = set(range(dst_start, dst_end + 1))
-            invalid_exclusion_wells = set(exclude_wells).difference(dst_range)
-            if len(invalid_exclusion_wells) > 0:
-                raise ValueError(
-                    f"The excluded wells {invalid_exclusion_wells} are not in the destination interval [{dst_start},{dst_end}]"
-                )
-            # condense into ;-separated text
-            exclude_wells = ";" + ";".join(map(str, sorted(exclude_wells)))
-        else:
-            exclude_wells = ""
-
-        src_args = (src_rack_label, 1, volume, "", Tip.Any, src_rack_id, "", src_rack_type, "")
-        (
-            src_rack_label,
-            _,
-            _,
-            _,
-            _,
-            src_rack_id,
-            _,
-            src_rack_type,
-            _,
-        ) = _prepare_aspirate_dispense_parameters(*src_args, max_volume=self.max_volume)
-
-        dst_args = (dst_rack_label, 1, volume, "", Tip.Any, dst_rack_id, "", dst_rack_type, "")
-        (
-            dst_rack_label,
-            _,
-            _,
-            _,
-            _,
-            dst_rack_id,
-            _,
-            dst_rack_type,
-            _,
-        ) = _prepare_aspirate_dispense_parameters(*dst_args, max_volume=self.max_volume)
-
-        # automatically decrease multi_disp to support the large volume
-        # at the expense of more washing
-        if multi_disp * volume > self.max_volume:
-            logger.warning(
-                "Decreasing `multi_disp` to account for a large dispense volume. The number of washs will increase."
-            )
-            multi_disp = math.floor(self.max_volume / volume)
-
-        src_parameters = f"{src_rack_label};{src_rack_id};{src_rack_type};{src_start};{src_end}"
-        dst_parameters = f"{dst_rack_label};{dst_rack_id};{dst_rack_type};{dst_start};{dst_end}"
-        self.append(
-            f"R;{src_parameters};{dst_parameters};{volume};{liquid_class};{diti_reuse};{multi_disp};{direction}{exclude_wells}"
-        )
-        return
-
-    def aspirate(
-        self,
-        labware: liquidhandling.Labware,
-        wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
-        volumes: typing.Union[float, typing.Sequence[float], numpy.ndarray],
-        *,
-        label: typing.Optional[str] = None,
-        **kwargs,
-    ) -> None:
-        """Performs aspiration from the provided labware.
-
-        Parameters
-        ----------
-        labware : liquidhandling.Labware
-            Source labware
-        wells : str or iterable
-            List of well ids
-        volumes : float or iterable
-            Volume(s) to aspirate
-        label : str
-            Label of the operation to log into labware history
-        kwargs
-            Additional keyword arguments to pass to `aspirate_well`.
-            Most prominent example: `liquid_class`.
-            Take a look at `Worklist.aspirate_well` for the full list of options.
-        """
-        wells = numpy.array(wells).flatten("F")
-        volumes = numpy.array(volumes).flatten("F")
-        if len(volumes) == 1:
-            volumes = numpy.repeat(volumes, len(wells))
-        labware.remove(wells, volumes, label)
-        self.comment(label)
-        for well, volume in zip(wells, volumes):
-            if volume > 0:
-                self.aspirate_well(labware.name, labware.positions[well], volume, **kwargs)
-        return
-
-    def evo_aspirate(
-        self,
-        labware: liquidhandling.Labware,
-        wells: typing.Union[str, typing.List[str]],
-        labware_position: typing.Tuple[int, int],
-        tips: typing.Union[typing.List[Tip], typing.List[int]],
-        volumes: typing.Union[float, typing.List[float]],
-        liquid_class: str,
-        *,
-        label: typing.Optional[str] = None,
-    ) -> None:
-        """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
-        ----------
-        labware : liquidhandling.Labware
-            Source labware
-        labware_position : tuple
-            Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
-        wells : list of str or iterable
-            List with target well ID(s)
-        tips : list
-            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
-        volumes : float or iterable
-            Volume(s) in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
-        liquid_class : str, optional
-            Overwrites the liquid class for this step (max 32 characters)
-        """
-        # diferentiate between what is needed for volume calculation and for pipetting commands
-        wells_calc = numpy.array(wells).flatten("F")
-        volumes_calc = numpy.array(volumes).flatten("F")
-        if len(volumes_calc) == 1:
-            volumes_calc = numpy.repeat(volumes_calc, len(wells_calc))
-        labware.remove(wells_calc, volumes_calc, label)
-        self.comment(label)
-        self.evo_aspirate_well(
-            labware=labware,
-            wells=wells,
-            labware_position=labware_position,
-            volume=volumes,
-            liquid_class=liquid_class,
-            tips=tips,
-        )
-        return
-
-    def dispense(
-        self,
-        labware: liquidhandling.Labware,
-        wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
-        volumes: typing.Union[float, typing.Sequence[float], numpy.ndarray],
-        *,
-        label: typing.Optional[str] = None,
-        compositions: typing.Optional[typing.List[typing.Optional[typing.Dict[str, float]]]] = None,
-        **kwargs,
-    ) -> None:
-        """Performs dispensing into the provided labware.
-
-        Parameters
-        ----------
-        labware : liquidhandling.Labware
-            Source labware
-        wells : str or iterable
-            List of well ids
-        volumes : float or iterable
-            Volume(s) to dispense
-        label : str
-            Label of the operation to log into labware history
-        compositions : list
-            Iterable of liquid compositions
-        kwargs
-            Additional keyword arguments to pass to `dispense_well`.
-            Most prominent example: `liquid_class`.
-            Take a look at `Worklist.dispense_well` for the full list of options.
-        """
-        wells = numpy.array(wells).flatten("F")
-        volumes = numpy.array(volumes).flatten("F")
-        if len(volumes) == 1:
-            volumes = numpy.repeat(volumes, len(wells))
-        labware.add(wells, volumes, label, compositions=compositions)
-        self.comment(label)
-        for well, volume in zip(wells, volumes):
-            if volume > 0:
-                self.dispense_well(labware.name, labware.positions[well], volume, **kwargs)
-        return
-
-    def evo_dispense(
-        self,
-        labware: liquidhandling.Labware,
-        wells: typing.Union[str, typing.List[str]],
-        labware_position: typing.Tuple[int, int],
-        tips: typing.Union[typing.List[Tip], typing.List[int]],
-        volumes: typing.Union[float, typing.List[float]],
-        liquid_class: str,
-        *,
-        label: typing.Optional[str] = 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.
-
-        Parameters
-        ----------
-        labware : liquidhandling.Labware
-            Source labware
-        labware_position : tuple
-            Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
-        wells : list of str or iterable
-            List with target well ID(s)
-        tips : list
-            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
-        volumes : float or iterable
-            Volume(s) in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
-        liquid_class : str, optional
-            Overwrites the liquid class for this step (max 32 characters)
-        """
-        # diferentiate between what is needed for volume calculation and for pipetting commands
-        wells_calc = numpy.array(wells).flatten("F")
-        volumes_calc = numpy.array(volumes).flatten("F")
-        if len(volumes_calc) == 1:
-            volumes_calc = numpy.repeat(volumes_calc, len(wells_calc))
-        labware.remove(wells_calc, volumes_calc, label)
-        self.comment(label)
-        self.evo_dispense_well(
-            labware=labware,
-            wells=wells,
-            labware_position=labware_position,
-            volume=volumes,
-            liquid_class=liquid_class,
-            tips=tips,
-        )
-        return
-
-    def transfer(
-        self,
-        source: liquidhandling.Labware,
-        source_wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
-        destination: liquidhandling.Labware,
-        destination_wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
-        volumes: typing.Union[float, typing.Sequence[float], numpy.ndarray],
-        *,
-        label: typing.Optional[str] = None,
-        wash_scheme: int = 1,
-        partition_by: str = "auto",
-        **kwargs,
-    ) -> None:
-        """Transfer operation between two labwares.
-
-        Parameters
-        ----------
-        source : liquidhandling.Labware
-            Source labware
-        source_wells : str or iterable
-            List of source well ids
-        destination : liquidhandling.Labware
-            Destination labware
-        destination_wells : str or iterable
-            List of destination well ids
-        volumes : float or iterable
-            Volume(s) to transfer
-        label : str
-            Label of the operation to log into labware history
-        wash_scheme : int
-            Wash scheme to apply after every tip use
-        partition_by : str
-            one of 'auto' (default), 'source' or 'destination'
-                'auto': partitioning by source unless the source is a Trough
-                'source': partitioning by source columns
-                'destination': partitioning by destination columns
-        kwargs
-            Additional keyword arguments to pass to aspirate and dispense.
-            Most prominent example: `liquid_class`.
-            Take a look at `Worklist.aspirate_well` for the full list of options.
-        """
-        # reformat the convenience parameters
-        source_wells = numpy.array(source_wells).flatten("F")
-        destination_wells = numpy.array(destination_wells).flatten("F")
-        volumes = numpy.array(volumes).flatten("F")
-        nmax = max((len(source_wells), len(destination_wells), len(volumes)))
-
-        if len(source_wells) == 1:
-            source_wells = numpy.repeat(source_wells, nmax)
-        if len(destination_wells) == 1:
-            destination_wells = numpy.repeat(destination_wells, nmax)
-        if len(volumes) == 1:
-            volumes = numpy.repeat(volumes, nmax)
-        lengths = (len(source_wells), len(destination_wells), len(volumes))
-        assert (
-            len(set(lengths)) == 1
-        ), f"Number of source/destination/volumes must be equal. They were {lengths}"
-
-        # automatic partitioning
-        partition_by = _optimize_partition_by(source, destination, partition_by, label)
-
-        # the label applies to the entire transfer operation and is not logged at individual aspirate/dispense steps
-        self.comment(label)
-        nsteps = 0
-        lvh_extra = 0
-
-        for srcs, dsts, vols in _partition_by_column(source_wells, destination_wells, volumes, partition_by):
-            # make vector of volumes into vector of volume-lists
-            vols = [
-                _partition_volume(float(v), max_volume=self.max_volume) if self.auto_split else [v]
-                for v in vols
-            ]
-            # transfer from this source column until all wells are done
-            npartitions = max(map(len, vols))
-            # Count only the extra steps created by LVH
-            lvh_extra += sum([len(vs) - 1 for vs in vols])
-            for p in range(npartitions):
-                naccessed = 0
-                # iterate the rows
-                for s, d, vs in zip(srcs, dsts, vols):
-                    # transfer the next volume-fraction for this well
-                    if len(vs) > p:
-                        v = vs[p]
-                        if v > 0:
-                            self.aspirate(source, s, v, label=None, **kwargs)
-                            self.dispense(
-                                destination,
-                                d,
-                                v,
-                                label=None,
-                                compositions=[source.get_well_composition(s)],
-                                **kwargs,
-                            )
-                            nsteps += 1
-                            if wash_scheme is not None:
-                                self.wash(scheme=wash_scheme)
-                            naccessed += 1
-                # LVH: if multiple wells are accessed, don't group across partitions
-                if npartitions > 1 and naccessed > 1 and not p == npartitions - 1:
-                    self.commit()
-            # LVH: don't group across columns
-            if npartitions > 1:
-                self.commit()
-
-        # Condense the labware logs into one operation
-        # after the transfer operation completed to facilitate debugging.
-        # Also include the number of extra steps because of LVH if applicable.
-        if lvh_extra:
-            if label:
-                label = f"{label} ({lvh_extra} LVH steps)"
-            else:
-                label = f"{lvh_extra} LVH steps"
-        if destination == source:
-            source.condense_log(nsteps * 2, label=label)
-        else:
-            source.condense_log(nsteps, label=label)
-            destination.condense_log(nsteps, label=label)
-        return
-
-    def distribute(
-        self,
-        source: liquidhandling.Labware,
-        source_column: int,
-        destination: liquidhandling.Labware,
-        destination_wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
-        *,
-        volume: float,
-        diti_reuse: int = 1,
-        multi_disp: int = 1,
-        liquid_class: str = "",
-        label: str = "",
-        direction: str = "left_to_right",
-        src_rack_id: str = "",
-        src_rack_type: str = "",
-        dst_rack_id: str = "",
-        dst_rack_type: str = "",
-    ) -> None:
-        """Transfers from a Trough into many destination wells using multi-pipetting.
-
-        Does NOT support large volume operations.
-
-        Parameters
-        ----------
-        source : liquidhandling.Labware
-            Source labware with virtual_rows (a Trough)
-        source_column : int
-            0-based column number of the reagent in the source labware
-        destination : liquidhandling.Labware
-            Destination labware
-        destination_wells : array-like
-            List or array of destination wells
-        volume : float
-            Microliters to dispense into each destination
-        multi_disp : int
-            Maximum number of allowed multi-dispenses
-        liquid_class : str
-            Liquid class to use for the operation
-        label : str
-            Label of the operation
-        diti_reuse : int
-            Number of allowed re-uses for disposable tips
-        direction : str
-            Moving direction on the destination ('left_to_right' or 'right_to_left')
-        src_rack_id : str, optional
-            Barcode of the source labware
-        src_rack_type : str, optional
-            Configuration name of the source labware
-        dst_rack_id : str, optional
-            Barcode of the destination labware
-        dst_rack_type : str
-            Configuration name of the destination labware
-        """
-        if source.virtual_rows is None:
-            raise ValueError(
-                f'Reagent distribution only works with Trough sources. "{source.name}" is not a Trough.'
-            )
-
-        if volume > self.max_volume:
-            raise InvalidOperationError(
-                f"Reagent distribution only works with volumes smaller than the diluter volume ({self.max_volume} µl)"
-            )
-
-        # always use the entire first column of the source
-        src_start = 1 + source.n_rows * source_column
-        src_end = src_start + source.n_rows - 1
-
-        # transform destination wells into range + mask
-        destination_wells = numpy.array(destination_wells).flatten("F")
-        dst_wells = list(sorted([destination.positions[w] for w in destination_wells]))
-        dst_start, dst_end = dst_wells[0], dst_wells[-1]
-        excluded_dst_wells = set(range(dst_start, dst_end + 1)).difference(dst_wells)
-
-        # hand over to low-level command implementation
-        self.comment(label)
-        self.reagent_distribution(
-            source.name,
-            src_start,
-            src_end,
-            destination.name,
-            dst_start,
-            dst_end,
-            volume=volume,
-            diti_reuse=diti_reuse,
-            multi_disp=multi_disp,
-            exclude_wells=excluded_dst_wells,
-            liquid_class=liquid_class,
-            direction=direction,
-            src_rack_id=src_rack_id,
-            src_rack_type=src_rack_type,
-            dst_rack_id=dst_rack_id,
-            dst_rack_type=dst_rack_type,
-        )
-
-        # update volume tracking
-        n_dst = len(dst_wells)
-        source.remove(source.wells[0, source_column], volume * n_dst, label=label)
-        src_composition = source.get_well_composition(source.wells[0, source_column])
-        destination.add(destination_wells, volume, label=label, compositions=[src_composition] * n_dst)
-        return
-
-    def __repr__(self) -> str:
-        return "\n".join(self)
-
-    def __str__(self) -> str:
-        return self.__repr__()
+from robotools.evotools.exceptions import InvalidOperationError
+from robotools.evotools.types import Labwares, Tip
+from robotools.evotools.worklist import Worklist
diff --git a/robotools/evotools/exceptions.py b/robotools/evotools/exceptions.py
new file mode 100644
index 0000000..9d2e3d0
--- /dev/null
+++ b/robotools/evotools/exceptions.py
@@ -0,0 +1,7 @@
+"""Exceptions related to liquid handling with worklists."""
+
+__all__ = ("InvalidOperationError",)
+
+
+class InvalidOperationError(Exception):
+    pass
diff --git a/robotools/evotools/types.py b/robotools/evotools/types.py
new file mode 100644
index 0000000..8caa3ec
--- /dev/null
+++ b/robotools/evotools/types.py
@@ -0,0 +1,51 @@
+"""Enums and classes, and related helper functions."""
+import enum
+
+__all__ = (
+    "Labwares",
+    "Tip",
+)
+
+
+class Labwares(str, enum.Enum):
+    """Built-in EVOware labware identifiers."""
+
+    SystemLiquid = "Systemliquid"
+
+
+class Tip(enum.IntEnum):
+    """Enumeration of LiHa tip IDs."""
+
+    Any = -1
+    T1 = 1
+    T2 = 2
+    T3 = 4
+    T4 = 8
+    T5 = 16
+    T6 = 32
+    T7 = 64
+    T8 = 128
+
+
+def int_to_tip(tip_int: int):
+    """Checks and convert a tip number [1-8] to the Tecan Tip ID."""
+    if not 1 <= tip_int <= 8:
+        raise ValueError(
+            f"Tip is {tip_int} with type {type(tip_int)}, but should be an int between 1 and 8 for _int_to_tip conversion."
+        )
+    if tip_int == 1:
+        return Tip.T1
+    elif tip_int == 2:
+        return Tip.T2
+    elif tip_int == 3:
+        return Tip.T3
+    elif tip_int == 4:
+        return Tip.T4
+    elif tip_int == 5:
+        return Tip.T5
+    elif tip_int == 6:
+        return Tip.T6
+    elif tip_int == 7:
+        return Tip.T7
+    elif tip_int == 8:
+        return Tip.T8
diff --git a/robotools/evotools/utils.py b/robotools/evotools/utils.py
new file mode 100644
index 0000000..d95a562
--- /dev/null
+++ b/robotools/evotools/utils.py
@@ -0,0 +1,14 @@
+"""Generic utility functions."""
+
+
+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.
+    """
+    digits = "0123456789ABCDEF"
+    x = dec % 16
+    rest = dec // 16
+    if rest == 0:
+        return digits[x]
+    return to_hex(rest) + digits[x]
diff --git a/robotools/evotools/worklist.py b/robotools/evotools/worklist.py
new file mode 100644
index 0000000..b587345
--- /dev/null
+++ b/robotools/evotools/worklist.py
@@ -0,0 +1,1649 @@
+""" Creating worklist files for the Tecan Freedom EVO.
+"""
+import collections
+import logging
+import math
+import os
+import typing
+
+import numpy
+
+from robotools.evotools.exceptions import InvalidOperationError
+from robotools.evotools.types import Tip, int_to_tip
+from robotools.evotools.utils import to_hex
+
+from .. import liquidhandling, transform
+
+__all__ = ("Worklist",)
+
+logger = logging.getLogger("evotools")
+
+
+def _prepare_aspirate_dispense_parameters(
+    rack_label: str,
+    position: int,
+    volume: float,
+    liquid_class: str = "",
+    tip: typing.Union[Tip, int, collections.abc.Iterable] = Tip.Any,
+    rack_id: str = "",
+    tube_id: str = "",
+    rack_type: str = "",
+    forced_rack_type: str = "",
+    max_volume: typing.Optional[int] = None,
+) -> typing.Tuple[str, int, float, str, typing.Union[Tip, int, collections.abc.Iterable], str, str, str, str]:
+    """Validates and prepares aspirate/dispense parameters.
+
+    Parameters
+    ----------
+    rack_label : str
+        User-defined labware name (max 32 characters)
+    position : int
+        Number of the well
+    volume : float
+        Volume in microliters (will be rounded to 2 decimal places)
+    liquid_class : str, optional
+        Overrides the liquid class for this step (max 32 characters)
+    tip : Tip, int or Iterable of Tip / int, optional
+        Tip that will be selected (Tip, 1-8 or Iterable of the former two)
+    rack_id : str, optional
+        Barcode of the labware (max 32 characters)
+    tube_id : str, optional
+        Barcode of the tube (max 32 characters)
+    rack_type : str, optional
+        Configuration name of the labware (max 32 characters).
+        An error is raised if it missmatches with the underlying worktable.
+    forced_rack_type : str, optional
+        Overrides rack_type from worktable
+    max_volume : int, optional
+        Maximum allowed volume
+
+    Returns
+    -------
+    rack_label : str
+        User-defined labware name (max 32 characters)
+    position : int
+        Number of the well
+    volume : float
+        Volume in microliters (will be rounded to 2 decimal places)
+    liquid_class : str
+        Overrides the liquid class for this step (max 32 characters)
+    tip : Tip, int or Iterable of Tip / int
+        Tip that will be selected (Tip, 1-8 or Iterable of the former two)
+    rack_id : str
+        Barcode of the labware (max 32 characters)
+    tube_id : str
+        Barcode of the tube (max 32 characters)
+    rack_type : str
+        Configuration name of the labware (max 32 characters).
+        An error is raised if it missmatches with the underlying worktable.
+    forced_rack_type : str
+        Overrides rack_type from worktable
+    """
+    # required parameters
+    if rack_label is None:
+        raise ValueError("Missing required parameter: rack_label")
+    if not isinstance(rack_label, str) or len(rack_label) > 32 or ";" in rack_label:
+        raise ValueError(f"Invalid rack_label: {rack_label}")
+
+    if position is None:
+        raise ValueError("Missing required parameter: position")
+    if not isinstance(position, int) or position < 0:
+        raise ValueError(f"Invalid position: {position}")
+
+    if volume is None:
+        raise ValueError("Missing required parameter: volume")
+    try:
+        volume = float(volume)
+    except:
+        raise ValueError(f"Invalid volume: {volume}")
+    if volume < 0 or volume > 7158278 or numpy.isnan(volume):
+        raise ValueError(f"Invalid volume: {volume}")
+    if max_volume is not None and volume > max_volume:
+        raise InvalidOperationError(f"Volume of {volume} exceeds max_volume.")
+
+    # optional parameters
+    if not isinstance(liquid_class, str) or ";" in liquid_class:
+        raise ValueError(f"Invalid liquid_class: {liquid_class}")
+
+    if isinstance(tip, int) and not isinstance(tip, Tip):
+        # User-specified integers from 1-8 need to be converted to Tecan logic
+        tip = int_to_tip(tip)
+
+    if isinstance(tip, collections.abc.Iterable):
+        tips = []
+        for element in tip:
+            if isinstance(element, int) and not isinstance(element, Tip):
+                tips.append(int_to_tip(element))
+            elif isinstance(element, Tip):
+                if element == -1:
+                    raise ValueError(
+                        "When Iterables are used, no Tip.Any elements are allowed. Pass just one Tip.Any instead."
+                    )
+                tips.append(element)
+            else:
+                raise ValueError(
+                    f"If tip is an Iterable, it may only contain int or Tip values, not {type(element)}."
+                )
+        tip = sum(set(tips))
+    elif not isinstance(tip, Tip):
+        raise ValueError(f"tip must be an int between 1 and 8, Tip or Iterable, but was {type(tip)}.")
+
+    if not isinstance(rack_id, str) or len(rack_id) > 32 or ";" in rack_id:
+        raise ValueError(f"Invalid rack_id: {rack_id}")
+    if not isinstance(rack_type, str) or len(rack_type) > 32 or ";" in rack_type:
+        raise ValueError(f"Invalid rack_type: {rack_type}")
+    if not isinstance(forced_rack_type, str) or len(forced_rack_type) > 32 or ";" in forced_rack_type:
+        raise ValueError(f"Invalid forced_rack_type: {forced_rack_type}")
+
+    # apply rounding and corrections for the right string formatting
+    volume = f"{numpy.round(volume, decimals=2):.2f}"
+    tip = "" if tip == -1 else tip
+    return rack_label, position, volume, liquid_class, tip, rack_id, tube_id, rack_type, forced_rack_type
+
+
+def _prepare_evo_aspirate_dispense_parameters(
+    labware: liquidhandling.Labware,
+    wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
+    *,
+    labware_position: typing.Tuple[int, int],
+    volume: typing.Union[float, typing.List[float], int],
+    liquid_class: str,
+    tips: typing.Union[typing.List[Tip], typing.List[int]],
+    max_volume: typing.Optional[int] = None,
+) -> typing.Tuple[str, list, tuple, float, str, list]:
+    """Validates and prepares aspirate/dispense parameters.
+
+    Parameters
+    ----------
+    labware : liquidhandling.Labware
+        Source labware
+    wells : list of str
+        List with target well ID(s)
+    labware_position : tuple
+        Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
+    volume : int, float or list
+        Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
+    liquid_class : str, optional
+        Overwrites the liquid class for this step (max 32 characters)
+    tips : list of int
+        Tip(s) that will be selected (out of tips 1-8)
+    max_volume : int, optional
+        Maximum allowed volume
+
+    Returns
+    -------
+    labware : liquidhandling.Labware
+        Source labware
+    wells : list of str
+        List with target well ID(s)
+    labware_position : tuple
+        Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
+    volume : list
+        Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
+    liquid_class : str, optional
+        Overwrites the liquid class for this step (max 32 characters)
+    tips : list of int
+        Tip(s) that will be selected (out of tips 1-8)
+    """
+    if labware is None:
+        raise ValueError("Missing required parameter: labware")
+    if not isinstance(labware, liquidhandling.Labware):
+        raise ValueError(f"Invalid labware: {labware}")
+
+    if wells is None:
+        raise ValueError("Missing required parameter: wells")
+    if not isinstance(wells, (str, list, tuple, numpy.ndarray)):
+        raise ValueError(f"Invalid wells: {wells}")
+    if not len(wells) == len(tips):
+        raise ValueError(f"Invalid wells: wells and tips need to have the same length.")
+    if labware_position is None:
+        raise ValueError("Missing required parameter: position")
+    if not all(isinstance(position, int) for position in labware_position) or any(
+        position < 0 for position in labware_position
+    ):
+        raise ValueError(f"Invalid position: {labware_position}")
+
+    if volume is None:
+        raise ValueError("Missing required parameter: volume")
+    if isinstance(volume, list):
+        for vol in volume:
+            try:
+                vol = float(vol)
+            except:
+                raise ValueError(f"Invalid volume: {vol}")
+            if vol < 0 or vol > 7158278 or numpy.isnan(vol):
+                raise ValueError(f"Invalid volume: {vol}")
+            if max_volume is not None and vol > max_volume:
+                raise InvalidOperationError(f"Invalid volume: volume of {vol} exceeds max_volume.")
+        if not len(volume) == len(tips) == len(wells):
+            raise Exception(
+                f"Invalid volume: Tips, wells, and volume lists have different lengths ({len(tips)}, {len(wells)} and {len(volume)}, respectively)."
+            )
+    elif isinstance(volume, (float, int)):
+        # test volume like in the list section
+        if volume < 0 or volume > 7158278 or numpy.isnan(volume):
+            raise ValueError(f"Invalid volume: {volume}")
+        if max_volume is not None and volume > max_volume:
+            raise InvalidOperationError(f"Invalid volume: volume of {volume} exceeds max_volume.")
+        # convert volume to list and multiply list to reach identical length as wells
+        volume = [float(volume)] * len(wells)
+    else:
+        raise ValueError(f"Invalid volume: {volume}")
+
+    # apply rounding and corrections for the right string formatting
+    volume = numpy.round(volume, decimals=2).tolist()
+
+    if liquid_class is None:
+        raise ValueError(f"Missing required parameter: liquid_class")
+    if not isinstance(liquid_class, str) or ";" in liquid_class:
+        raise ValueError(f"Invalid liquid_class: {liquid_class}")
+
+    if tips is None:
+        raise ValueError(f"Missing required parameter: tips")
+    for tip in tips:
+        if not isinstance(tip, (int, Tip)):
+            raise ValueError(f"Invalid type of tips: {type(tip)}. Has to be int or Tip.")
+    tecan_tips = []
+    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
+            tip = int_to_tip(tip)
+        tecan_tips.append(tip)
+
+    return labware, wells, labware_position, volume, liquid_class, tecan_tips
+
+
+def _prepare_evo_wash_parameters(
+    *,
+    tips: typing.Union[typing.List[Tip], typing.List[int]],
+    waste_location: typing.Tuple[int, int],
+    cleaner_location: typing.Tuple[int, int],
+    arm: int = 0,
+    waste_vol: float = 3.0,
+    waste_delay: int = 500,
+    cleaner_vol: float = 4.0,
+    cleaner_delay: int = 500,
+    airgap: int = 10,
+    airgap_speed: int = 70,
+    retract_speed: int = 30,
+    fastwash: int = 1,
+    low_volume: int = 0,
+) -> typing.Tuple[list, tuple, tuple, int, float, int, float, int, int, int, int, int, int]:
+    """Validates and prepares aspirate/dispense parameters.
+
+    Parameters
+    ----------
+    tips : list
+        Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
+    waste_location : tuple
+        Tuple with grid position (1-67) and site number (0-127) of waste as integers
+    cleaner_location : tuple
+        Tuple with grid position (1-67) and site number (0-127) of cleaner as integers
+    arm : int
+        number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2
+    waste_vol: float
+        Volume in waste in mL (0-100)
+    waste_delay : int
+        Delay before closing valves in waste in ms (0-1000)
+    cleaner_vol: float
+        Volume in cleaner in mL (0-100)
+    cleaner_delay : int
+        Delay before closing valves in cleaner in ms (0-1000)
+    airgap : int
+        Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100)
+    airgap_speed : int
+        Speed of airgap aspiration in µL/s (1-1000)
+    retract_speed : int
+        Retract speed in mm/s (1-100)
+    fastwash : int
+        Use fast-wash module = 1, don't use it = 0
+    low_volume : int
+        Use pinch valves = 1, don't use them = 0
+
+    Returns
+    -------
+    tips : list
+        Tip(s) that will be selected; have been converted to tip.T1 - tip.T8 here if they weren't originally formatted that way
+    waste_location : tuple
+        Tuple with grid position (1-67) and site number (0-127) of waste as integers
+    cleaner_location : tuple
+        Tuple with grid position (1-67) and site number (0-127) of cleaner as integers
+    arm : int
+        number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2
+    waste_vol: float
+        Volume in waste in mL (0-100)
+    waste_delay : int
+        Delay before closing valves in waste in ms (0-1000)
+    cleaner_vol: float
+        Volume in cleaner in mL (0-100)
+    cleaner_delay : int
+        Delay before closing valves in cleaner in ms (0-1000)
+    airgap : int
+        Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100)
+    airgap_speed : int
+        Speed of airgap aspiration in µL/s (1-1000)
+    retract_speed : int
+        Retract speed in mm/s (1-100)
+    fastwash : int
+        Use fast-wash module = 1, don't use it = 0
+    low_volume : int
+        Use pinch valves = 1, don't use them = 0
+    """
+    if tips is None:
+        raise ValueError("Missing required parameter: tips")
+
+    tecan_tips = []
+    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
+            tip = int_to_tip(tip)
+        tecan_tips.append(tip)
+
+    if waste_location is None:
+        raise ValueError("Missing required parameter: waste_location")
+    grid, site = waste_location
+    if not isinstance(grid, int) or not 1 <= grid <= 67:
+        raise ValueError("Grid (first number in waste_location tuple) has to be an int from 1 - 67.")
+    if not isinstance(site, int) or not 0 <= site <= 127:
+        raise ValueError("Site (second number in waste_location tuple) has to be an int from 0 - 127.")
+
+    if cleaner_location is None:
+        raise ValueError("Missing required parameter: cleaner_location")
+    grid, site = cleaner_location
+    if not isinstance(grid, int) or not 1 <= grid <= 67:
+        raise ValueError("Grid (first number in cleaner_location tuple) has to be an int from 1 - 67.")
+    if not isinstance(site, int) or not 0 <= site <= 127:
+        raise ValueError("Site (second number in cleaner_location tuple) has to be an int from 0 - 127.")
+
+    if arm is None:
+        raise ValueError("Missing required paramter: arm")
+    if not isinstance(arm, int):
+        raise ValueError("Parameter arm is not int.")
+    if not arm == 0 and not arm == 1:
+        raise ValueError("Parameter arm has to be 0 (LiHa 1) or 1 (LiHa 2).")
+
+    if waste_vol is None:
+        raise ValueError("Missing required parameter: waste_vol")
+    if not isinstance(waste_vol, float) or not 0 <= waste_vol <= 100:
+        raise ValueError("waste_vol has to be a float from 0 - 100.")
+    # round waste_vol to the first decimal (pre-requisite for Tecan's wash command)
+    waste_vol = numpy.round(waste_vol, 1)
+
+    if waste_delay is None:
+        raise ValueError("Missing required parameter: waste_delay")
+    if not isinstance(waste_delay, int) or not 0 <= waste_delay <= 1000:
+        raise ValueError("waste_delay has to be an int from 0 - 1000.")
+
+    if cleaner_vol is None:
+        raise ValueError("Missing required parameter: cleaner_vol")
+    if not isinstance(cleaner_vol, float) or not 0 <= cleaner_vol <= 100:
+        raise ValueError("cleaner_vol has to be a float from 0 - 100.")
+    # round cleaner_vol to the first decimal (pre-requisite for Tecan's wash command)
+    cleaner_vol = numpy.round(cleaner_vol, 1)
+
+    if cleaner_delay is None:
+        raise ValueError("Missing required parameter: cleaner_delay")
+    if not isinstance(cleaner_delay, int) or not 0 <= cleaner_delay <= 1000:
+        raise ValueError("cleaner_delay has to be an int from 0 - 1000.")
+
+    if airgap is None:
+        raise ValueError("Missing required parameter: airgap")
+    if not isinstance(airgap, int) or not 0 <= airgap <= 100:
+        raise ValueError("airgap has to be an int from 0 - 100.")
+
+    if airgap_speed is None:
+        raise ValueError("Missing required parameter: airgap_speed")
+    if not isinstance(airgap_speed, int) or not 1 <= airgap_speed <= 1000:
+        raise ValueError("airgap_speed has to be an int from 1 - 1000.")
+
+    if retract_speed is None:
+        raise ValueError("Missing required parameter: retract_speed")
+    if not isinstance(retract_speed, int) or not 1 <= retract_speed <= 100:
+        raise ValueError("retract_speed has to be an int from 1 - 100.")
+
+    if fastwash is None:
+        raise ValueError("Missing required paramter: fastwash")
+    if not isinstance(fastwash, int):
+        raise ValueError("Parameter fastwash is not int.")
+    if not fastwash == 0 and not fastwash == 1:
+        raise ValueError("Parameter fastwash has to be 0 (no fast-wash) or 1 (use fast-wash).")
+
+    if low_volume is None:
+        raise ValueError("Missing required paramter: low_volume")
+    if not isinstance(low_volume, int):
+        raise ValueError("Parameter low_volume is not int.")
+    if not low_volume == 0 and not low_volume == 1:
+        raise ValueError("Parameter low_volume has to be 0 (no fast-wash) or 1 (use fast-wash).")
+
+    return (
+        tecan_tips,
+        waste_location,
+        cleaner_location,
+        arm,
+        waste_vol,
+        waste_delay,
+        cleaner_vol,
+        cleaner_delay,
+        airgap,
+        airgap_speed,
+        retract_speed,
+        fastwash,
+        low_volume,
+    )
+
+
+def _optimize_partition_by(
+    source: liquidhandling.Labware,
+    destination: liquidhandling.Labware,
+    partition_by: str,
+    label: typing.Optional[str] = None,
+) -> str:
+    """Determines optimal partitioning settings.
+
+    Parameters
+    ----------
+    source (Labware): source labware object
+    destination (Labware): destination labware object
+    partition_by : str
+    user-provided partitioning settings
+    label : str
+    label of the operation (optional)
+
+    Returns
+    -------
+    partition_by : str
+        Either 'source' or 'destination'
+    """
+    if not partition_by in {"auto", "source", "destination"}:
+        raise ValueError(f"Invalid partition_by argument: {partition_by}")
+    # automatic partitioning decision
+    if partition_by == "auto":
+        if source.is_trough and not destination.is_trough:
+            partition_by = "destination"
+        else:
+            partition_by = "source"
+    else:
+        # log warnings about potentially inefficient partitioning settings
+        if partition_by == "source" and source.is_trough and not destination.is_trough:
+            logger.warning(
+                f'Partitioning by "source" ({source.name}), which is a Trough while destination ({destination.name}) is not a Trough.'
+                ' This is potentially inefficient. Consider using partition_by="destination".'
+                f" (label={label})"
+            )
+        elif partition_by == "destination" and destination.is_trough and not source.is_trough:
+            logger.warning(
+                f'Partitioning by "destination" ({destination.name}), which is a Trough while source ({source.name}) is not a Trough.'
+                ' This is potentially inefficient. Consider using partition_by="source"'
+                f" (label={label})"
+            )
+    return partition_by
+
+
+def _partition_volume(volume: float, *, max_volume: int) -> typing.List[float]:
+    """Partitions a pipetting volume into zero or more integer-valued volumes that are <= max_volume.
+
+    Parameters
+    ----------
+    volume : float
+        A volume to partition
+    max_volume : int
+        Maximum volume of a pipetting step
+
+    Returns
+    -------
+    volumes : list
+        Partitioned volumes
+    """
+    if volume == 0:
+        return []
+    if volume < max_volume:
+        return [volume]
+    isteps = math.ceil(volume / max_volume)
+    step_volume = math.ceil(volume / isteps)
+    volumes: typing.List[float] = [step_volume] * (isteps - 1)
+    volumes.append(volume - numpy.sum(volumes))
+    return volumes
+
+
+def _partition_by_column(
+    sources: typing.Iterable[str],
+    destinations: typing.Iterable[str],
+    volumes: typing.Iterable[float],
+    partition_by: str,
+) -> typing.Dict[int, typing.Tuple[typing.List[str], typing.List[str], typing.List[float]]]:
+    """Partitions sources/destinations/volumes by the source column and sorts within those columns.
+
+    Parameters
+    ----------
+    sources : list
+        The source well ids; same length as destinations and volumes
+    destinations : list
+        The destination well ids; same length as sources and volumes
+    volumes : list
+        The volumes; same length as sources and destinations
+    partition_by : str
+        Either 'source' or 'destination'
+
+    Returns
+    -------
+    column_groups : list
+        A list of (sources, destinations, volumes)
+    """
+    # first partition the wells into columns
+    column_groups = collections.defaultdict(lambda: ([], [], []))
+    for s, d, v in zip(sources, destinations, volumes):
+        if partition_by == "source":
+            group = s[1:]
+        elif partition_by == "destination":
+            group = d[1:]
+        else:
+            raise ValueError(f'Invalid `partition_by` parameter "{partition_by}""')
+        column_groups[group][0].append(s)
+        column_groups[group][1].append(d)
+        column_groups[group][2].append(v)
+    # bring columns in the right order
+    column_groups = [column_groups[col] for col in sorted(column_groups.keys())]
+    # sort the rows within the column
+    for c, (srcs, dsts, vols) in enumerate(column_groups):
+        if partition_by == "source":
+            order = numpy.argsort(srcs)
+        elif partition_by == "destination":
+            order = numpy.argsort(dsts)
+        else:
+            raise ValueError(f'Invalid `partition_by` parameter "{partition_by}""')
+        column_groups[c] = (
+            list(numpy.array(srcs)[order]),
+            list(numpy.array(dsts)[order]),
+            list(numpy.array(vols)[order]),
+        )
+    return column_groups
+
+
+def evo_make_selection_array(rows: int, columns: int, wells: numpy.ndarray):
+    """Translate well IDs to a numpy array with 1s (selected) and 0s (not selected).
+
+    Parameters
+    ----------
+    rows : int
+        Number of rows of target labware object
+    cols : int
+        Number of columns of target labware object
+    wells : List[str]
+        Selected wells by well IDs as strings (e.g. ["A01", "B01"])
+
+    Returns
+    -------
+    selection_array : numpy.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 = numpy.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 wells:
+        selection_array[well_index_dict[well]] = 1
+    return selection_array
+
+
+def evo_get_selection(rows: int, cols: int, selected: numpy.ndarray):
+    """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).
+
+    Parameters
+    ----------
+    rows : int
+        Number of rows of target labware object
+    cols : int
+        Number of columns of target labware object
+    selected : numpy.ndarray
+        Numpy array in labware dimensions with selected wells as 1 and others as 0 (from evo_make_selection_array)
+
+    Returns
+    -------
+    selection : str
+        Code string for well selection of pipetting actions in EvoWare scripts (.esc)
+    """
+    # apply bit mask with 7 bits, adapted from function detailed in EvoWare manual
+    selection = f"0{to_hex(cols)}{rows:02d}"
+    bit_counter = 0
+    bit_mask = 0
+    for x in range(cols):
+        for y in range(rows):
+            if selected[y, x] == 1:
+                bit_mask |= 1 << bit_counter
+            bit_counter += 1
+            if bit_counter > 6:
+                selection += chr(bit_mask + 48)
+                bit_counter = 0
+                bit_mask = 0
+    if bit_counter > 0:
+        selection += chr(bit_mask + 48)
+
+    # check if wells from more than one column are selected and raise Exception if so
+    check = 0
+    for column in selected.transpose():
+        if sum(column) >= 1:
+            check += 1
+    if check >= 2:
+        raise Exception(
+            "Wells from more than one column are selected.\nSelect only wells from one column per pipetting action."
+        )
+
+    return selection
+
+
+class Worklist(list):
+    """Context manager for the creation of Worklists."""
+
+    def __init__(self, filepath: str = None, max_volume: int = 950, auto_split: bool = True) -> None:
+        """Creates a worklist writer.
+
+        Parameters
+        ----------
+        filepath : str
+            Optional filename/filepath to write when the context is exited (must include a .gwl extension)
+        max_volume : int
+            Maximum aspiration volume in µL
+        auto_split : bool
+            If `True`, large volumes in transfer operations are automatically splitted.
+            If set to `False`, `InvalidOperationError` is raised when a pipetting volume exceeds `max_volume`.
+        """
+        self._filepath = filepath
+        if max_volume is None:
+            raise ValueError("The `max_volume` parameter is required.")
+        self.max_volume = max_volume
+        self.auto_split = auto_split
+        super().__init__()
+
+    def __enter__(self) -> None:
+        self.clear()
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+        if self._filepath:
+            self.save(self._filepath)
+        return
+
+    def save(self, filepath: str) -> None:
+        """Writes the worklist to the filepath.
+
+        Parameters
+        ----------
+        filepath : str
+            File name or path to write (must include a .gwl extension)
+        """
+        assert ".gwl" in filepath.lower(), "The filename did not contain the .gwl extension."
+        if os.path.exists(filepath):
+            os.remove(filepath)
+        with open(filepath, "w", newline="\r\n", encoding="latin_1") as file:
+            file.write("\n".join(self))
+        return
+
+    def comment(self, comment: typing.Optional[str]) -> None:
+        """Adds a comment.
+
+        Parameters
+        ----------
+        comment : str
+            A single- or multi-line comment. Be nice and avoid special characters.
+        """
+        if not comment:
+            return
+        if ";" in comment:
+            raise ValueError("Illegal semicolon in comment.")
+        for cline in comment.split("\n"):
+            cline = cline.strip()
+            if cline:
+                self.append(f"C;{cline}")
+        return
+
+    def wash(self, scheme: int = 1) -> None:
+        """Washes fixed tips or replaces DiTis.
+
+        Washes/replaces the tip that was used by the preceding aspirate record(s).
+
+        Parameters
+        ----------
+        scheme : int
+            Number indicating the wash scheme (default: 1)
+        """
+        if not scheme in {1, 2, 3, 4}:
+            raise ValueError("scheme must be either 1, 2, 3 or 4")
+        self.append(f"W{scheme};")
+        return
+
+    def decontaminate(self) -> None:
+        """Decontamination wash consists of a decontamination wash followed by a normal wash."""
+        self.append("WD;")
+        return
+
+    def flush(self) -> None:
+        """Discards the contents of the tips WITHOUT WASHING or DROPPING of tips."""
+        self.append("F;")
+        return
+
+    def commit(self) -> None:
+        """Inserts a 'break' that forces the execution of aspirate/dispense operations at this point.
+
+        WARNING: may be unreliable
+
+        If you don’t specify a Break record, Freedom EVOware normally executes
+        pipetting commands in groups to optimize the efficiency. For example, if
+        you have specified four tips in the Worklist command, Freedom EVOware
+        will queue Aspirate records until four of them are ready for execution.
+        This allows pipetting to take place using all four tips at the same time.
+        Specify the Break record if you want to execute all of the currently queued
+        commands without waiting. You can use the Break record e.g. to create a
+        worklist which pipettes using only one tip at a time (even if you chose
+        more than one tip in the tip selection).
+        """
+        self.append("B;")
+        return
+
+    def set_diti(self, diti_index: int) -> None:
+        """Switches the DiTi types within the worklist.
+
+        IMPORTANT: As the DiTi index in worklists is 1-based you have to increase the shown DiTi index by one.
+
+        Choose the required DiTi type by specifying the DiTi index.
+        Freedom EVOware automatically assigns a unique index to each DiTi type.
+        The DiTi index is shown in the Edit Labware dialog box for the DiTi labware (Well dimensions tab).
+
+        The Set DiTi Type record can only be used at the very beginning of the
+        worklist or directly after a Break record. A Break record always resets
+        the DiTi type to the type selected in the Worklist command. Accordingly,
+        if your worklist contains a Break record, you may need to specify the
+        Set DiTi Type record again.
+
+        Parameters
+        ----------
+        diti_index : int
+            Type of DiTis to use in subsequent steps
+        """
+        if not (len(self) == 0 or self[-1][0] == "B"):
+            raise InvalidOperationError(
+                "DiTi type can only be switched at the beginning or after a Break/commit step. Read the docstring."
+            )
+        self.append(f"S;{diti_index}")
+        return
+
+    def aspirate_well(
+        self,
+        rack_label: str,
+        position: int,
+        volume: float,
+        *,
+        liquid_class: str = "",
+        tip: typing.Union[Tip, int, typing.Iterable] = Tip.Any,
+        rack_id: str = "",
+        tube_id: str = "",
+        rack_type: str = "",
+        forced_rack_type: str = "",
+    ) -> None:
+        """Command for aspirating with a single tip.
+
+        Each Aspirate record specifies the aspiration parameters for a single tip (the next unused tip from the tip selection you have specified).
+
+        Parameters
+        ----------
+        rack_label : str
+            User-defined labware name (max 32 characters)
+        position : int
+            Number of the well
+        volume : float
+            Volume in microliters (will be rounded to 2 decimal places)
+        liquid_class : str, optional
+            Overwrites the liquid class for this step (max 32 characters)
+        tip : Tip, int or Iterable of Tip / int, optional
+            Tip that will be selected (Tip, 1-8 or Iterable of the former two)
+        rack_id : str, optional
+            Barcode of the labware (max 32 characters)
+        tube_id : str, optional
+            Barcode of the tube (max 32 characters)
+        rack_type : str
+            Configuration name of the labware (max 32 characters).
+            An error is raised if it missmatches with the underlying worktable.
+        forced_rack_type : str, optional
+            Overrides rack_type from worktable
+        """
+        args = (
+            rack_label,
+            position,
+            volume,
+            liquid_class,
+            tip,
+            rack_id,
+            tube_id,
+            rack_type,
+            forced_rack_type,
+        )
+        (
+            rack_label,
+            position,
+            volume,
+            liquid_class,
+            tip,
+            rack_id,
+            tube_id,
+            rack_type,
+            forced_rack_type,
+        ) = _prepare_aspirate_dispense_parameters(*args, max_volume=self.max_volume)
+        tip_type = ""
+        self.append(
+            f"A;{rack_label};{rack_id};{rack_type};{position};{tube_id};{volume};{liquid_class};{tip_type};{tip};{forced_rack_type}"
+        )
+        return
+
+    def evo_aspirate_well(
+        self,
+        *,
+        labware: liquidhandling.Labware,
+        wells: typing.Union[str, typing.List[str]],
+        labware_position: typing.Tuple[int, int],
+        volume: typing.Union[float, typing.List[float], int],
+        liquid_class: str,
+        tips: typing.Union[typing.List[Tip], typing.List[int]],
+    ) -> 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.
+        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.
+
+        Parameters
+        ----------
+        labware : liquidhandling.Labware
+            Source labware
+        wells : list of str
+            List with target well ID(s)
+        labware_position : tuple
+            Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
+        volume : int, float or list
+            Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
+        liquid_class : str, optional
+            Overwrites the liquid class for this step (max 32 characters)
+        tips : list
+            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
+        """
+        # perform consistency checks
+        kwargs = dict(
+            labware=labware,
+            wells=wells,
+            labware_position=labware_position,
+            volume=volume,
+            liquid_class=liquid_class,
+            tips=tips,
+        )
+        (
+            labware,
+            wells,
+            labware_position,
+            volume,
+            liquid_class,
+            tips,
+        ) = _prepare_evo_aspirate_dispense_parameters(**kwargs, max_volume=self.max_volume)
+
+        # calculate tip_selection based on tips argument (tips are converted to evotools.Tip in _prepare_evo_aspirate_dispense_parameters)
+        tip_selection = 0
+        for tip in tips:
+            tip_selection += tip.value
+
+        # prepare volume section (volume is converted to list in _prepare_evo_aspirate_dispense_parameters)
+        tip_volumes = ""
+        for tip in [1, 2, 4, 8, 16, 32, 64, 128]:
+            if tip in [tecantip.value for tecantip in tips]:
+                tip_volumes += f'"{volume[0]}",'
+                volume.pop(0)
+            else:
+                tip_volumes += "0,"
+
+        # convert selection from list of well ids to numpy array with same dimensions as target labware (1: well is selected, 0: well is not selected)
+        selected = evo_make_selection_array(labware.n_rows, labware.n_columns, wells)
+        # create code string containing information about target well(s)
+        code_string = evo_get_selection(labware.n_rows, labware.n_columns, selected)
+        self.append(
+            f'B;Aspirate({tip_selection},"{liquid_class}",{tip_volumes}0,0,0,0,{labware_position[0]},{labware_position[1]},1,"{code_string}",0,0);'
+        )
+        return
+
+    def dispense_well(
+        self,
+        rack_label: str,
+        position: int,
+        volume: float,
+        *,
+        liquid_class: str = "",
+        tip: typing.Union[Tip, int] = Tip.Any,
+        rack_id: str = "",
+        tube_id: str = "",
+        rack_type: str = "",
+        forced_rack_type: str = "",
+    ) -> None:
+        """Command for dispensing with a single tip.
+
+        Each Dispense record specifies the dispense parameters for a single tip.
+        It uses the same tip which was used by the preceding Aspirate record.
+
+        Parameters
+        ----------
+        rack_label : str
+            User-defined labware name (max 32 characters)
+        position : int
+            Number of the well
+        volume : float
+            Volume in microliters (will be rounded to 2 decimal places)
+        liquid_class : str, optional
+            Overwrites the liquid class for this step (max 32 characters)
+        tip : Tip, int or Iterable of Tip / int, optional
+            Tip that will be selected (Tip, 1-8 or Iterable of the former two)
+        rack_id : str, optional
+            Barcode of the labware (max 32 characters)
+        tube_id : str, optional
+            Barcode of the tube (max 32 characters)
+        rack_type : str, optional
+            Configuration name of the labware (max 32 characters).
+            An error is raised if it missmatches with the underlying worktable.
+        forced_rack_type : str, optional
+            Overrides rack_type from worktable
+        """
+        args = (
+            rack_label,
+            position,
+            volume,
+            liquid_class,
+            tip,
+            rack_id,
+            tube_id,
+            rack_type,
+            forced_rack_type,
+        )
+        (
+            rack_label,
+            position,
+            volume,
+            liquid_class,
+            tip,
+            rack_id,
+            tube_id,
+            rack_type,
+            forced_rack_type,
+        ) = _prepare_aspirate_dispense_parameters(*args, max_volume=self.max_volume)
+        tip_type = ""
+        self.append(
+            f"D;{rack_label};{rack_id};{rack_type};{position};{tube_id};{volume};{liquid_class};{tip_type};{tip};{forced_rack_type}"
+        )
+        return
+
+    def evo_dispense_well(
+        self,
+        *,
+        labware: liquidhandling.Labware,
+        wells: typing.Union[str, typing.List[str]],
+        labware_position: typing.Tuple[int, int],
+        volume: typing.Union[float, typing.List[float], int],
+        liquid_class: str,
+        tips: typing.Union[typing.List[Tip], typing.List[int]],
+    ) -> None:
+        """Command for dispensing using the EvoWARE dispense 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.
+
+        Parameters
+        ----------
+        labware : liquidhandling.Labware
+            Source labware
+        wells : list of str
+            List with target well ID(s)
+        labware_position : tuple
+            Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
+        volume : int, float or list
+            Volume in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
+        liquid_class : str, optional
+            Overwrites the liquid class for this step (max 32 characters)
+        tips : list
+            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
+        """
+        # perform consistency checks
+        kwargs = dict(
+            labware=labware,
+            wells=wells,
+            labware_position=labware_position,
+            volume=volume,
+            liquid_class=liquid_class,
+            tips=tips,
+        )
+        (
+            labware,
+            wells,
+            labware_position,
+            volume,
+            liquid_class,
+            tips,
+        ) = _prepare_evo_aspirate_dispense_parameters(**kwargs, max_volume=self.max_volume)
+
+        # calculate tip_selection based on tips argument (tips are converted to evotools.Tip in _prepare_evo_aspirate_dispense_parameters)
+        tip_selection = 0
+        for tip in tips:
+            tip_selection += tip.value
+
+        # prepare volume section (volume is converted to list in _prepare_evo_aspirate_dispense_parameters)
+        tip_volumes = ""
+        for tip in [1, 2, 4, 8, 16, 32, 64, 128]:
+            if tip in [tecantip.value for tecantip in tips]:
+                tip_volumes += f'"{volume[0]}",'
+                volume.pop(0)
+            else:
+                tip_volumes += "0,"
+
+        # convert selection from list of well ids to numpy array with same dimensions as target labware (1: well is selected, 0: well is not selected)
+        selected = evo_make_selection_array(labware.n_rows, labware.n_columns, wells)
+        # create code string containing information about target well(s)
+        code_string = evo_get_selection(labware.n_rows, labware.n_columns, selected)
+        self.append(
+            f'B;Dispense({tip_selection},"{liquid_class}",{tip_volumes}0,0,0,0,{labware_position[0]},{labware_position[1]},1,"{code_string}",0,0);'
+        )
+        return
+
+    def evo_wash(
+        self,
+        *,
+        tips: typing.Union[typing.List[Tip], typing.List[int]],
+        waste_location: typing.Tuple[int, int],
+        cleaner_location: typing.Tuple[int, int],
+        arm: int = 0,
+        waste_vol: float = 3.0,
+        waste_delay: int = 500,
+        cleaner_vol: float = 4.0,
+        cleaner_delay: int = 500,
+        airgap: int = 10,
+        airgap_speed: int = 70,
+        retract_speed: int = 30,
+        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.
+        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.
+
+        Parameters
+        ----------
+        tips : list
+            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
+        waste_location : tuple
+            Tuple with grid position (1-67) and site number (0-127) of waste as integers
+        cleaner_location : tuple
+            Tuple with grid position (1-67) and site number (0-127) of cleaner as integers
+        arm : int
+            number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2
+        waste_vol: float
+            Volume in waste in mL (0-100)
+        waste_delay : int
+            Delay before closing valves in waste in ms (0-1000)
+        cleaner_vol: float
+            Volume in cleaner in mL (0-100)
+        cleaner_delay : int
+            Delay before closing valves in cleaner in ms (0-1000)
+        airgap : int
+            Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100)
+        airgap_speed : int
+            Speed of airgap aspiration in µL/s (1-1000)
+        retract_speed : int
+            Retract speed in mm/s (1-100)
+        fastwash : int
+            Use fast-wash module = 1, don't use it = 0
+        low_volume : int
+            Use pinch valves = 1, don't use them = 0
+        """
+
+        # perform consistency checks
+        kwargs = dict(
+            tips=tips,
+            waste_location=waste_location,
+            cleaner_location=cleaner_location,
+            arm=arm,
+            waste_vol=waste_vol,
+            waste_delay=waste_delay,
+            cleaner_vol=cleaner_vol,
+            cleaner_delay=cleaner_delay,
+            airgap=airgap,
+            airgap_speed=airgap_speed,
+            retract_speed=retract_speed,
+            fastwash=fastwash,
+            low_volume=low_volume,
+        )
+        (
+            tips,
+            waste_location,
+            cleaner_location,
+            arm,
+            waste_vol,
+            waste_delay,
+            cleaner_vol,
+            cleaner_delay,
+            airgap,
+            airgap_speed,
+            retract_speed,
+            fastwash,
+            low_volume,
+        ) = _prepare_evo_wash_parameters(**kwargs)
+        # calculate tip_selection based on tips argument
+        tip_selection = 0
+        for tip in tips:
+            tip_selection += tip.value
+
+        self.append(
+            f'B;Wash({tip_selection},{waste_location[0]},{waste_location[1]},{cleaner_location[0]},{cleaner_location[1]},"{waste_vol}",{waste_delay},"{cleaner_vol}",{cleaner_delay},{airgap},{airgap_speed},{retract_speed},{fastwash},{low_volume},1000,{arm});'
+        )
+        return
+
+    def reagent_distribution(
+        self,
+        src_rack_label: str,
+        src_start: int,
+        src_end: int,
+        dst_rack_label: str,
+        dst_start: int,
+        dst_end: int,
+        *,
+        volume: float,
+        diti_reuse: int = 1,
+        multi_disp: int = 1,
+        exclude_wells: typing.Optional[typing.Iterable[int]] = None,
+        liquid_class: str = "",
+        direction: str = "left_to_right",
+        src_rack_id: str = "",
+        src_rack_type: str = "",
+        dst_rack_id: str = "",
+        dst_rack_type: str = "",
+    ) -> None:
+        """Transfers from a Trough into many destination wells using multi-pipetting.
+
+        Parameters
+        ----------
+        src_rack_label : str
+            Name of the source labware on the worktable
+        src_start : int
+            First well to be used in the source labware
+        end_start : int
+            Last well to be used in the source labware
+        src_rack_label : str
+            Name of the destination labware on the worktable
+        src_start : int
+            First well to be used in the destination labware
+        end_start :int
+            Last well to be used in the destination labware
+        volume : float
+            Microliters to dispense into each destination
+        diti_reuse : int
+            Number of allowed re-uses for disposable tips
+        multi_disp : int
+            Maximum number of allowed multi-dispenses
+        exclude_wells : list
+            Numbers of destination wells to skip
+        liquid_class : str
+            Liquid class to use for the operation
+        direction : str
+            Moving direction on the destination ('left_to_right' or 'right_to_left')
+        src_rack_id : str, optional
+            Barcode of the source labware
+        src_rack_type : str, optional
+            Configuration name of the source labware
+        dst_rack_id : str, optional
+            Barcode of the destination labware
+        dst_rack_type : str, optional
+            Configuration name of the destination labware
+        """
+        # check & convert arguments
+        if not direction in {"left_to_right", "right_to_left"}:
+            raise ValueError(f'"direction" must be either "left_to_right" or "right_to_left"')
+        direction = 0 if direction == "left_to_right" else 1
+
+        if exclude_wells is None:
+            exclude_wells = []
+        if len(exclude_wells) > 0:
+            # check that all excluded wells fall in the range
+            dst_range = set(range(dst_start, dst_end + 1))
+            invalid_exclusion_wells = set(exclude_wells).difference(dst_range)
+            if len(invalid_exclusion_wells) > 0:
+                raise ValueError(
+                    f"The excluded wells {invalid_exclusion_wells} are not in the destination interval [{dst_start},{dst_end}]"
+                )
+            # condense into ;-separated text
+            exclude_wells = ";" + ";".join(map(str, sorted(exclude_wells)))
+        else:
+            exclude_wells = ""
+
+        src_args = (src_rack_label, 1, volume, "", Tip.Any, src_rack_id, "", src_rack_type, "")
+        (
+            src_rack_label,
+            _,
+            _,
+            _,
+            _,
+            src_rack_id,
+            _,
+            src_rack_type,
+            _,
+        ) = _prepare_aspirate_dispense_parameters(*src_args, max_volume=self.max_volume)
+
+        dst_args = (dst_rack_label, 1, volume, "", Tip.Any, dst_rack_id, "", dst_rack_type, "")
+        (
+            dst_rack_label,
+            _,
+            _,
+            _,
+            _,
+            dst_rack_id,
+            _,
+            dst_rack_type,
+            _,
+        ) = _prepare_aspirate_dispense_parameters(*dst_args, max_volume=self.max_volume)
+
+        # automatically decrease multi_disp to support the large volume
+        # at the expense of more washing
+        if multi_disp * volume > self.max_volume:
+            logger.warning(
+                "Decreasing `multi_disp` to account for a large dispense volume. The number of washs will increase."
+            )
+            multi_disp = math.floor(self.max_volume / volume)
+
+        src_parameters = f"{src_rack_label};{src_rack_id};{src_rack_type};{src_start};{src_end}"
+        dst_parameters = f"{dst_rack_label};{dst_rack_id};{dst_rack_type};{dst_start};{dst_end}"
+        self.append(
+            f"R;{src_parameters};{dst_parameters};{volume};{liquid_class};{diti_reuse};{multi_disp};{direction}{exclude_wells}"
+        )
+        return
+
+    def aspirate(
+        self,
+        labware: liquidhandling.Labware,
+        wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
+        volumes: typing.Union[float, typing.Sequence[float], numpy.ndarray],
+        *,
+        label: typing.Optional[str] = None,
+        **kwargs,
+    ) -> None:
+        """Performs aspiration from the provided labware.
+
+        Parameters
+        ----------
+        labware : liquidhandling.Labware
+            Source labware
+        wells : str or iterable
+            List of well ids
+        volumes : float or iterable
+            Volume(s) to aspirate
+        label : str
+            Label of the operation to log into labware history
+        kwargs
+            Additional keyword arguments to pass to `aspirate_well`.
+            Most prominent example: `liquid_class`.
+            Take a look at `Worklist.aspirate_well` for the full list of options.
+        """
+        wells = numpy.array(wells).flatten("F")
+        volumes = numpy.array(volumes).flatten("F")
+        if len(volumes) == 1:
+            volumes = numpy.repeat(volumes, len(wells))
+        labware.remove(wells, volumes, label)
+        self.comment(label)
+        for well, volume in zip(wells, volumes):
+            if volume > 0:
+                self.aspirate_well(labware.name, labware.positions[well], volume, **kwargs)
+        return
+
+    def evo_aspirate(
+        self,
+        labware: liquidhandling.Labware,
+        wells: typing.Union[str, typing.List[str]],
+        labware_position: typing.Tuple[int, int],
+        tips: typing.Union[typing.List[Tip], typing.List[int]],
+        volumes: typing.Union[float, typing.List[float]],
+        liquid_class: str,
+        *,
+        label: typing.Optional[str] = None,
+    ) -> None:
+        """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
+        ----------
+        labware : liquidhandling.Labware
+            Source labware
+        labware_position : tuple
+            Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
+        wells : list of str or iterable
+            List with target well ID(s)
+        tips : list
+            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
+        volumes : float or iterable
+            Volume(s) in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
+        liquid_class : str, optional
+            Overwrites the liquid class for this step (max 32 characters)
+        """
+        # diferentiate between what is needed for volume calculation and for pipetting commands
+        wells_calc = numpy.array(wells).flatten("F")
+        volumes_calc = numpy.array(volumes).flatten("F")
+        if len(volumes_calc) == 1:
+            volumes_calc = numpy.repeat(volumes_calc, len(wells_calc))
+        labware.remove(wells_calc, volumes_calc, label)
+        self.comment(label)
+        self.evo_aspirate_well(
+            labware=labware,
+            wells=wells,
+            labware_position=labware_position,
+            volume=volumes,
+            liquid_class=liquid_class,
+            tips=tips,
+        )
+        return
+
+    def dispense(
+        self,
+        labware: liquidhandling.Labware,
+        wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
+        volumes: typing.Union[float, typing.Sequence[float], numpy.ndarray],
+        *,
+        label: typing.Optional[str] = None,
+        compositions: typing.Optional[typing.List[typing.Optional[typing.Dict[str, float]]]] = None,
+        **kwargs,
+    ) -> None:
+        """Performs dispensing into the provided labware.
+
+        Parameters
+        ----------
+        labware : liquidhandling.Labware
+            Source labware
+        wells : str or iterable
+            List of well ids
+        volumes : float or iterable
+            Volume(s) to dispense
+        label : str
+            Label of the operation to log into labware history
+        compositions : list
+            Iterable of liquid compositions
+        kwargs
+            Additional keyword arguments to pass to `dispense_well`.
+            Most prominent example: `liquid_class`.
+            Take a look at `Worklist.dispense_well` for the full list of options.
+        """
+        wells = numpy.array(wells).flatten("F")
+        volumes = numpy.array(volumes).flatten("F")
+        if len(volumes) == 1:
+            volumes = numpy.repeat(volumes, len(wells))
+        labware.add(wells, volumes, label, compositions=compositions)
+        self.comment(label)
+        for well, volume in zip(wells, volumes):
+            if volume > 0:
+                self.dispense_well(labware.name, labware.positions[well], volume, **kwargs)
+        return
+
+    def evo_dispense(
+        self,
+        labware: liquidhandling.Labware,
+        wells: typing.Union[str, typing.List[str]],
+        labware_position: typing.Tuple[int, int],
+        tips: typing.Union[typing.List[Tip], typing.List[int]],
+        volumes: typing.Union[float, typing.List[float]],
+        liquid_class: str,
+        *,
+        label: typing.Optional[str] = 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.
+
+        Parameters
+        ----------
+        labware : liquidhandling.Labware
+            Source labware
+        labware_position : tuple
+            Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2)
+        wells : list of str or iterable
+            List with target well ID(s)
+        tips : list
+            Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8
+        volumes : float or iterable
+            Volume(s) in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases
+        liquid_class : str, optional
+            Overwrites the liquid class for this step (max 32 characters)
+        """
+        # diferentiate between what is needed for volume calculation and for pipetting commands
+        wells_calc = numpy.array(wells).flatten("F")
+        volumes_calc = numpy.array(volumes).flatten("F")
+        if len(volumes_calc) == 1:
+            volumes_calc = numpy.repeat(volumes_calc, len(wells_calc))
+        labware.remove(wells_calc, volumes_calc, label)
+        self.comment(label)
+        self.evo_dispense_well(
+            labware=labware,
+            wells=wells,
+            labware_position=labware_position,
+            volume=volumes,
+            liquid_class=liquid_class,
+            tips=tips,
+        )
+        return
+
+    def transfer(
+        self,
+        source: liquidhandling.Labware,
+        source_wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
+        destination: liquidhandling.Labware,
+        destination_wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
+        volumes: typing.Union[float, typing.Sequence[float], numpy.ndarray],
+        *,
+        label: typing.Optional[str] = None,
+        wash_scheme: int = 1,
+        partition_by: str = "auto",
+        **kwargs,
+    ) -> None:
+        """Transfer operation between two labwares.
+
+        Parameters
+        ----------
+        source : liquidhandling.Labware
+            Source labware
+        source_wells : str or iterable
+            List of source well ids
+        destination : liquidhandling.Labware
+            Destination labware
+        destination_wells : str or iterable
+            List of destination well ids
+        volumes : float or iterable
+            Volume(s) to transfer
+        label : str
+            Label of the operation to log into labware history
+        wash_scheme : int
+            Wash scheme to apply after every tip use
+        partition_by : str
+            one of 'auto' (default), 'source' or 'destination'
+                'auto': partitioning by source unless the source is a Trough
+                'source': partitioning by source columns
+                'destination': partitioning by destination columns
+        kwargs
+            Additional keyword arguments to pass to aspirate and dispense.
+            Most prominent example: `liquid_class`.
+            Take a look at `Worklist.aspirate_well` for the full list of options.
+        """
+        # reformat the convenience parameters
+        source_wells = numpy.array(source_wells).flatten("F")
+        destination_wells = numpy.array(destination_wells).flatten("F")
+        volumes = numpy.array(volumes).flatten("F")
+        nmax = max((len(source_wells), len(destination_wells), len(volumes)))
+
+        if len(source_wells) == 1:
+            source_wells = numpy.repeat(source_wells, nmax)
+        if len(destination_wells) == 1:
+            destination_wells = numpy.repeat(destination_wells, nmax)
+        if len(volumes) == 1:
+            volumes = numpy.repeat(volumes, nmax)
+        lengths = (len(source_wells), len(destination_wells), len(volumes))
+        assert (
+            len(set(lengths)) == 1
+        ), f"Number of source/destination/volumes must be equal. They were {lengths}"
+
+        # automatic partitioning
+        partition_by = _optimize_partition_by(source, destination, partition_by, label)
+
+        # the label applies to the entire transfer operation and is not logged at individual aspirate/dispense steps
+        self.comment(label)
+        nsteps = 0
+        lvh_extra = 0
+
+        for srcs, dsts, vols in _partition_by_column(source_wells, destination_wells, volumes, partition_by):
+            # make vector of volumes into vector of volume-lists
+            vols = [
+                _partition_volume(float(v), max_volume=self.max_volume) if self.auto_split else [v]
+                for v in vols
+            ]
+            # transfer from this source column until all wells are done
+            npartitions = max(map(len, vols))
+            # Count only the extra steps created by LVH
+            lvh_extra += sum([len(vs) - 1 for vs in vols])
+            for p in range(npartitions):
+                naccessed = 0
+                # iterate the rows
+                for s, d, vs in zip(srcs, dsts, vols):
+                    # transfer the next volume-fraction for this well
+                    if len(vs) > p:
+                        v = vs[p]
+                        if v > 0:
+                            self.aspirate(source, s, v, label=None, **kwargs)
+                            self.dispense(
+                                destination,
+                                d,
+                                v,
+                                label=None,
+                                compositions=[source.get_well_composition(s)],
+                                **kwargs,
+                            )
+                            nsteps += 1
+                            if wash_scheme is not None:
+                                self.wash(scheme=wash_scheme)
+                            naccessed += 1
+                # LVH: if multiple wells are accessed, don't group across partitions
+                if npartitions > 1 and naccessed > 1 and not p == npartitions - 1:
+                    self.commit()
+            # LVH: don't group across columns
+            if npartitions > 1:
+                self.commit()
+
+        # Condense the labware logs into one operation
+        # after the transfer operation completed to facilitate debugging.
+        # Also include the number of extra steps because of LVH if applicable.
+        if lvh_extra:
+            if label:
+                label = f"{label} ({lvh_extra} LVH steps)"
+            else:
+                label = f"{lvh_extra} LVH steps"
+        if destination == source:
+            source.condense_log(nsteps * 2, label=label)
+        else:
+            source.condense_log(nsteps, label=label)
+            destination.condense_log(nsteps, label=label)
+        return
+
+    def distribute(
+        self,
+        source: liquidhandling.Labware,
+        source_column: int,
+        destination: liquidhandling.Labware,
+        destination_wells: typing.Union[str, typing.Sequence[str], numpy.ndarray],
+        *,
+        volume: float,
+        diti_reuse: int = 1,
+        multi_disp: int = 1,
+        liquid_class: str = "",
+        label: str = "",
+        direction: str = "left_to_right",
+        src_rack_id: str = "",
+        src_rack_type: str = "",
+        dst_rack_id: str = "",
+        dst_rack_type: str = "",
+    ) -> None:
+        """Transfers from a Trough into many destination wells using multi-pipetting.
+
+        Does NOT support large volume operations.
+
+        Parameters
+        ----------
+        source : liquidhandling.Labware
+            Source labware with virtual_rows (a Trough)
+        source_column : int
+            0-based column number of the reagent in the source labware
+        destination : liquidhandling.Labware
+            Destination labware
+        destination_wells : array-like
+            List or array of destination wells
+        volume : float
+            Microliters to dispense into each destination
+        multi_disp : int
+            Maximum number of allowed multi-dispenses
+        liquid_class : str
+            Liquid class to use for the operation
+        label : str
+            Label of the operation
+        diti_reuse : int
+            Number of allowed re-uses for disposable tips
+        direction : str
+            Moving direction on the destination ('left_to_right' or 'right_to_left')
+        src_rack_id : str, optional
+            Barcode of the source labware
+        src_rack_type : str, optional
+            Configuration name of the source labware
+        dst_rack_id : str, optional
+            Barcode of the destination labware
+        dst_rack_type : str
+            Configuration name of the destination labware
+        """
+        if source.virtual_rows is None:
+            raise ValueError(
+                f'Reagent distribution only works with Trough sources. "{source.name}" is not a Trough.'
+            )
+
+        if volume > self.max_volume:
+            raise InvalidOperationError(
+                f"Reagent distribution only works with volumes smaller than the diluter volume ({self.max_volume} µl)"
+            )
+
+        # always use the entire first column of the source
+        src_start = 1 + source.n_rows * source_column
+        src_end = src_start + source.n_rows - 1
+
+        # transform destination wells into range + mask
+        destination_wells = numpy.array(destination_wells).flatten("F")
+        dst_wells = list(sorted([destination.positions[w] for w in destination_wells]))
+        dst_start, dst_end = dst_wells[0], dst_wells[-1]
+        excluded_dst_wells = set(range(dst_start, dst_end + 1)).difference(dst_wells)
+
+        # hand over to low-level command implementation
+        self.comment(label)
+        self.reagent_distribution(
+            source.name,
+            src_start,
+            src_end,
+            destination.name,
+            dst_start,
+            dst_end,
+            volume=volume,
+            diti_reuse=diti_reuse,
+            multi_disp=multi_disp,
+            exclude_wells=excluded_dst_wells,
+            liquid_class=liquid_class,
+            direction=direction,
+            src_rack_id=src_rack_id,
+            src_rack_type=src_rack_type,
+            dst_rack_id=dst_rack_id,
+            dst_rack_type=dst_rack_type,
+        )
+
+        # update volume tracking
+        n_dst = len(dst_wells)
+        source.remove(source.wells[0, source_column], volume * n_dst, label=label)
+        src_composition = source.get_well_composition(source.wells[0, source_column])
+        destination.add(destination_wells, volume, label=label, compositions=[src_composition] * n_dst)
+        return
+
+    def __repr__(self) -> str:
+        return "\n".join(self)
+
+    def __str__(self) -> str:
+        return self.__repr__()
diff --git a/robotools/liquidhandling/__init__.py b/robotools/liquidhandling/__init__.py
index 538f044..f29488f 100644
--- a/robotools/liquidhandling/__init__.py
+++ b/robotools/liquidhandling/__init__.py
@@ -1,586 +1,5 @@
-import logging
-import typing
-import warnings
-
-import numpy
-
-logger = logging.getLogger("liquidhandling")
-
-
-class VolumeOverflowError(Exception):
-    """Error that indicates the planned overflow of a well."""
-
-    def __init__(
-        self,
-        labware: str,
-        well: str,
-        current: float,
-        change: float,
-        threshold: float,
-        label: typing.Optional[str] = None,
-    ) -> None:
-        if label:
-            super().__init__(
-                f'Too much volume for "{labware}".{well}: {current} + {change} > {threshold} in step {label}'
-            )
-        else:
-            super().__init__(f'Too much volume for "{labware}".{well}: {current} + {change} > {threshold}')
-
-
-class VolumeUnderflowError(Exception):
-    """Error that indicates the planned underflow of a well."""
-
-    def __init__(
-        self,
-        labware: str,
-        well: str,
-        current: float,
-        change: float,
-        threshold: float,
-        label: typing.Optional[str] = None,
-    ) -> None:
-        if label:
-            super().__init__(
-                f'Too little volume in "{labware}".{well}: {current} - {change} < {threshold} in step {label}'
-            )
-        else:
-            super().__init__(f'Too little volume in "{labware}".{well}: {current} - {change} < {threshold}')
-
-
-def _combine_composition(
-    volume_A: float,
-    composition_A: typing.Optional[typing.Dict[str, float]],
-    volume_B: float,
-    composition_B: typing.Optional[typing.Dict[str, float]],
-) -> typing.Optional[typing.Dict[str, float]]:
-    """Computes the composition of a liquid, created by the mixing of two liquids (A and B).
-
-    Parameters
-    ----------
-    volume_A : float
-        Volume of liquid A
-    composition_A : dict
-        Relative composition of liquid A
-    volume_B : float
-        Volume of liquid B
-    composition_B : dict
-        Relative composition of liquid B
-
-    Returns
-    -------
-    composition : dict
-        Composition of the new liquid created by mixing the given volumes of A and B
-    """
-    if composition_A is None or composition_B is None:
-        return None
-    # convert to volumetric fractions
-    volumetric_fractions = {k: f * volume_A for k, f in composition_A.items()}
-    # volumetrically add incoming fractions
-    for k, f in composition_B.items():
-        if not k in volumetric_fractions:
-            volumetric_fractions[k] = 0
-        volumetric_fractions[k] += f * volume_B
-    # convert back to relative fractions
-    new_composition = {k: v / (volume_A + volume_B) for k, v in volumetric_fractions.items()}
-    return new_composition
-
-
-def _get_initial_composition(
-    name: str,
-    real_wells: numpy.ndarray,
-    component_names: typing.Dict[str, typing.Union[str, None]],
-    initial_volumes: numpy.ndarray,
-) -> typing.Dict[str, typing.Union[str, None]]:
-    """Creates a dictionary of initial composition arrays.
-
-    Parameters
-    ----------
-    name : str
-        Name of the labware - used for default component names.
-    real_wells : array-like
-        2D array of non-virtual wells in the labware.
-    component_names : dict
-        User-provided dictionary that maps real well IDs to component names.
-    initial_volumes : numpy.ndarray
-        Initial volumes of real wells.
-
-    Returns
-    -------
-    composition : dict
-        The component-wise dictionary of numpy arrays that describe the composition of real wells.
-    """
-    possible_component_wells = set(numpy.unique(real_wells))
-    illegal_component_wells = set(component_names.keys()) - possible_component_wells
-    if illegal_component_wells:
-        raise ValueError(f"Invalid component name keys: {illegal_component_wells}")
-
-    is_multiwell = len(real_wells) > 1
-    composition = {}
-    for idx, w in numpy.ndenumerate(real_wells):
-        # Ignore None-valued component names, but don't allow naming of empty wells.
-        if initial_volumes[idx] == 0:
-            if component_names.get(w, None) is not None:
-                raise ValueError(
-                    f"A component name '{component_names[w]}' was specified for {name}.{w}, but the corresponding initial volume is 0."
-                )
-            continue
-
-        # Fetch a name for identifying the liquid from this non-empty well
-        default_name = f"{name}.{w}" if is_multiwell else name
-        cname = component_names.get(w, default_name)
-
-        # Make sure that a composition array exists
-        if cname not in composition:
-            composition[cname] = numpy.zeros_like(real_wells, dtype=float)
-
-        # Mark this well as filled by this component
-        composition[cname][idx] = 1
-    return composition
-
-
-class Labware:
-    """Represents an array of liquid cavities."""
-
-    @property
-    def history(self) -> typing.List[typing.Tuple[typing.Optional[str], numpy.ndarray]]:
-        """List of label/volumes history."""
-        return list(zip(self._labels, self._history))
-
-    @property
-    def report(self) -> str:
-        """A printable report of the labware history."""
-        report = self.name
-        for label, state in self.history:
-            if label:
-                report += f"\n{label}"
-            report += f"\n{numpy.round(state, decimals=1)}"
-            report += "\n"
-        return report
-
-    @property
-    def volumes(self) -> numpy.ndarray:
-        """Current volumes in the labware."""
-        return self._volumes.copy()
-
-    @property
-    def wells(self) -> numpy.ndarray:
-        """Array of well ids."""
-        return self._wells
-
-    @property
-    def indices(self) -> typing.Dict[str, typing.Tuple[int, int]]:
-        """Mapping of well-ids to numpy indices."""
-        return self._indices
-
-    @property
-    def positions(self) -> typing.Dict[str, int]:
-        """Mapping of well-ids to EVOware-compatible position numbers."""
-        return self._positions
-
-    @property
-    def n_rows(self) -> int:
-        return len(self.row_ids)
-
-    @property
-    def n_columns(self) -> int:
-        return len(self.column_ids)
-
-    @property
-    def is_trough(self) -> bool:
-        return self.virtual_rows != None
-
-    @property
-    def composition(self) -> typing.Dict[str, numpy.ndarray]:
-        """Relative composition of the liquids.
-
-        This dictionary maps liquid names (keys) to arrays of relative amounts in each well.
-        """
-        return self._composition
-
-    def __init__(
-        self,
-        name: str,
-        rows: int,
-        columns: int,
-        *,
-        min_volume: float,
-        max_volume: float,
-        initial_volumes: typing.Optional[typing.Union[float, numpy.ndarray]] = None,
-        virtual_rows: typing.Optional[int] = None,
-        component_names: typing.Optional[typing.Dict[str, str]] = None,
-    ) -> None:
-        """Creates a `Labware` object.
-
-        Parameters
-        ----------
-        name : str
-            Label that the labware is identified by.
-        rows : int
-            Number of rows in the labware
-        columns : int
-            Number of columns in the labware
-        min_volume : float
-            Filling volume that must remain after an aspirate operation.
-        max_volume : float
-            Maximum volume that must not be exceeded after a dispense.
-        initial_volumes : float, array-like, optional
-            Initial filling volume of the wells (default: 0)
-        virtual_rows : int, optional
-            When specified to a positive number, the `Labware` is treated as a trough.
-            Must be used in combination with `rows=1`.
-            For example: A `Labware` with virtual rows can be accessed with 6 Tips,
-            but has just one row in the `volumes` array.
-        component_names : dict, optional
-            A dictionary that names the content of non-empty real wells for composition tracking.
-        """
-        # sanity checking
-        if not isinstance(rows, int) or rows < 1:
-            raise ValueError(f"Invalid rows: {rows}")
-        if not isinstance(columns, int) or columns < 1:
-            raise ValueError(f"Invalid columns: {columns}")
-        if min_volume is None or min_volume < 0:
-            raise ValueError(f"Invalid min_volume: {min_volume}")
-        if max_volume is None or max_volume <= min_volume:
-            raise ValueError(f"Invalid max_volume: {max_volume}")
-        if virtual_rows is not None and rows != 1:
-            raise ValueError("When using virtual_rows, the number of rows must be == 1")
-        if virtual_rows is not None and virtual_rows < 1:
-            raise ValueError(f"Invalid virtual_rows: {virtual_rows}")
-        if virtual_rows and not isinstance(self, Trough):
-            warnings.warn(
-                "Troughs should be created with the robotools.Trough class.",
-                UserWarning,
-                stacklevel=2,
-            )
-
-        # explode convenience parameters
-        if initial_volumes is None:
-            initial_volumes = 0
-        initial_volumes = numpy.array(initial_volumes)
-        if initial_volumes.shape == ():
-            initial_volumes = numpy.full((rows, columns), initial_volumes)
-        else:
-            initial_volumes = initial_volumes.reshape((rows, columns))
-        assert initial_volumes.shape == (
-            rows,
-            columns,
-        ), f"Invalid shape of initial_volumes: {initial_volumes.shape}"
-        if numpy.any(initial_volumes < 0):
-            raise ValueError("initial_volume cannot be negative")
-        if numpy.any(initial_volumes > max_volume):
-            raise ValueError("initial_volume cannot be above max_volume")
-
-        # initialize properties
-        self.name = name
-        self.row_ids = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ"[: rows if not virtual_rows else virtual_rows])
-        self.column_ids = list(range(1, columns + 1))
-        self.min_volume = min_volume
-        self.max_volume = max_volume
-        self.virtual_rows = virtual_rows
-
-        # generate arrays/mappings of well ids
-        self._wells = numpy.array(
-            [[f"{row}{column:02d}" for column in self.column_ids] for row in self.row_ids]
-        )
-        if virtual_rows is None:
-            self._indices = {
-                f"{row}{column:02d}": (r, c)
-                for r, row in enumerate(self.row_ids)
-                for c, column in enumerate(self.column_ids)
-            }
-            self._positions = {
-                f"{row}{column:02d}": 1 + c * rows + r
-                for r, row in enumerate(self.row_ids)
-                for c, column in enumerate(self.column_ids)
-            }
-        else:
-            self._indices = {
-                f"{vrow}{column:02d}": (0, c)
-                for vr, vrow in enumerate(self.row_ids)
-                for c, column in enumerate(self.column_ids)
-            }
-            self._positions = {
-                f"{vrow}{column:02d}": 1 + c * virtual_rows + vr
-                for vr, vrow in enumerate(self.row_ids)
-                for c, column in enumerate(self.column_ids)
-            }
-
-        # initialize state variables
-        self._volumes = initial_volumes.copy().astype(float)
-        self._history: typing.List[numpy.ndarray] = [self.volumes]
-        self._labels: typing.List[typing.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,
-        )
-        super().__init__()
-
-    def get_well_composition(self, well: str) -> typing.Dict[str, float]:
-        """Retrieves the relative composition of a well.
-
-        Parameters
-        ----------
-        well : str
-            ID of the well for which to retrieve the composition.
-
-        Returns
-        -------
-        composition : dict
-            Keys: liquid names
-            Values: relative amount
-        """
-        if self._composition is None:
-            return None
-        idx = self.indices[well]
-        well_comp = {k: f[idx] for k, f in self.composition.items() if f[idx] > 0}
-        return well_comp
-
-    def add(
-        self,
-        wells: typing.Sequence[str],
-        volumes: typing.Union[float, typing.Sequence[float], numpy.ndarray],
-        label: typing.Optional[str] = None,
-        compositions: typing.Optional[typing.List[typing.Optional[typing.Dict[str, float]]]] = None,
-    ) -> None:
-        """Adds volumes to wells.
-
-        Parameters
-        ----------
-        wells : iterable of str
-            Well ids
-        volumes : float, iterable of float
-            Scalar or iterable of volumes
-        label : str
-            Description of the operation
-        compositions : iterable
-            List of composition dictionaries ({ name : relative amount })
-        """
-        wells = numpy.array(wells).flatten("F")
-        volumes = numpy.array(volumes).flatten("F")
-        if len(volumes) == 1:
-            volumes = numpy.repeat(volumes, len(wells))
-        assert len(volumes) == len(wells), "Number of volumes must equal the number of wells"
-        assert numpy.all(volumes >= 0), "Volumes must be positive or zero."
-        if compositions is not None:
-            assert len(compositions) == len(
-                wells
-            ), "Well compositions must be given for either all or none of the wells."
-        else:
-            compositions = [None] * len(wells)
-
-        for well, volume, composition in zip(wells, volumes, compositions):
-            idx = self.indices[well]
-            v_original = self._volumes[idx]
-            v_new = v_original + volume
-
-            if v_new > self.max_volume:
-                raise VolumeOverflowError(self.name, well, v_original, volume, self.max_volume, label)
-
-            self._volumes[idx] = v_new
-
-            if composition is not None and self._composition is not None:
-                assert isinstance(composition, dict), "Well compositions must be given as dicts"
-                # update the volumentric composition for this well
-                original_composition = self.get_well_composition(well)
-                new_composition = _combine_composition(v_original, original_composition, volume, composition)
-                for k, f in new_composition.items():
-                    if not k in self._composition:
-                        # a new liquid is being added
-                        self._composition[k] = numpy.zeros_like(self.volumes)
-                    self._composition[k][idx] = f
-
-        self.log(label)
-        return
-
-    def remove(
-        self,
-        wells: typing.Sequence[str],
-        volumes: typing.Union[float, typing.Sequence[float], numpy.ndarray],
-        label: typing.Optional[str] = None,
-    ) -> None:
-        """Removes volumes from wells.
-
-        Parameters
-        ----------
-        wells : iterable of str
-            Well ids
-        volumes : float, iterable of float
-            Scalar or iterable of volumes
-        label : str
-            Description of the operation
-        """
-        wells = numpy.array(wells).flatten("F")
-        volumes = numpy.array(volumes).flatten("F")
-        if len(volumes) == 1:
-            volumes = numpy.repeat(volumes, len(wells))
-        assert len(volumes) == len(wells), "Number of volumes must number of wells"
-        assert numpy.all(volumes >= 0), "Volumes must be positive or zero."
-        for well, volume in zip(wells, volumes):
-            idx = self.indices[well]
-            v_original = self._volumes[idx]
-            v_new = v_original - volume
-
-            if v_new < self.min_volume:
-                raise VolumeUnderflowError(self.name, well, v_original, volume, self.min_volume, label)
-
-            self._volumes[idx] -= volume
-        self.log(label)
-        return
-
-    def log(self, label: typing.Optional[str]) -> None:
-        """Logs the current volumes to the history.
-
-        Parameters
-        ----------
-        label : str
-            A label to insert in the history.
-        """
-        self._history.append(self.volumes)
-        self._labels.append(label)
-        return
-
-    def condense_log(self, n: int, label: typing.Optional[str] = "last") -> None:
-        """Condense the last n log entries.
-
-        Parameters
-        ----------
-        n : int
-            Number of log entries to condense
-        label : str
-            'first', 'last' or label of the condensed entry (default: label of the last entry in the condensate)
-        """
-        if label == "first":
-            label = self._labels[len(self._labels) - n]
-        if label == "last":
-            label = self._labels[-1]
-        state = self._history[-1]
-        # cut away the history
-        self._labels = self._labels[:-n]
-        self._history = self._history[:-n]
-        # append the last state
-        self._labels.append(label)
-        self._history.append(state)
-        return
-
-    def __repr__(self) -> None:
-        return f"{self.name}\n{numpy.round(self.volumes, decimals=1)}"
-
-    def __str__(self) -> None:
-        return self.__repr__()
-
-
-def _get_trough_component_names(
-    name: str,
-    columns: int,
-    column_names: typing.Sequence[typing.Union[str, None]],
-    initial_volumes: typing.Sequence[typing.Union[int, float]],
-) -> typing.Dict[str, typing.Union[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.
-    Instead of "stocks.A01" this function defaults to "stocks.column_01" with 1-based column numbering.
-
-    Parameters
-    ----------
-    name : str
-        Name of the trough - used for default component names.
-    columns : int
-        Number of trough columns.
-    column_names : array-like
-        Column-wise component names.
-        Must be given for all columns, but can contain None elements.
-    initial_volumes : array-like
-        Column-wise initial volumes.
-        Used to determine if a default component name is needed.
-
-    Returns
-    -------
-    component_names : dict
-        The component name dictionary that maps all row A well IDs to component names.
-    """
-    if numpy.shape(column_names) != (columns,):
-        raise ValueError(f"The column names {column_names} don't match the number of columns ({columns}).")
-    if numpy.shape(initial_volumes) != (columns,):
-        raise ValueError(
-            f"The initial volumes {initial_volumes} don't match the number of columns ({columns})."
-        )
-
-    if any([cname is not None and ivol == 0 for cname, ivol in zip(column_names, initial_volumes)]):
-        raise ValueError(
-            f"Empty columns must be unnamed."
-            f"\n\tcolumn_names: {column_names}"
-            f"\n\tinitial_volumes: {initial_volumes}"
-        )
-
-    component_names = {}
-    for c, (cname, ivol) in enumerate(zip(column_names, initial_volumes)):
-        if ivol > 0 and cname is None:
-            # Determine default name
-            if columns > 1:
-                cname = f"{name}.column_{c+1:02d}"
-            else:
-                cname = name
-        component_names[f"A{c+1:02d}"] = cname
-    return component_names
-
-
-class Trough(Labware):
-    """Special type of labware that can be accessed by many pipette tips in parallel."""
-
-    def __init__(
-        self,
-        name: str,
-        virtual_rows: int,
-        columns: int,
-        *,
-        min_volume: float,
-        max_volume: float,
-        initial_volumes: typing.Union[float, numpy.ndarray] = 0,
-        column_names: typing.Optional[typing.Sequence[typing.Union[str, None]]] = None,
-    ) -> None:
-        """Creates a `Labware` object.
-
-        Parameters
-        ----------
-        name : str
-            Label that the labware is identified by.
-        virtual_rows : int, optional
-            Number of tips that may access the trough in parallel.
-            For example: A `Labware` with virtual rows can be accessed with 6 Tips,
-            but has just one row in the `volumes` array.
-        columns : int
-            Number of columns in the labware
-        min_volume : float
-            Filling volume that must remain after an aspirate operation.
-        max_volume : float
-            Maximum volume that must not be exceeded after a dispense.
-        initial_volumes : float, array-like, optional
-            Initial filling volume of the wells (default: 0)
-        column_names : array-like, optional
-            A list/tuple of names for the column-wise contents of the troughs.
-            If provided, these names are used for composition tracking.
-        """
-        # Convert lazily scalar-valued parameters to lists
-        if column_names is None:
-            column_names = [None] * columns
-        if isinstance(column_names, str):
-            column_names = [column_names]
-
-        if isinstance(initial_volumes, (int, float)):
-            initial_volumes = [initial_volumes] * columns
-
-        # Determine component names with a different default pattern compared to Labware
-        component_names = _get_trough_component_names(name, columns, column_names, initial_volumes)
-
-        super().__init__(
-            name=name,
-            rows=1,
-            columns=columns,
-            min_volume=min_volume,
-            max_volume=max_volume,
-            initial_volumes=initial_volumes,
-            virtual_rows=virtual_rows,
-            component_names=component_names,
-        )
+from robotools.liquidhandling.exceptions import (
+    VolumeOverflowError,
+    VolumeUnderflowError,
+)
+from robotools.liquidhandling.labware import Labware, Trough
diff --git a/robotools/liquidhandling/composition.py b/robotools/liquidhandling/composition.py
new file mode 100644
index 0000000..b20e1c8
--- /dev/null
+++ b/robotools/liquidhandling/composition.py
@@ -0,0 +1,151 @@
+"""Functions for tracking fluid composition through liquid handling operations."""
+
+from typing import Dict, Optional, Sequence, Union
+
+import numpy as np
+
+
+def combine_composition(
+    volume_A: float,
+    composition_A: Optional[Dict[str, float]],
+    volume_B: float,
+    composition_B: Optional[Dict[str, float]],
+) -> Optional[Dict[str, float]]:
+    """Computes the composition of a liquid, created by the mixing of two liquids (A and B).
+
+    Parameters
+    ----------
+    volume_A : float
+        Volume of liquid A
+    composition_A : dict
+        Relative composition of liquid A
+    volume_B : float
+        Volume of liquid B
+    composition_B : dict
+        Relative composition of liquid B
+
+    Returns
+    -------
+    composition : dict
+        Composition of the new liquid created by mixing the given volumes of A and B
+    """
+    if composition_A is None or composition_B is None:
+        return None
+    # convert to volumetric fractions
+    volumetric_fractions = {k: f * volume_A for k, f in composition_A.items()}
+    # volumetrically add incoming fractions
+    for k, f in composition_B.items():
+        if not k in volumetric_fractions:
+            volumetric_fractions[k] = 0
+        volumetric_fractions[k] += f * volume_B
+    # convert back to relative fractions
+    new_composition = {k: v / (volume_A + volume_B) for k, v in volumetric_fractions.items()}
+    return new_composition
+
+
+def get_initial_composition(
+    name: str,
+    real_wells: np.ndarray,
+    component_names: Dict[str, Union[str, None]],
+    initial_volumes: np.ndarray,
+) -> Dict[str, Union[str, None]]:
+    """Creates a dictionary of initial composition arrays.
+
+    Parameters
+    ----------
+    name : str
+        Name of the labware - used for default component names.
+    real_wells : array-like
+        2D array of non-virtual wells in the labware.
+    component_names : dict
+        User-provided dictionary that maps real well IDs to component names.
+    initial_volumes : np.ndarray
+        Initial volumes of real wells.
+
+    Returns
+    -------
+    composition : dict
+        The component-wise dictionary of numpy arrays that describe the composition of real wells.
+    """
+    possible_component_wells = set(np.unique(real_wells))
+    illegal_component_wells = set(component_names.keys()) - possible_component_wells
+    if illegal_component_wells:
+        raise ValueError(f"Invalid component name keys: {illegal_component_wells}")
+
+    is_multiwell = len(real_wells) > 1
+    composition = {}
+    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:
+            if component_names.get(w, None) is not None:
+                raise ValueError(
+                    f"A component name '{component_names[w]}' was specified for {name}.{w}, but the corresponding initial volume is 0."
+                )
+            continue
+
+        # Fetch a name for identifying the liquid from this non-empty well
+        default_name = f"{name}.{w}" if is_multiwell else name
+        cname = component_names.get(w, default_name)
+
+        # Make sure that a composition array exists
+        if cname not in composition:
+            composition[cname] = np.zeros_like(real_wells, dtype=float)
+
+        # Mark this well as filled by this component
+        composition[cname][idx] = 1
+    return composition
+
+
+def get_trough_component_names(
+    name: str,
+    columns: int,
+    column_names: Sequence[Union[str, None]],
+    initial_volumes: Sequence[Union[int, float]],
+) -> Dict[str, Union[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.
+    Instead of "stocks.A01" this function defaults to "stocks.column_01" with 1-based column numbering.
+
+    Parameters
+    ----------
+    name : str
+        Name of the trough - used for default component names.
+    columns : int
+        Number of trough columns.
+    column_names : array-like
+        Column-wise component names.
+        Must be given for all columns, but can contain None elements.
+    initial_volumes : array-like
+        Column-wise initial volumes.
+        Used to determine if a default component name is needed.
+
+    Returns
+    -------
+    component_names : dict
+        The component name dictionary that maps all row A well IDs to component names.
+    """
+    if np.shape(column_names) != (columns,):
+        raise ValueError(f"The column names {column_names} don't match the number of columns ({columns}).")
+    if np.shape(initial_volumes) != (columns,):
+        raise ValueError(
+            f"The initial volumes {initial_volumes} don't match the number of columns ({columns})."
+        )
+
+    if any([cname is not None and ivol == 0 for cname, ivol in zip(column_names, initial_volumes)]):
+        raise ValueError(
+            f"Empty columns must be unnamed."
+            f"\n\tcolumn_names: {column_names}"
+            f"\n\tinitial_volumes: {initial_volumes}"
+        )
+
+    component_names = {}
+    for c, (cname, ivol) in enumerate(zip(column_names, initial_volumes)):
+        if ivol > 0 and cname is None:
+            # Determine default name
+            if columns > 1:
+                cname = f"{name}.column_{c+1:02d}"
+            else:
+                cname = name
+        component_names[f"A{c+1:02d}"] = cname
+    return component_names
diff --git a/robotools/liquidhandling/exceptions.py b/robotools/liquidhandling/exceptions.py
new file mode 100644
index 0000000..44730f3
--- /dev/null
+++ b/robotools/liquidhandling/exceptions.py
@@ -0,0 +1,48 @@
+"""Exceptions that indicate problems in liquid handling."""
+
+from typing import Optional
+
+__all__ = (
+    "VolumeOverflowError",
+    "VolumeUnderflowError",
+)
+
+
+class VolumeOverflowError(Exception):
+    """Error that indicates the planned overflow of a well."""
+
+    def __init__(
+        self,
+        labware: str,
+        well: str,
+        current: float,
+        change: float,
+        threshold: float,
+        label: Optional[str] = None,
+    ) -> None:
+        if label:
+            super().__init__(
+                f'Too much volume for "{labware}".{well}: {current} + {change} > {threshold} in step {label}'
+            )
+        else:
+            super().__init__(f'Too much volume for "{labware}".{well}: {current} + {change} > {threshold}')
+
+
+class VolumeUnderflowError(Exception):
+    """Error that indicates the planned underflow of a well."""
+
+    def __init__(
+        self,
+        labware: str,
+        well: str,
+        current: float,
+        change: float,
+        threshold: float,
+        label: Optional[str] = None,
+    ) -> None:
+        if label:
+            super().__init__(
+                f'Too little volume in "{labware}".{well}: {current} - {change} < {threshold} in step {label}'
+            )
+        else:
+            super().__init__(f'Too little volume in "{labware}".{well}: {current} - {change} < {threshold}')
diff --git a/robotools/liquidhandling/labware.py b/robotools/liquidhandling/labware.py
new file mode 100644
index 0000000..4be6e44
--- /dev/null
+++ b/robotools/liquidhandling/labware.py
@@ -0,0 +1,408 @@
+"""Object-oriented, stateful labware representations."""
+
+
+import warnings
+from typing import Dict, List, Optional, Sequence, Tuple, Union
+
+import numpy as np
+
+from robotools.liquidhandling.composition import (
+    combine_composition,
+    get_initial_composition,
+    get_trough_component_names,
+)
+from robotools.liquidhandling.exceptions import (
+    VolumeOverflowError,
+    VolumeUnderflowError,
+)
+
+
+class Labware:
+    """Represents an array of liquid cavities."""
+
+    @property
+    def history(self) -> List[Tuple[Optional[str], np.ndarray]]:
+        """List of label/volumes history."""
+        return list(zip(self._labels, self._history))
+
+    @property
+    def report(self) -> str:
+        """A printable report of the labware history."""
+        report = self.name
+        for label, state in self.history:
+            if label:
+                report += f"\n{label}"
+            report += f"\n{np.round(state, decimals=1)}"
+            report += "\n"
+        return report
+
+    @property
+    def volumes(self) -> np.ndarray:
+        """Current volumes in the labware."""
+        return self._volumes.copy()
+
+    @property
+    def wells(self) -> np.ndarray:
+        """Array of well ids."""
+        return self._wells
+
+    @property
+    def indices(self) -> Dict[str, Tuple[int, int]]:
+        """Mapping of well-ids to numpy indices."""
+        return self._indices
+
+    @property
+    def positions(self) -> Dict[str, int]:
+        """Mapping of well-ids to EVOware-compatible position numbers."""
+        return self._positions
+
+    @property
+    def n_rows(self) -> int:
+        return len(self.row_ids)
+
+    @property
+    def n_columns(self) -> int:
+        return len(self.column_ids)
+
+    @property
+    def is_trough(self) -> bool:
+        return self.virtual_rows != None
+
+    @property
+    def composition(self) -> Dict[str, np.ndarray]:
+        """Relative composition of the liquids.
+
+        This dictionary maps liquid names (keys) to arrays of relative amounts in each well.
+        """
+        return self._composition
+
+    def __init__(
+        self,
+        name: str,
+        rows: int,
+        columns: int,
+        *,
+        min_volume: float,
+        max_volume: float,
+        initial_volumes: Optional[Union[float, np.ndarray]] = None,
+        virtual_rows: Optional[int] = None,
+        component_names: Optional[Dict[str, str]] = None,
+    ) -> None:
+        """Creates a `Labware` object.
+
+        Parameters
+        ----------
+        name : str
+            Label that the labware is identified by.
+        rows : int
+            Number of rows in the labware
+        columns : int
+            Number of columns in the labware
+        min_volume : float
+            Filling volume that must remain after an aspirate operation.
+        max_volume : float
+            Maximum volume that must not be exceeded after a dispense.
+        initial_volumes : float, array-like, optional
+            Initial filling volume of the wells (default: 0)
+        virtual_rows : int, optional
+            When specified to a positive number, the `Labware` is treated as a trough.
+            Must be used in combination with `rows=1`.
+            For example: A `Labware` with virtual rows can be accessed with 6 Tips,
+            but has just one row in the `volumes` array.
+        component_names : dict, optional
+            A dictionary that names the content of non-empty real wells for composition tracking.
+        """
+        # sanity checking
+        if not isinstance(rows, int) or rows < 1:
+            raise ValueError(f"Invalid rows: {rows}")
+        if not isinstance(columns, int) or columns < 1:
+            raise ValueError(f"Invalid columns: {columns}")
+        if min_volume is None or min_volume < 0:
+            raise ValueError(f"Invalid min_volume: {min_volume}")
+        if max_volume is None or max_volume <= min_volume:
+            raise ValueError(f"Invalid max_volume: {max_volume}")
+        if virtual_rows is not None and rows != 1:
+            raise ValueError("When using virtual_rows, the number of rows must be == 1")
+        if virtual_rows is not None and virtual_rows < 1:
+            raise ValueError(f"Invalid virtual_rows: {virtual_rows}")
+        if virtual_rows and not isinstance(self, Trough):
+            warnings.warn(
+                "Troughs should be created with the robotools.Trough class.",
+                UserWarning,
+                stacklevel=2,
+            )
+
+        # 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)
+        else:
+            initial_volumes = initial_volumes.reshape((rows, columns))
+        assert initial_volumes.shape == (
+            rows,
+            columns,
+        ), f"Invalid shape of initial_volumes: {initial_volumes.shape}"
+        if np.any(initial_volumes < 0):
+            raise ValueError("initial_volume cannot be negative")
+        if np.any(initial_volumes > max_volume):
+            raise ValueError("initial_volume cannot be above max_volume")
+
+        # initialize properties
+        self.name = name
+        self.row_ids = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ"[: rows if not virtual_rows else virtual_rows])
+        self.column_ids = list(range(1, columns + 1))
+        self.min_volume = min_volume
+        self.max_volume = max_volume
+        self.virtual_rows = virtual_rows
+
+        # generate arrays/mappings of well ids
+        self._wells = np.array([[f"{row}{column:02d}" for column in self.column_ids] for row in self.row_ids])
+        if virtual_rows is None:
+            self._indices = {
+                f"{row}{column:02d}": (r, c)
+                for r, row in enumerate(self.row_ids)
+                for c, column in enumerate(self.column_ids)
+            }
+            self._positions = {
+                f"{row}{column:02d}": 1 + c * rows + r
+                for r, row in enumerate(self.row_ids)
+                for c, column in enumerate(self.column_ids)
+            }
+        else:
+            self._indices = {
+                f"{vrow}{column:02d}": (0, c)
+                for vr, vrow in enumerate(self.row_ids)
+                for c, column in enumerate(self.column_ids)
+            }
+            self._positions = {
+                f"{vrow}{column:02d}": 1 + c * virtual_rows + vr
+                for vr, vrow in enumerate(self.row_ids)
+                for c, column in enumerate(self.column_ids)
+            }
+
+        # initialize state variables
+        self._volumes = initial_volumes.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,
+        )
+        super().__init__()
+
+    def get_well_composition(self, well: str) -> Dict[str, float]:
+        """Retrieves the relative composition of a well.
+
+        Parameters
+        ----------
+        well : str
+            ID of the well for which to retrieve the composition.
+
+        Returns
+        -------
+        composition : dict
+            Keys: liquid names
+            Values: relative amount
+        """
+        if self._composition is None:
+            return None
+        idx = self.indices[well]
+        well_comp = {k: f[idx] for k, f in self.composition.items() if f[idx] > 0}
+        return well_comp
+
+    def add(
+        self,
+        wells: Sequence[str],
+        volumes: Union[float, Sequence[float], np.ndarray],
+        label: Optional[str] = None,
+        compositions: Optional[List[Optional[Dict[str, float]]]] = None,
+    ) -> None:
+        """Adds volumes to wells.
+
+        Parameters
+        ----------
+        wells : iterable of str
+            Well ids
+        volumes : float, iterable of float
+            Scalar or iterable of volumes
+        label : str
+            Description of the operation
+        compositions : iterable
+            List of composition dictionaries ({ name : relative amount })
+        """
+        wells = np.array(wells).flatten("F")
+        volumes = np.array(volumes).flatten("F")
+        if len(volumes) == 1:
+            volumes = np.repeat(volumes, len(wells))
+        assert len(volumes) == len(wells), "Number of volumes must equal the number of wells"
+        assert np.all(volumes >= 0), "Volumes must be positive or zero."
+        if compositions is not None:
+            assert len(compositions) == len(
+                wells
+            ), "Well compositions must be given for either all or none of the wells."
+        else:
+            compositions = [None] * len(wells)
+
+        for well, volume, composition in zip(wells, volumes, compositions):
+            idx = self.indices[well]
+            v_original = self._volumes[idx]
+            v_new = v_original + volume
+
+            if v_new > self.max_volume:
+                raise VolumeOverflowError(self.name, well, v_original, volume, self.max_volume, label)
+
+            self._volumes[idx] = v_new
+
+            if composition is not None and self._composition is not None:
+                assert isinstance(composition, dict), "Well compositions must be given as dicts"
+                # update the volumentric composition for this well
+                original_composition = self.get_well_composition(well)
+                new_composition = combine_composition(v_original, original_composition, volume, composition)
+                for k, f in new_composition.items():
+                    if not k in self._composition:
+                        # a new liquid is being added
+                        self._composition[k] = np.zeros_like(self.volumes)
+                    self._composition[k][idx] = f
+
+        self.log(label)
+        return
+
+    def remove(
+        self,
+        wells: Sequence[str],
+        volumes: Union[float, Sequence[float], np.ndarray],
+        label: Optional[str] = None,
+    ) -> None:
+        """Removes volumes from wells.
+
+        Parameters
+        ----------
+        wells : iterable of str
+            Well ids
+        volumes : float, iterable of float
+            Scalar or iterable of volumes
+        label : str
+            Description of the operation
+        """
+        wells = np.array(wells).flatten("F")
+        volumes = np.array(volumes).flatten("F")
+        if len(volumes) == 1:
+            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."
+        for well, volume in zip(wells, volumes):
+            idx = self.indices[well]
+            v_original = self._volumes[idx]
+            v_new = v_original - volume
+
+            if v_new < self.min_volume:
+                raise VolumeUnderflowError(self.name, well, v_original, volume, self.min_volume, label)
+
+            self._volumes[idx] -= volume
+        self.log(label)
+        return
+
+    def log(self, label: Optional[str]) -> None:
+        """Logs the current volumes to the history.
+
+        Parameters
+        ----------
+        label : str
+            A label to insert in the history.
+        """
+        self._history.append(self.volumes)
+        self._labels.append(label)
+        return
+
+    def condense_log(self, n: int, label: Optional[str] = "last") -> None:
+        """Condense the last n log entries.
+
+        Parameters
+        ----------
+        n : int
+            Number of log entries to condense
+        label : str
+            'first', 'last' or label of the condensed entry (default: label of the last entry in the condensate)
+        """
+        if label == "first":
+            label = self._labels[len(self._labels) - n]
+        if label == "last":
+            label = self._labels[-1]
+        state = self._history[-1]
+        # cut away the history
+        self._labels = self._labels[:-n]
+        self._history = self._history[:-n]
+        # append the last state
+        self._labels.append(label)
+        self._history.append(state)
+        return
+
+    def __repr__(self) -> None:
+        return f"{self.name}\n{np.round(self.volumes, decimals=1)}"
+
+    def __str__(self) -> None:
+        return self.__repr__()
+
+
+class Trough(Labware):
+    """Special type of labware that can be accessed by many pipette tips in parallel."""
+
+    def __init__(
+        self,
+        name: str,
+        virtual_rows: int,
+        columns: int,
+        *,
+        min_volume: float,
+        max_volume: float,
+        initial_volumes: Union[float, np.ndarray] = 0,
+        column_names: Optional[Sequence[Union[str, None]]] = None,
+    ) -> None:
+        """Creates a `Labware` object.
+
+        Parameters
+        ----------
+        name : str
+            Label that the labware is identified by.
+        virtual_rows : int, optional
+            Number of tips that may access the trough in parallel.
+            For example: A `Labware` with virtual rows can be accessed with 6 Tips,
+            but has just one row in the `volumes` array.
+        columns : int
+            Number of columns in the labware
+        min_volume : float
+            Filling volume that must remain after an aspirate operation.
+        max_volume : float
+            Maximum volume that must not be exceeded after a dispense.
+        initial_volumes : float, array-like, optional
+            Initial filling volume of the wells (default: 0)
+        column_names : array-like, optional
+            A list/tuple of names for the column-wise contents of the troughs.
+            If provided, these names are used for composition tracking.
+        """
+        # Convert lazily scalar-valued parameters to lists
+        if column_names is None:
+            column_names = [None] * columns
+        if isinstance(column_names, str):
+            column_names = [column_names]
+
+        if isinstance(initial_volumes, (int, float)):
+            initial_volumes = [initial_volumes] * columns
+
+        # Determine component names with a different default pattern compared to Labware
+        component_names = get_trough_component_names(name, columns, column_names, initial_volumes)
+
+        super().__init__(
+            name=name,
+            rows=1,
+            columns=columns,
+            min_volume=min_volume,
+            max_volume=max_volume,
+            initial_volumes=initial_volumes,
+            virtual_rows=virtual_rows,
+            component_names=component_names,
+        )


Test patch:
diff --git a/robotools/evotools/test_worklist.py b/robotools/evotools/test_worklist.py
new file mode 100644
index 0000000..666288c
--- /dev/null
+++ b/robotools/evotools/test_worklist.py
@@ -0,0 +1,2156 @@
+import logging
+import os
+import tempfile
+
+import numpy as np
+import pytest
+
+from robotools.evotools.exceptions import InvalidOperationError
+from robotools.evotools.types import Labwares, Tip
+from robotools.evotools.worklist import (
+    Worklist,
+    _optimize_partition_by,
+    _partition_by_column,
+    _partition_volume,
+    _prepare_aspirate_dispense_parameters,
+    _prepare_evo_aspirate_dispense_parameters,
+    _prepare_evo_wash_parameters,
+)
+from robotools.liquidhandling.labware import Labware, Trough
+
+
+class TestWorklist:
+    def test_context(self) -> None:
+        with Worklist() as worklist:
+            assert worklist is not None
+        return
+
+    def test_parameter_validation(self) -> None:
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label=None, position=1, volume=15)
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label=15, position=1, volume=15)
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="thisisaveryverylongracklabelthatexceedsthemaximumlength", position=1, volume=15
+            )
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="rack label; with semicolon", position=1, volume=15
+            )
+        _prepare_aspirate_dispense_parameters(rack_label="valid rack label", position=1, volume=15)
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=None, volume=15)
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position="3", volume=15)
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=-1, volume=15)
+        _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=15)
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=None)
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume="nan")
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=float("nan"))
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=-15.4)
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume="bla")
+        _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume="15")
+        _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=20)
+        _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=23.78)
+        _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=np.array(23.4))
+
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, liquid_class=None
+            )
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, liquid_class="liquid;class"
+            )
+        _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, liquid_class="valid liquid class"
+        )
+
+        _, _, _, _, tip, _, _, _, _ = _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, tip=4
+        )
+        assert tip == 8
+        _, _, _, _, tip, _, _, _, _ = _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, tip=Tip.T5
+        )
+        assert tip == 16
+        _, _, _, _, tip, _, _, _, _ = _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, tip=(Tip.T4, 4)
+        )
+        assert tip == 8
+        _, _, _, _, tip, _, _, _, _ = _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, tip=[Tip.T1, 4]
+        )
+        assert tip == 9
+        _, _, _, _, tip, _, _, _, _ = _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, tip=[1, 4]
+        )
+        assert tip == 9
+        _, _, _, _, tip, _, _, _, _ = _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, tip=[1, Tip.T4]
+        )
+        assert tip == 9
+        _, _, _, _, tip, _, _, _, _ = _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, tip=Tip.Any
+        )
+        assert tip == ""
+
+        with pytest.raises(ValueError, match="no Tip.Any elements are allowed"):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, tip=(Tip.T1, Tip.Any)
+            )
+        with pytest.raises(ValueError, match="tip must be an int between 1 and 8, Tip or Iterable"):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=15, tip=None)
+        with pytest.raises(ValueError, match="it may only contain int or Tip values"):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, tip=[1, 2.6]
+            )
+        with pytest.raises(ValueError, match="should be an int between 1 and 8 for _int_to_tip"):
+            _prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=15, tip=12)
+
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, rack_id=None
+            )
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, rack_id="invalid;rack"
+            )
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough",
+                position=1,
+                volume=15,
+                rack_id="thisisaveryverylongrackthatexceedsthemaximumlength",
+            )
+        _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, rack_id="1235464"
+        )
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, rack_type=None
+            )
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, rack_type="invalid;rack type"
+            )
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough",
+                position=1,
+                volume=15,
+                rack_type="thisisaveryverylongracktypethatexceedsthemaximumlength",
+            )
+        _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, rack_type="valid rack type"
+        )
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, forced_rack_type=None
+            )
+        with pytest.raises(ValueError):
+            _prepare_aspirate_dispense_parameters(
+                rack_label="WaterTrough", position=1, volume=15, forced_rack_type="invalid;forced rack type"
+            )
+        _prepare_aspirate_dispense_parameters(
+            rack_label="WaterTrough", position=1, volume=15, forced_rack_type="valid forced rack type"
+        )
+
+        # test _prepare_evo_aspirate_dispense_parameters
+        # define a labware correctly for testing purposes
+        plate = Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
+        # test labware argument checks
+        with pytest.raises(ValueError, match="Invalid labware:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware="wrong_labware_type",
+                wells=["A01", "B01"],
+                labware_position=(38, 2),
+                volume=15,
+                liquid_class="Water_DispZmax-1_AspZmax-1",
+                tips=[1, 2],
+            )
+        # test wells argument checks
+        with pytest.raises(ValueError, match="Invalid wells:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware=plate,
+                wells="A01",
+                labware_position=(38, 2),
+                volume=15,
+                liquid_class="Water_DispZmax-1_AspZmax-1",
+                tips=[1, 2],
+            )
+        # test labware_position argument checks
+        with pytest.raises(ValueError, match="Invalid position:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware=plate,
+                wells=["A01", "B01"],
+                labware_position=(38, -1),
+                volume=15,
+                liquid_class="Water_DispZmax-1_AspZmax-1",
+                tips=[1, 2],
+            )
+        with pytest.raises(ValueError, match="Invalid position:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware=plate,
+                wells=["A01", "B01"],
+                labware_position=("a", 2),
+                volume=15,
+                liquid_class="Water_DispZmax-1_AspZmax-1",
+                tips=[1, 2],
+            )
+        # test liquid_class argument checks
+        with pytest.raises(ValueError, match="Invalid liquid_class:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware=plate,
+                wells=["A01", "B01"],
+                labware_position=(38, 2),
+                volume=15,
+                liquid_class=["Water_DispZmax-1_AspZmax-1"],
+                tips=[1, 2],
+            )
+        with pytest.raises(ValueError, match="Invalid liquid_class:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware=plate,
+                wells=["A01", "B01"],
+                labware_position=(38, 2),
+                volume=15,
+                liquid_class="Water;DispZmax-1;AspZmax-1",
+                tips=[1, 2],
+            )
+        # test tips argument checks
+        with pytest.raises(ValueError, match="Invalid type of tips:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware=plate,
+                wells=["A01", "B01"],
+                labware_position=(38, 2),
+                volume=15,
+                liquid_class="Water_DispZmax-1_AspZmax-1",
+                tips=[1, "2"],
+            )
+        _, _, _, _, _, tips = _prepare_evo_aspirate_dispense_parameters(
+            labware=plate,
+            wells=["A01", "B01"],
+            labware_position=(38, 2),
+            volume=15,
+            liquid_class="Water_DispZmax-1_AspZmax-1",
+            tips=[1, 2],
+        )
+        if not all(isinstance(n, Tip) for n in tips):
+            raise TypeError(
+                f"Even after completing the _prepare_evo_aspirate_dispense_parameters method, not all tips are type Tip."
+            )
+        # test volume argument checks
+        with pytest.raises(ValueError, match="Invalid volume:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware=plate,
+                wells=["A01", "B01"],
+                labware_position=(38, 2),
+                volume="volume",
+                liquid_class="Water_DispZmax-1_AspZmax-1",
+                tips=[1, 2],
+            )
+        with pytest.raises(ValueError, match="Invalid volume:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware=plate,
+                wells=["A01", "B01"],
+                labware_position=(38, 2),
+                volume=-10,
+                liquid_class="Water_DispZmax-1_AspZmax-1",
+                tips=[1, 2],
+            )
+        with pytest.raises(ValueError, match="Invalid volume:"):
+            _prepare_evo_aspirate_dispense_parameters(
+                labware=plate,
+                wells=["A01", "B01"],
+                labware_position=(38, 2),
+                volume=7158279,
+                liquid_class="Water_DispZmax-1_AspZmax-1",
+                tips=[1, 2],
+            )
+
+        # test complete _prepare_evo_aspirate_dispense_parameters() command
+        actual = _prepare_evo_aspirate_dispense_parameters(
+            labware=plate,
+            wells=["E01", "F01", "G01"],
+            labware_position=(38, 2),
+            volume=750,
+            liquid_class="Water_DispZmax_AspZmax",
+            tips=[5, 6, 7],
+        )
+        expected = (
+            plate,
+            ["E01", "F01", "G01"],
+            (38, 2),
+            [750.0, 750.0, 750.0],
+            "Water_DispZmax_AspZmax",
+            [Tip.T5, Tip.T6, Tip.T7],
+        )
+        assert actual == expected
+
+        # test _prepare_evo_wash_parameters
+        # test tips argument checks
+        tips, _, _, _, _, _, _, _, _, _, _, _, _ = _prepare_evo_wash_parameters(
+            tips=[1, 2],
+            waste_location=(52, 1),
+            cleaner_location=(52, 0),
+        )
+        if not all(isinstance(n, Tip) for n in tips):
+            raise TypeError(
+                f"Even after completing the _prepare_evo_aspirate_dispense_parameters method, not all tips are type Tip."
+            )
+
+        # test waste_location argument checks
+        with pytest.raises(ValueError, match="Grid \\(first number in waste_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(68, 1),
+                cleaner_location=(52, 0),
+            )
+        with pytest.raises(ValueError, match="Grid \\(first number in waste_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(0, 1),
+                cleaner_location=(52, 0),
+            )
+        with pytest.raises(ValueError, match="Grid \\(first number in waste_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(1.7, 1),
+                cleaner_location=(52, 0),
+            )
+        with pytest.raises(ValueError, match="Site \\(second number in waste_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, -1),
+                cleaner_location=(52, 0),
+            )
+        with pytest.raises(ValueError, match="Site \\(second number in waste_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 128),
+                cleaner_location=(52, 0),
+            )
+        with pytest.raises(ValueError, match="Site \\(second number in waste_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1.7),
+                cleaner_location=(52, 0),
+            )
+
+        # test cleaner_location argument checks
+        with pytest.raises(ValueError, match="Grid \\(first number in cleaner_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(68, 1),
+            )
+        with pytest.raises(ValueError, match="Grid \\(first number in cleaner_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(0, 1),
+            )
+        with pytest.raises(ValueError, match="Grid \\(first number in cleaner_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(1.7, 1),
+            )
+        with pytest.raises(ValueError, match="Site \\(second number in cleaner_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, -1),
+            )
+        with pytest.raises(ValueError, match="Site \\(second number in cleaner_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 128),
+            )
+        with pytest.raises(ValueError, match="Site \\(second number in cleaner_location tuple\\)"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 1.7),
+            )
+
+        # test arm argument check
+        with pytest.raises(ValueError, match="Parameter arm"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                arm=2,
+            )
+
+        # test waste_vol argument check
+        with pytest.raises(ValueError, match="waste_vol has to be a float"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                waste_vol=-1.0,
+            )
+        with pytest.raises(ValueError, match="waste_vol has to be a float"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                waste_vol=101.0,
+            )
+        with pytest.raises(ValueError, match="waste_vol has to be a float"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                waste_vol=1,
+            )
+
+        # test waste_delay argument check
+        with pytest.raises(ValueError, match="waste_delay has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                waste_delay=-1,
+            )
+        with pytest.raises(ValueError, match="waste_delay has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                waste_delay=1001,
+            )
+        with pytest.raises(ValueError, match="waste_delay has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                waste_delay=10.0,
+            )
+
+        # test cleaner_vol argument check
+        with pytest.raises(ValueError, match="cleaner_vol has to be a float"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                cleaner_vol=-1.0,
+            )
+        with pytest.raises(ValueError, match="cleaner_vol has to be a float"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                cleaner_vol=101.0,
+            )
+        with pytest.raises(ValueError, match="cleaner_vol has to be a float"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                cleaner_vol=1,
+            )
+
+        # test cleaner_delay argument check
+        with pytest.raises(ValueError, match="cleaner_delay has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                cleaner_delay=-1,
+            )
+        with pytest.raises(ValueError, match="cleaner_delay has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                cleaner_delay=1001,
+            )
+        with pytest.raises(ValueError, match="cleaner_delay has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                cleaner_delay=10.0,
+            )
+
+        # test airgap argument check
+        with pytest.raises(ValueError, match="airgap has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                airgap=-1,
+            )
+        with pytest.raises(ValueError, match="airgap has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                airgap=101,
+            )
+        with pytest.raises(ValueError, match="airgap has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                airgap=10.0,
+            )
+
+        # test airgap_speed argument check
+        with pytest.raises(ValueError, match="airgap_speed has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                airgap_speed=0,
+            )
+        with pytest.raises(ValueError, match="airgap_speed has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                airgap_speed=1001,
+            )
+        with pytest.raises(ValueError, match="airgap_speed has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                airgap_speed=10.0,
+            )
+
+        # test retract_speed argument check
+        with pytest.raises(ValueError, match="retract_speed has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                retract_speed=0,
+            )
+        with pytest.raises(ValueError, match="retract_speed has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                retract_speed=101,
+            )
+        with pytest.raises(ValueError, match="retract_speed has to be an int"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                retract_speed=10.0,
+            )
+
+        # test fastwash argument check
+        with pytest.raises(ValueError, match="Parameter fastwash"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                fastwash=2,
+            )
+        with pytest.raises(ValueError, match="Parameter fastwash"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                fastwash=1.0,
+            )
+
+        # test low_volume argument check
+        with pytest.raises(ValueError, match="Parameter low_volume"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                low_volume=2,
+            )
+        with pytest.raises(ValueError, match="Parameter low_volume"):
+            _prepare_evo_wash_parameters(
+                tips=[1, 2],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+                low_volume=1.0,
+            )
+
+        # test complete _prepare_evo_wash_parameters() command
+        actual = _prepare_evo_wash_parameters(
+            tips=[1, 2, 3, 4, 5, 6, 7, 8],
+            waste_location=(52, 1),
+            cleaner_location=(52, 0),
+        )
+        expected = (
+            [
+                Tip.T1,
+                Tip.T2,
+                Tip.T3,
+                Tip.T4,
+                Tip.T5,
+                Tip.T6,
+                Tip.T7,
+                Tip.T8,
+            ],
+            (52, 1),
+            (52, 0),
+            0,
+            3.0,
+            500,
+            4.0,
+            500,
+            10,
+            70,
+            30,
+            1,
+            0,
+        )
+        assert actual == expected
+        return
+
+    def test_evo_aspirate1(self) -> None:
+        plate = Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
+        with Worklist() as wl:
+            wl.evo_aspirate(
+                labware=plate,
+                wells=["E01", "F01", "G01"],
+                labware_position=(38, 2),
+                tips=[5, 6, 7],
+                volumes=750,
+                liquid_class="Water_DispZmax_AspZmax",
+            )
+            exp = 'B;Aspirate(112,"Water_DispZmax_AspZmax",0,0,0,0,"750.0","750.0","750.0",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);'
+            assert wl[0] == exp
+        return
+
+    def test_evo_aspirate2(self) -> None:
+        plate = Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
+        with Worklist() as wl:
+            wl.evo_aspirate(
+                labware=plate,
+                wells=["E01", "F01", "G01"],
+                labware_position=(38, 2),
+                tips=[5, 6, 7],
+                volumes=[750, 730, 710],
+                liquid_class="Water_DispZmax_AspZmax",
+            )
+            exp = 'B;Aspirate(112,"Water_DispZmax_AspZmax",0,0,0,0,"750","730","710",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);'
+            assert wl[0] == exp
+        return
+
+    def test_evo_dispense1(self) -> None:
+        plate = Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
+        with Worklist() as wl:
+            wl.evo_dispense(
+                labware=plate,
+                wells=["E01", "F01", "G01"],
+                labware_position=(38, 2),
+                tips=[5, 6, 7],
+                volumes=750,
+                liquid_class="Water_DispZmax_AspZmax",
+            )
+            exp = 'B;Dispense(112,"Water_DispZmax_AspZmax",0,0,0,0,"750.0","750.0","750.0",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);'
+            assert wl[0] == exp
+        return
+
+    def test_evo_dispense2(self) -> None:
+        plate = Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
+        with Worklist() as wl:
+            wl.evo_dispense(
+                labware=plate,
+                wells=["E01", "F01", "G01"],
+                labware_position=(38, 2),
+                tips=[5, 6, 7],
+                volumes=[750, 730, 710],
+                liquid_class="Water_DispZmax_AspZmax",
+            )
+            exp = 'B;Dispense(112,"Water_DispZmax_AspZmax",0,0,0,0,"750","730","710",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);'
+            assert wl[0] == exp
+        return
+
+    def test_evo_wash(self) -> None:
+        with Worklist() as wl:
+            wl.evo_wash(
+                tips=[1, 2, 3, 4, 5, 6, 7, 8],
+                waste_location=(52, 1),
+                cleaner_location=(52, 0),
+            )
+            assert wl[0] == 'B;Wash(255,52,1,52,0,"3.0",500,"4.0",500,10,70,30,1,0,1000,0);'
+        return
+
+    def test_comment(self) -> None:
+        with Worklist() as wl:
+            # empty and None comments should be ignored
+            wl.comment("")
+            wl.comment(None)
+            # this will be the first actual comment
+            wl.comment("This is a simple comment")
+            with pytest.raises(ValueError):
+                wl.comment("It must not contain ; semicolons")
+            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
+
+    def test_wash(self) -> None:
+        with Worklist() as wl:
+            wl.wash()
+            with pytest.raises(ValueError):
+                wl.wash(scheme=15)
+            with pytest.raises(ValueError):
+                wl.wash(scheme="2")
+            wl.wash(scheme=1)
+            wl.wash(scheme=2)
+            wl.wash(scheme=3)
+            wl.wash(scheme=4)
+            exp = [
+                "W1;",
+                "W1;",
+                "W2;",
+                "W3;",
+                "W4;",
+            ]
+            assert wl == exp
+        return
+
+    def test_decontaminate(self) -> None:
+        with Worklist() as wl:
+            wl.decontaminate()
+            assert wl == ["WD;"]
+        return
+
+    def test_flush(self) -> None:
+        with Worklist() as wl:
+            wl.flush()
+            assert wl == ["F;"]
+        return
+
+    def test_commit(self) -> None:
+        with Worklist() as wl:
+            wl.commit()
+            assert wl == ["B;"]
+        return
+
+    def test_set_diti(self) -> None:
+        with Worklist() as wl:
+            wl.set_diti(diti_index=1)
+            with pytest.raises(InvalidOperationError):
+                wl.set_diti(diti_index=2)
+            wl.commit()
+            wl.set_diti(diti_index=2)
+            assert wl == [
+                "S;1",
+                "B;",
+                "S;2",
+            ]
+        return
+
+    def test_aspirate_single(self) -> None:
+        with Worklist() as wl:
+            wl.aspirate_well("WaterTrough", 1, 200)
+            assert wl[-1] == "A;WaterTrough;;;1;;200.00;;;;"
+            wl.aspirate_well(
+                "WaterTrough", 1, 200, rack_id="12345", rack_type="my_rack_id", tube_id="my_tube_id"
+            )
+            assert wl[-1] == "A;WaterTrough;12345;my_rack_id;1;my_tube_id;200.00;;;;"
+            wl.aspirate_well(
+                "WaterTrough", 1, 200, liquid_class="my_liquid_class", tip=8, forced_rack_type="forced_rack"
+            )
+            assert wl[-1] == "A;WaterTrough;;;1;;200.00;my_liquid_class;;128;forced_rack"
+        return
+
+    def test_dispense_single(self) -> None:
+        with Worklist() as wl:
+            wl.dispense_well("WaterTrough", 1, 200)
+            assert wl[-1] == "D;WaterTrough;;;1;;200.00;;;;"
+            wl.dispense_well(
+                "WaterTrough", 1, 200, rack_id="12345", rack_type="my_rack_id", tube_id="my_tube_id"
+            )
+            assert wl[-1] == "D;WaterTrough;12345;my_rack_id;1;my_tube_id;200.00;;;;"
+            wl.dispense_well(
+                "WaterTrough", 1, 200, liquid_class="my_liquid_class", tip=8, forced_rack_type="forced_rack"
+            )
+            assert wl[-1] == "D;WaterTrough;;;1;;200.00;my_liquid_class;;128;forced_rack"
+        return
+
+    def test_aspirate_systemliquid(self) -> None:
+        with Worklist() as wl:
+            wl.aspirate_well(Labwares.SystemLiquid.value, 1, 200)
+            assert wl[-1] == "A;Systemliquid;;;1;;200.00;;;;"
+        return
+
+    def test_save(self) -> None:
+        tf = tempfile.mktemp() + ".gwl"
+        error = None
+        try:
+            with Worklist() as worklist:
+                worklist.flush()
+                worklist.save(tf)
+                assert os.path.exists(tf)
+                # also check that the file can be overwritten if it exists already
+                worklist.save(tf)
+            assert os.path.exists(tf)
+            with open(tf) as file:
+                lines = file.readlines()
+                assert lines == ["F;"]
+        except Exception as ex:
+            error = ex
+        finally:
+            os.remove(tf)
+        assert os.path.exists(tf == False)
+        if error:
+            raise error
+        return
+
+    def test_autosave(self) -> None:
+        tf = tempfile.mktemp() + ".gwl"
+        error = None
+        try:
+            with Worklist(tf) as worklist:
+                worklist.flush()
+            assert os.path.exists(tf)
+            with open(tf) as file:
+                lines = file.readlines()
+                assert lines == ["F;"]
+        except Exception as ex:
+            error = ex
+        finally:
+            os.remove(tf)
+        assert os.path.exists(tf == False)
+        if error:
+            raise error
+        return
+
+
+class TestStandardLabwareWorklist:
+    def test_aspirate(self) -> None:
+        source = Labware("SourceLW", rows=3, columns=3, min_volume=10, max_volume=200, initial_volumes=200)
+        with Worklist() as wl:
+            wl.aspirate(source, ["A01", "A02", "C02"], 50, label=None)
+            wl.aspirate(source, ["A03", "B03", "C03"], [10, 20, 30.5], label="second aspirate")
+            assert wl == [
+                "A;SourceLW;;;1;;50.00;;;;",
+                "A;SourceLW;;;4;;50.00;;;;",
+                "A;SourceLW;;;6;;50.00;;;;",
+                "C;second aspirate",
+                "A;SourceLW;;;7;;10.00;;;;",
+                "A;SourceLW;;;8;;20.00;;;;",
+                "A;SourceLW;;;9;;30.50;;;;",
+            ]
+            np.testing.assert_array_equal(
+                source.volumes,
+                [
+                    [150, 150, 190],
+                    [200, 200, 180],
+                    [200, 150, 169.5],
+                ],
+            )
+            assert len(source.history) == 3
+        return
+
+    def test_aspirate_2d_volumes(self) -> None:
+        source = Labware("SourceLW", rows=2, columns=3, min_volume=10, max_volume=200, initial_volumes=200)
+        with Worklist() as wl:
+            wl.aspirate(
+                source,
+                source.wells[:, :2],
+                volumes=np.array(
+                    [
+                        [20, 30],
+                        [15.3, 17.53],
+                    ]
+                ),
+            )
+            assert wl == [
+                "A;SourceLW;;;1;;20.00;;;;",
+                "A;SourceLW;;;2;;15.30;;;;",
+                "A;SourceLW;;;3;;30.00;;;;",
+                "A;SourceLW;;;4;;17.53;;;;",
+            ]
+            np.testing.assert_array_equal(source.volumes, [[180, 170, 200], [200 - 15.3, 200 - 17.53, 200]])
+            assert len(source.history) == 2
+        return
+
+    def test_dispense(self) -> None:
+        destination = Labware("DestinationLW", rows=2, columns=3, min_volume=10, max_volume=200)
+        with Worklist() as wl:
+            wl.dispense(destination, ["A01", "A02", "A03"], 150, label=None)
+            wl.dispense(destination, ["B01", "B02", "B03"], [10, 20, 30.5], label="second dispense")
+            assert wl == [
+                "D;DestinationLW;;;1;;150.00;;;;",
+                "D;DestinationLW;;;3;;150.00;;;;",
+                "D;DestinationLW;;;5;;150.00;;;;",
+                "C;second dispense",
+                "D;DestinationLW;;;2;;10.00;;;;",
+                "D;DestinationLW;;;4;;20.00;;;;",
+                "D;DestinationLW;;;6;;30.50;;;;",
+            ]
+            np.testing.assert_array_equal(
+                destination.volumes,
+                [
+                    [150, 150, 150],
+                    [10, 20, 30.5],
+                ],
+            )
+            assert len(destination.history) == 3
+        return
+
+    def test_dispense_2d_volumes(self) -> None:
+        destination = Labware("DestinationLW", rows=2, columns=3, min_volume=10, max_volume=200)
+        with Worklist() as wl:
+            wl.dispense(
+                destination,
+                destination.wells[:, :2],
+                volumes=np.array(
+                    [
+                        [20, 30],
+                        [15.3, 17.53],
+                    ]
+                ),
+            )
+            assert wl == [
+                "D;DestinationLW;;;1;;20.00;;;;",
+                "D;DestinationLW;;;2;;15.30;;;;",
+                "D;DestinationLW;;;3;;30.00;;;;",
+                "D;DestinationLW;;;4;;17.53;;;;",
+            ]
+            np.testing.assert_array_equal(destination.volumes, [[20, 30, 0], [15.3, 17.53, 0]])
+            assert len(destination.history) == 2
+        return
+
+    def test_skip_zero_volumes(self) -> None:
+        source = Labware("SourceLW", rows=3, columns=3, min_volume=10, max_volume=200, initial_volumes=200)
+        destination = Labware("DestinationLW", rows=2, columns=3, min_volume=10, max_volume=200)
+        with Worklist() as wl:
+            wl.aspirate(source, ["A03", "B03", "C03"], [10, 0, 30.5])
+            wl.dispense(destination, ["B01", "B02", "B03"], [10, 0, 30.5])
+            assert wl == [
+                "A;SourceLW;;;7;;10.00;;;;",
+                "A;SourceLW;;;9;;30.50;;;;",
+                "D;DestinationLW;;;2;;10.00;;;;",
+                "D;DestinationLW;;;6;;30.50;;;;",
+            ]
+            np.testing.assert_array_equal(
+                source.volumes,
+                [
+                    [200, 200, 190],
+                    [200, 200, 200],
+                    [200, 200, 169.5],
+                ],
+            )
+            np.testing.assert_array_equal(
+                destination.volumes,
+                [
+                    [0, 0, 0],
+                    [10, 0, 30.5],
+                ],
+            )
+            assert len(destination.history) == 2
+        return
+
+    def test_transfer_2d_volumes(self) -> None:
+        A = Labware("A", 2, 4, min_volume=50, max_volume=250, initial_volumes=200)
+        B = Labware("B", 2, 4, min_volume=50, max_volume=250)
+        with Worklist() as wl:
+            wl.transfer(
+                A,
+                A.wells[:, :2],
+                B,
+                B.wells[:, :2],
+                volumes=np.array(
+                    [
+                        [20, 30],
+                        [15.3, 17.53],
+                    ]
+                ),
+            )
+            assert wl == [
+                "A;A;;;1;;20.00;;;;",
+                "D;B;;;1;;20.00;;;;",
+                "W1;",
+                "A;A;;;2;;15.30;;;;",
+                "D;B;;;2;;15.30;;;;",
+                "W1;",
+                "A;A;;;3;;30.00;;;;",
+                "D;B;;;3;;30.00;;;;",
+                "W1;",
+                "A;A;;;4;;17.53;;;;",
+                "D;B;;;4;;17.53;;;;",
+                "W1;",
+            ]
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [180, 170, 200, 200],
+                    [200 - 15.3, 200 - 17.53, 200, 200],
+                ],
+            )
+            assert np.array_equal(
+                B.volumes,
+                np.array(
+                    [
+                        [20, 30, 0, 0],
+                        [15.3, 17.53, 0, 0],
+                    ]
+                ),
+            )
+            assert len(A.history) == 2
+            assert len(B.history) == 2
+        return
+
+    def test_transfer_2d_volumes_no_wash(self) -> None:
+        A = Labware("A", 2, 4, min_volume=50, max_volume=250, initial_volumes=200)
+        B = Labware("B", 2, 4, min_volume=50, max_volume=250)
+        with Worklist() as wl:
+            wl.transfer(
+                A,
+                A.wells[:, :2],
+                B,
+                B.wells[:, :2],
+                volumes=np.array(
+                    [
+                        [20, 30],
+                        [15.3, 17.53],
+                    ]
+                ),
+                wash_scheme=None,
+            )
+            assert wl == [
+                "A;A;;;1;;20.00;;;;",
+                "D;B;;;1;;20.00;;;;",
+                "A;A;;;2;;15.30;;;;",
+                "D;B;;;2;;15.30;;;;",
+                "A;A;;;3;;30.00;;;;",
+                "D;B;;;3;;30.00;;;;",
+                "A;A;;;4;;17.53;;;;",
+                "D;B;;;4;;17.53;;;;",
+            ]
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [180, 170, 200, 200],
+                    [200 - 15.3, 200 - 17.53, 200, 200],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [20, 30, 0, 0],
+                    [15.3, 17.53, 0, 0],
+                ],
+            )
+            assert len(A.history) == 2
+            assert len(B.history) == 2
+        return
+
+    def test_transfer_many_many(self) -> None:
+        A = Labware("A", 3, 4, min_volume=50, max_volume=250, initial_volumes=200)
+        B = Labware("B", 3, 4, min_volume=50, max_volume=250)
+        wells = ["A01", "B01"]
+        with Worklist() as worklist:
+            worklist.transfer(A, wells, B, wells, 50, label="first transfer")
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [150, 200, 200, 200],
+                    [150, 200, 200, 200],
+                    [200, 200, 200, 200],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [50, 0, 0, 0],
+                    [50, 0, 0, 0],
+                    [0, 0, 0, 0],
+                ],
+            )
+            worklist.transfer(A, ["A03", "B04"], B, ["A04", "B04"], 50, label="second transfer")
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [150, 200, 150, 200],
+                    [150, 200, 200, 150],
+                    [200, 200, 200, 200],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [50, 0, 0, 50],
+                    [50, 0, 0, 50],
+                    [0, 0, 0, 0],
+                ],
+            )
+            assert worklist == [
+                "C;first transfer",
+                "A;A;;;1;;50.00;;;;",
+                "D;B;;;1;;50.00;;;;",
+                "W1;",
+                "A;A;;;2;;50.00;;;;",
+                "D;B;;;2;;50.00;;;;",
+                "W1;",
+                "C;second transfer",
+                "A;A;;;7;;50.00;;;;",
+                "D;B;;;10;;50.00;;;;",
+                "W1;",
+                "A;A;;;11;;50.00;;;;",
+                "D;B;;;11;;50.00;;;;",
+                "W1;",
+            ]
+            assert len(A.history) == 3
+            assert len(B.history) == 3
+        return
+
+    def test_transfer_many_many_2d(self) -> None:
+        A = Labware("A", 3, 4, min_volume=50, max_volume=250, initial_volumes=200)
+        B = Labware("B", 3, 4, min_volume=50, max_volume=250)
+        wells = A.wells[:, :2]
+        with Worklist() as worklist:
+            worklist.transfer(A, wells, B, wells, 50)
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [150, 150, 200, 200],
+                    [150, 150, 200, 200],
+                    [150, 150, 200, 200],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [50, 50, 0, 0],
+                    [50, 50, 0, 0],
+                    [50, 50, 0, 0],
+                ],
+            )
+            assert worklist == [
+                # first transfer
+                "A;A;;;1;;50.00;;;;",
+                "D;B;;;1;;50.00;;;;",
+                "W1;",
+                "A;A;;;2;;50.00;;;;",
+                "D;B;;;2;;50.00;;;;",
+                "W1;",
+                "A;A;;;3;;50.00;;;;",
+                "D;B;;;3;;50.00;;;;",
+                "W1;",
+                "A;A;;;4;;50.00;;;;",
+                "D;B;;;4;;50.00;;;;",
+                "W1;",
+                "A;A;;;5;;50.00;;;;",
+                "D;B;;;5;;50.00;;;;",
+                "W1;",
+                "A;A;;;6;;50.00;;;;",
+                "D;B;;;6;;50.00;;;;",
+                "W1;",
+            ]
+            assert len(A.history) == 2
+            assert len(B.history) == 2
+        return
+
+    def test_transfer_one_many(self) -> None:
+        A = Labware("A", 3, 4, min_volume=50, max_volume=250, initial_volumes=200)
+        B = Labware("B", 3, 4, min_volume=50, max_volume=250)
+        with Worklist() as worklist:
+            worklist.transfer(A, "A01", B, ["B01", "B02", "B03"], 25)
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [125, 200, 200, 200],
+                    [200, 200, 200, 200],
+                    [200, 200, 200, 200],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [0, 0, 0, 0],
+                    [25, 25, 25, 0],
+                    [0, 0, 0, 0],
+                ],
+            )
+            worklist.transfer(A, ["A01"], B, ["B01", "B02", "B03"], 25)
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [50, 200, 200, 200],
+                    [200, 200, 200, 200],
+                    [200, 200, 200, 200],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [0, 0, 0, 0],
+                    [50, 50, 50, 0],
+                    [0, 0, 0, 0],
+                ],
+            )
+            assert worklist == [
+                # first transfer
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;5;;25.00;;;;",
+                "W1;",
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;8;;25.00;;;;",
+                "W1;",
+                # second transfer
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;5;;25.00;;;;",
+                "W1;",
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;8;;25.00;;;;",
+                "W1;",
+            ]
+            assert len(A.history) == 3
+            assert len(B.history) == 3
+        return
+
+    def test_transfer_many_one(self) -> None:
+        A = Labware("A", 3, 4, min_volume=50, max_volume=250, initial_volumes=200)
+        B = Labware("B", 3, 4, min_volume=50, max_volume=250)
+        with Worklist() as worklist:
+            worklist.transfer(A, ["A01", "A02", "A03"], B, "B01", 25)
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [175, 175, 175, 200],
+                    [200, 200, 200, 200],
+                    [200, 200, 200, 200],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [0, 0, 0, 0],
+                    [75, 0, 0, 0],
+                    [0, 0, 0, 0],
+                ],
+            )
+            assert worklist == [
+                # first transfer
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;4;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;7;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+            ]
+            assert len(A.history) == 2
+            assert len(B.history) == 2
+        return
+
+    def test_tip_selection(self) -> None:
+        A = Labware("A", 3, 4, min_volume=10, max_volume=250, initial_volumes=100)
+        with Worklist() as wl:
+            wl.aspirate(A, "A01", 10, tip=1)
+            wl.aspirate(A, "A01", 10, tip=2)
+            wl.aspirate(A, "A01", 10, tip=3)
+            wl.aspirate(A, "A01", 10, tip=4)
+            wl.aspirate(A, "A01", 10, tip=5)
+            wl.aspirate(A, "A01", 10, tip=6)
+            wl.aspirate(A, "A01", 10, tip=7)
+            wl.aspirate(A, "A01", 10, tip=8)
+            wl.dispense(A, "B01", 10, tip=Tip.T1)
+            wl.dispense(A, "B02", 10, tip=Tip.T2)
+            wl.dispense(A, "B03", 10, tip=Tip.T3)
+            wl.dispense(A, "B04", 10, tip=Tip.T4)
+            wl.dispense(A, "B04", 10, tip=Tip.T5)
+            wl.dispense(A, "B04", 10, tip=Tip.T6)
+            wl.dispense(A, "B04", 10, tip=Tip.T7)
+            wl.dispense(A, "B04", 10, tip=Tip.T8)
+            assert wl == [
+                "A;A;;;1;;10.00;;;1;",
+                "A;A;;;1;;10.00;;;2;",
+                "A;A;;;1;;10.00;;;4;",
+                "A;A;;;1;;10.00;;;8;",
+                "A;A;;;1;;10.00;;;16;",
+                "A;A;;;1;;10.00;;;32;",
+                "A;A;;;1;;10.00;;;64;",
+                "A;A;;;1;;10.00;;;128;",
+                "D;A;;;2;;10.00;;;1;",
+                "D;A;;;5;;10.00;;;2;",
+                "D;A;;;8;;10.00;;;4;",
+                "D;A;;;11;;10.00;;;8;",
+                "D;A;;;11;;10.00;;;16;",
+                "D;A;;;11;;10.00;;;32;",
+                "D;A;;;11;;10.00;;;64;",
+                "D;A;;;11;;10.00;;;128;",
+            ]
+        return
+
+    def test_tip_mask(self) -> None:
+        A = Labware("A", 3, 4, min_volume=10, max_volume=250)
+
+        # Only allow three specific tips to be used...
+        tips = [
+            Tip.T1,  # 1 +
+            Tip.T4,  # 8 +
+            Tip.T7,  # 64
+            # The sum of tips is = 73
+        ]
+        with Worklist() as wl:
+            wl.dispense(A, "A01", 10, tip=tips)
+        assert wl[-1] == "D;A;;;1;;10.00;;;73;"
+        pass
+
+    def test_history_condensation(self) -> None:
+        A = Labware("A", 3, 2, min_volume=300, max_volume=4600, initial_volumes=1500)
+        B = Labware("B", 3, 2, min_volume=300, max_volume=4600, initial_volumes=1500)
+
+        with Worklist() as wl:
+            wl.transfer(A, ["A01", "B01", "C02"], B, ["A01", "B02", "C01"], [900, 100, 900], label="transfer")
+
+        assert len(A.history) == 2
+        assert A.history[-1][0] == "transfer"
+        np.testing.assert_array_equal(
+            A.history[-1][1],
+            [
+                [1500 - 900, 1500],
+                [1500 - 100, 1500],
+                [1500, 1500 - 900],
+            ],
+        )
+
+        assert len(B.history) == 2
+        assert B.history[-1][0] == "transfer"
+        np.testing.assert_array_equal(
+            B.history[-1][1],
+            [
+                [1500 + 900, 1500],
+                [1500, 1500 + 100],
+                [1500 + 900, 1500],
+            ],
+        )
+        return
+
+    def test_history_condensation_within_labware(self) -> None:
+        A = Labware("A", 3, 2, min_volume=300, max_volume=4600, initial_volumes=1500)
+
+        with Worklist() as wl:
+            wl.transfer(A, ["A01", "B01", "C02"], A, ["A01", "B02", "C01"], [900, 100, 900], label="mix")
+
+        assert len(A.history) == 2
+        assert A.history[-1][0] == "mix"
+        np.testing.assert_array_equal(
+            A.history[-1][1],
+            [
+                [1500 - 900 + 900, 1500],
+                [1500 - 100, 1500 + 100],
+                [1500 + 900, 1500 - 900],
+            ],
+        )
+        return
+
+
+class TestTroughLabwareWorklist:
+    def test_aspirate(self) -> None:
+        source = Trough(
+            "SourceLW", virtual_rows=3, columns=3, min_volume=10, max_volume=200, initial_volumes=200
+        )
+        with Worklist() as wl:
+            wl.aspirate(source, ["A01", "A02", "C02"], 50)
+            wl.aspirate(source, ["A01", "A02", "C02"], [1, 2, 3])
+            assert wl == [
+                "A;SourceLW;;;1;;50.00;;;;",
+                "A;SourceLW;;;4;;50.00;;;;",
+                "A;SourceLW;;;6;;50.00;;;;",
+                "A;SourceLW;;;1;;1.00;;;;",
+                "A;SourceLW;;;4;;2.00;;;;",
+                "A;SourceLW;;;6;;3.00;;;;",
+            ]
+            np.testing.assert_array_equal(source.volumes, [[149, 95, 200]])
+            assert len(source.history) == 3
+        return
+
+    def test_dispense(self) -> None:
+        destination = Trough("DestinationLW", virtual_rows=3, columns=3, min_volume=10, max_volume=200)
+        with Worklist() as wl:
+            wl.dispense(destination, ["A01", "A02", "A03", "B01"], 50)
+            wl.dispense(destination, ["A01", "A02", "C02"], [1, 2, 3])
+            assert wl == [
+                "D;DestinationLW;;;1;;50.00;;;;",
+                "D;DestinationLW;;;4;;50.00;;;;",
+                "D;DestinationLW;;;7;;50.00;;;;",
+                "D;DestinationLW;;;2;;50.00;;;;",
+                "D;DestinationLW;;;1;;1.00;;;;",
+                "D;DestinationLW;;;4;;2.00;;;;",
+                "D;DestinationLW;;;6;;3.00;;;;",
+            ]
+            np.testing.assert_array_equal(destination.volumes, [[101, 55, 50]])
+            assert len(destination.history) == 3
+        return
+
+    def test_transfer_many_many(self) -> None:
+        A = Trough("A", 3, 4, min_volume=50, max_volume=2500, initial_volumes=2000)
+        B = Labware("B", 3, 4, min_volume=50, max_volume=250)
+        with Worklist() as worklist:
+            worklist.transfer(A, ["A01", "B01"], B, ["A01", "B01"], 50)
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [1900, 2000, 2000, 2000],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [50, 0, 0, 0],
+                    [50, 0, 0, 0],
+                    [0, 0, 0, 0],
+                ],
+            )
+            worklist.transfer(A, ["A03", "B04"], B, ["A04", "B04"], [50, 75])
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [1900, 2000, 1950, 1925],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [50, 0, 0, 50],
+                    [50, 0, 0, 75],
+                    [0, 0, 0, 0],
+                ],
+            )
+            assert worklist == [
+                # first transfer
+                "A;A;;;1;;50.00;;;;",
+                "D;B;;;1;;50.00;;;;",
+                "W1;",
+                "A;A;;;2;;50.00;;;;",
+                "D;B;;;2;;50.00;;;;",
+                "W1;",
+                # second transfer
+                "A;A;;;7;;50.00;;;;",
+                "D;B;;;10;;50.00;;;;",
+                "W1;",
+                "A;A;;;11;;75.00;;;;",
+                "D;B;;;11;;75.00;;;;",
+                "W1;",
+            ]
+            assert len(A.history) == 3
+            assert len(B.history) == 3
+        return
+
+    def test_transfer_one_many(self) -> None:
+        A = Trough("A", 3, 4, min_volume=50, max_volume=2500, initial_volumes=2000)
+        B = Labware("B", 3, 4, min_volume=50, max_volume=250)
+        with Worklist() as worklist:
+            worklist.transfer(A, "A01", B, ["B01", "B02", "B03"], 25)
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [1925, 2000, 2000, 2000],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [0, 0, 0, 0],
+                    [25, 25, 25, 0],
+                    [0, 0, 0, 0],
+                ],
+            )
+            assert worklist == [
+                # first transfer
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;5;;25.00;;;;",
+                "W1;",
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;8;;25.00;;;;",
+                "W1;",
+            ]
+
+            worklist.transfer(A, ["A01"], B, ["B01", "B02", "B03"], [25, 30, 35])
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [1835, 2000, 2000, 2000],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [0, 0, 0, 0],
+                    [50, 55, 60, 0],
+                    [0, 0, 0, 0],
+                ],
+            )
+            assert worklist == [
+                # first transfer
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;5;;25.00;;;;",
+                "W1;",
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;8;;25.00;;;;",
+                "W1;",
+                # second transfer
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;1;;30.00;;;;",
+                "D;B;;;5;;30.00;;;;",
+                "W1;",
+                "A;A;;;1;;35.00;;;;",
+                "D;B;;;8;;35.00;;;;",
+                "W1;",
+            ]
+            assert len(A.history) == 3
+            assert len(B.history) == 3
+        return
+
+    def test_transfer_many_one(self) -> None:
+        A = Trough("A", 3, 4, min_volume=50, max_volume=2500, initial_volumes=[2000, 1500, 1000, 500])
+        B = Labware("B", 3, 4, min_volume=10, max_volume=250, initial_volumes=100)
+        with Worklist() as worklist:
+            worklist.transfer(A, ["A01", "A02", "A03"], B, "B01", 25)
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [1975, 1475, 975, 500],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [100, 100, 100, 100],
+                    [175, 100, 100, 100],
+                    [100, 100, 100, 100],
+                ],
+            )
+            assert worklist == [
+                # first transfer
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;4;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;7;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+            ]
+
+            worklist.transfer(B, B.wells[:, 2], A, A.wells[:, 3], [50, 60, 70])
+            np.testing.assert_array_equal(
+                A.volumes,
+                [
+                    [1975, 1475, 975, 680],
+                ],
+            )
+            np.testing.assert_array_equal(
+                B.volumes,
+                [
+                    [100, 100, 50, 100],
+                    [175, 100, 40, 100],
+                    [100, 100, 30, 100],
+                ],
+            )
+            assert worklist == [
+                # first transfer
+                "A;A;;;1;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;4;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                "A;A;;;7;;25.00;;;;",
+                "D;B;;;2;;25.00;;;;",
+                "W1;",
+                # second transfer
+                "A;B;;;7;;50.00;;;;",
+                "D;A;;;10;;50.00;;;;",
+                "W1;",
+                "A;B;;;8;;60.00;;;;",
+                "D;A;;;11;;60.00;;;;",
+                "W1;",
+                "A;B;;;9;;70.00;;;;",
+                "D;A;;;12;;70.00;;;;",
+                "W1;",
+            ]
+            assert len(A.history) == 3
+            assert len(B.history) == 3
+        return
+
+
+class TestLargeVolumeHandling:
+    def test_partition_volume_helper(self) -> None:
+        assert [] == _partition_volume(0, max_volume=950)
+        assert [550.3] == _partition_volume(550.3, max_volume=950)
+        assert [500 == 500], _partition_volume(1000, max_volume=950)
+        assert [500 == 499], _partition_volume(999, max_volume=950)
+        assert [667 == 667, 666], _partition_volume(2000, max_volume=950)
+        return
+
+    def test_worklist_constructor(self) -> None:
+        with pytest.raises(ValueError):
+            with Worklist(max_volume=None) as wl:
+                pass
+        with Worklist(max_volume=800, auto_split=True) as wl:
+            assert wl.max_volume == 800
+            assert wl.auto_split == True
+        with Worklist(max_volume=800, auto_split=False) as wl:
+            assert wl.max_volume == 800
+            assert wl.auto_split == False
+        return
+
+    def test_max_volume_checking(self) -> None:
+        source = Trough(
+            "WaterTrough",
+            virtual_rows=3,
+            columns=3,
+            min_volume=1000,
+            max_volume=100 * 1000,
+            initial_volumes=50 * 1000,
+        )
+        destination = Trough(
+            "WaterTrough",
+            virtual_rows=3,
+            columns=3,
+            min_volume=1000,
+            max_volume=100 * 1000,
+            initial_volumes=50 * 1000,
+        )
+        with Worklist(max_volume=900, auto_split=False) as wl:
+            with pytest.raises(InvalidOperationError):
+                wl.aspirate_well("WaterTrough", 1, 1000)
+            with pytest.raises(InvalidOperationError):
+                wl.dispense_well("WaterTrough", 1, 1000)
+            with pytest.raises(InvalidOperationError):
+                wl.aspirate(source, ["A01", "A02", "C02"], 1000)
+            with pytest.raises(InvalidOperationError):
+                wl.dispense(source, ["A01", "A02", "C02"], 1000)
+            with pytest.raises(InvalidOperationError):
+                wl.transfer(source, ["A01", "B01"], destination, ["A01", "B01"], 1000)
+
+        source = Trough(
+            "WaterTrough",
+            virtual_rows=3,
+            columns=3,
+            min_volume=1000,
+            max_volume=100 * 1000,
+            initial_volumes=50 * 1000,
+        )
+        destination = Trough(
+            "WaterTrough",
+            virtual_rows=3,
+            columns=3,
+            min_volume=1000,
+            max_volume=100 * 1000,
+            initial_volumes=50 * 1000,
+        )
+        with Worklist(max_volume=1200) as wl:
+            wl.aspirate_well("WaterTrough", 1, 1000)
+            wl.dispense_well("WaterTrough", 1, 1000)
+            wl.aspirate(source, ["A01", "A02", "C02"], 1000)
+            wl.dispense(source, ["A01", "A02", "C02"], 1000)
+            wl.transfer(source, ["A01", "B01"], destination, ["A01", "B01"], 1000)
+        return
+
+    def test_partition_by_columns_source(self) -> None:
+        column_groups = _partition_by_column(
+            ["A01", "B01", "A03", "B03", "C02"],
+            ["A01", "B01", "C01", "D01", "E01"],
+            [2500, 3500, 1000, 500, 2000],
+            partition_by="source",
+        )
+        assert len(column_groups) == 3
+        assert column_groups[0] == (
+            ["A01", "B01"],
+            ["A01", "B01"],
+            [2500, 3500],
+        )
+        assert column_groups[1] == (
+            ["C02"],
+            ["E01"],
+            [2000],
+        )
+        assert column_groups[2] == (
+            ["A03", "B03"],
+            ["C01", "D01"],
+            [1000, 500],
+        )
+        return
+
+    def test_partition_by_columns_destination(self) -> None:
+        column_groups = _partition_by_column(
+            ["A01", "B01", "A03", "B03", "C02"],
+            ["A01", "B01", "C02", "D01", "E02"],
+            [2500, 3500, 1000, 500, 2000],
+            partition_by="destination",
+        )
+        assert len(column_groups) == 2
+        assert column_groups[0] == (
+            ["A01", "B01", "B03"],
+            ["A01", "B01", "D01"],
+            [2500, 3500, 500],
+        )
+        assert column_groups[1] == (
+            ["A03", "C02"],
+            ["C02", "E02"],
+            [1000, 2000],
+        )
+        return
+
+    def test_partition_by_columns_sorting(self) -> None:
+        # within every column, the wells are supposed to be sorted by row
+        # The test source wells are partially sorted (col 1 is in the right order, col 3 in the reverse)
+        # The result is expected to always be sorted by row, either in the source (first case) or destination:
+
+        # by source
+        column_groups = _partition_by_column(
+            ["A01", "B01", "B03", "A03", "C02"],
+            ["B01", "A01", "C01", "D01", "E01"],
+            [2500, 3500, 1000, 500, 2000],
+            partition_by="source",
+        )
+        assert len(column_groups) == 3
+        assert column_groups[0] == (
+            ["A01", "B01"],
+            ["B01", "A01"],
+            [2500, 3500],
+        )
+        assert column_groups[1] == (
+            ["C02"],
+            ["E01"],
+            [2000],
+        )
+        assert column_groups[2] == (
+            ["A03", "B03"],
+            ["D01", "C01"],
+            [500, 1000],
+        )
+
+        # by destination
+        # (destination wells are across 3 columns; reverse order in col 1, forward order in col 3)
+        column_groups = _partition_by_column(
+            ["A01", "B01", "B03", "A03", "C02"],
+            ["B01", "A01", "C03", "D03", "E02"],
+            [2500, 3500, 1000, 500, 2000],
+            partition_by="destination",
+        )
+        assert len(column_groups) == 3
+        assert column_groups[0] == (
+            ["B01", "A01"],
+            ["A01", "B01"],
+            [3500, 2500],
+        )
+        assert column_groups[1] == (
+            ["C02"],
+            ["E02"],
+            [2000],
+        )
+        assert column_groups[2] == (
+            ["B03", "A03"],
+            ["C03", "D03"],
+            [1000, 500],
+        )
+        return
+
+    def test_single_split(self) -> None:
+        src = Labware("A", 3, 2, min_volume=1000, max_volume=25000, initial_volumes=12000)
+        dst = Labware("B", 3, 2, min_volume=1000, max_volume=25000)
+        with Worklist(auto_split=True) as wl:
+            wl.transfer(src, "A01", dst, "A01", 2000, label="Transfer more than 2x the max")
+            assert wl == [
+                "C;Transfer more than 2x the max",
+                "A;A;;;1;;667.00;;;;",
+                "D;B;;;1;;667.00;;;;",
+                "W1;",
+                # no breaks when pipetting single wells
+                "A;A;;;1;;667.00;;;;",
+                "D;B;;;1;;667.00;;;;",
+                "W1;",
+                # no breaks when pipetting single wells
+                "A;A;;;1;;666.00;;;;",
+                "D;B;;;1;;666.00;;;;",
+                "W1;",
+                "B;",  # always break after partitioning
+            ]
+        # Two extra steps were necessary because of LVH
+        assert "Transfer more than 2x the max (2 LVH steps)" in src.report
+        assert "Transfer more than 2x the max (2 LVH steps)" in dst.report
+        np.testing.assert_array_equal(
+            src.volumes,
+            [
+                [12000 - 2000, 12000],
+                [12000, 12000],
+                [12000, 12000],
+            ],
+        )
+        np.testing.assert_array_equal(
+            dst.volumes,
+            [
+                [2000, 0],
+                [0, 0],
+                [0, 0],
+            ],
+        )
+        return
+
+    def test_column_split(self) -> None:
+        src = Labware("A", 4, 2, min_volume=1000, max_volume=25000, initial_volumes=12000)
+        dst = Labware("B", 4, 2, min_volume=1000, max_volume=25000)
+        with Worklist(auto_split=True) as wl:
+            wl.transfer(
+                src, ["A01", "B01", "D01", "C01"], dst, ["A01", "B01", "D01", "C01"], [1500, 250, 0, 1200]
+            )
+            assert wl == [
+                "A;A;;;1;;750.00;;;;",
+                "D;B;;;1;;750.00;;;;",
+                "W1;",
+                "A;A;;;2;;250.00;;;;",
+                "D;B;;;2;;250.00;;;;",
+                "W1;",
+                # D01 is ignored because the volume is 0
+                "A;A;;;3;;600.00;;;;",
+                "D;B;;;3;;600.00;;;;",
+                "W1;",
+                "B;",  # within-column break
+                "A;A;;;1;;750.00;;;;",
+                "D;B;;;1;;750.00;;;;",
+                "W1;",
+                "A;A;;;3;;600.00;;;;",
+                "D;B;;;3;;600.00;;;;",
+                "W1;",
+                "B;",  # tailing break after partitioning
+            ]
+        np.testing.assert_array_equal(
+            src.volumes,
+            [
+                [12000 - 1500, 12000],
+                [12000 - 250, 12000],
+                [12000 - 1200, 12000],
+                [12000, 12000],
+            ],
+        )
+        np.testing.assert_array_equal(
+            dst.volumes,
+            [
+                [1500, 0],
+                [250, 0],
+                [1200, 0],
+                [0, 0],
+            ],
+        )
+        return
+
+    def test_block_split(self) -> None:
+        src = Labware("A", 3, 2, min_volume=1000, max_volume=25000, initial_volumes=12000)
+        dst = Labware("B", 3, 2, min_volume=1000, max_volume=25000)
+        with Worklist(auto_split=True) as wl:
+            wl.transfer(
+                # A01, B01, A02, B02
+                src,
+                src.wells[:2, :],
+                dst,
+                ["A01", "B01", "C01", "A02"],
+                [1500, 250, 1200, 3000],
+            )
+            assert wl == [
+                "A;A;;;1;;750.00;;;;",
+                "D;B;;;1;;750.00;;;;",
+                "W1;",
+                "A;A;;;2;;250.00;;;;",
+                "D;B;;;2;;250.00;;;;",
+                "W1;",
+                "B;",  # within-column 1 break
+                "A;A;;;1;;750.00;;;;",
+                "D;B;;;1;;750.00;;;;",
+                "W1;",
+                "B;",  # between-column 1/2 break
+                "A;A;;;4;;600.00;;;;",
+                "D;B;;;3;;600.00;;;;",
+                "W1;",
+                "A;A;;;5;;750.00;;;;",
+                "D;B;;;4;;750.00;;;;",
+                "W1;",
+                "B;",  # within-column 2 break
+                "A;A;;;4;;600.00;;;;",
+                "D;B;;;3;;600.00;;;;",
+                "W1;",
+                "A;A;;;5;;750.00;;;;",
+                "D;B;;;4;;750.00;;;;",
+                "W1;",
+                "B;",  # within-column 2 break
+                "A;A;;;5;;750.00;;;;",
+                "D;B;;;4;;750.00;;;;",
+                "W1;",
+                # no break because only one well is accessed in this partition
+                "A;A;;;5;;750.00;;;;",
+                "D;B;;;4;;750.00;;;;",
+                "W1;",
+                "B;",  # tailing break after partitioning
+            ]
+
+        # How the number of splits is calculated:
+        # 1500 is split 2x → 1 extra
+        # 250 is not split
+        # 1200 is split 2x → 1 extra
+        # 3000 is split 4x → 3 extra
+        # Sum of extra steps: 5
+        assert "5 LVH steps" in src.report
+        assert "5 LVH steps" in dst.report
+        np.testing.assert_array_equal(
+            src.volumes,
+            [
+                [12000 - 1500, 12000 - 1200],
+                [12000 - 250, 12000 - 3000],
+                [12000, 12000],
+            ],
+        )
+        np.testing.assert_array_equal(
+            dst.volumes,
+            [
+                [1500, 3000],
+                [250, 0],
+                [1200, 0],
+            ],
+        )
+        return
+
+
+class TestReagentDistribution:
+    def test_parameter_validation(self, caplog) -> None:
+        with Worklist() as wl:
+            with pytest.raises(ValueError):
+                wl.reagent_distribution("S1", 1, 8, "D1", 1, 20, volume=50, direction="invalid")
+            with pytest.raises(ValueError):
+                # one excluded well not in the destination range
+                wl.reagent_distribution("S1", 1, 8, "D1", 1, 20, volume=50, exclude_wells=[18, 19, 23])
+        with pytest.raises(InvalidOperationError):
+            with caplog.at_level(logging.WARNING, logger="evotools"):
+                with Worklist(max_volume=950) as wl:
+                    # dispense more than diluter volume
+                    wl.reagent_distribution("S1", 1, 8, "D1", 1, 20, volume=1200)
+            assert "account for a large dispense" in caplog.records[0].message
+        return
+
+    def test_default_parameterization(self) -> None:
+        with Worklist() as wl:
+            wl.reagent_distribution("S1", 1, 20, "D1", 2, 21, volume=50)
+        assert wl[0] == "R;S1;;;1;20;D1;;;2;21;50;;1;1;0"
+        return
+
+    def test_full_parameterization(self) -> None:
+        with Worklist() as wl:
+            wl.reagent_distribution(
+                "S1",
+                1,
+                20,
+                "D1",
+                2,
+                21,
+                volume=50,
+                diti_reuse=2,
+                multi_disp=3,
+                exclude_wells=[2, 4, 8],
+                liquid_class="TestLC",
+                direction="right_to_left",
+                src_rack_type="MP3Pos",
+                dst_rack_type="MP4Pos",
+                src_rack_id="S1234",
+                dst_rack_id="D1234",
+            )
+        assert wl[0] == "R;S1;S1234;MP3Pos;1;20;D1;D1234;MP4Pos;2;21;50;TestLC;2;3;1;2;4;8"
+        return
+
+    def test_large_volume_multi_disp_adaption(self) -> None:
+        with Worklist() as wl:
+            wl.reagent_distribution(
+                "S1",
+                1,
+                8,
+                "D1",
+                1,
+                96,
+                volume=400,
+                multi_disp=6,
+            )
+        assert wl[0] == "R;S1;;;1;8;D1;;;1;96;400;;1;2;0"
+        return
+
+    def test_oo_parameter_validation(self) -> None:
+        with Worklist() as wl:
+            src = Labware("NotATrough", 6, 2, min_volume=20, max_volume=1000)
+            dst = Labware("48deep", 6, 8, min_volume=50, max_volume=4000)
+            with pytest.raises(ValueError):
+                wl.distribute(src, 0, dst, dst.wells[:, :3], volume=50)
+        with Worklist(max_volume=950) as wl:
+            src = Trough("Water", 8, 2, min_volume=20, max_volume=1000)
+            dst = Labware("48deep", 6, 8, min_volume=50, max_volume=4000)
+            with pytest.raises(InvalidOperationError):
+                wl.distribute(src, 0, dst, dst.wells[:, :3], volume=1200)
+        return
+
+    def test_oo_example_1(self) -> None:
+        src = Trough(
+            "T3",
+            8,
+            1,
+            min_volume=20,
+            max_volume=100 * 1000,
+            initial_volumes=100 * 1000,
+        )
+        dst = Labware("MTP-96-3", 8, 12, min_volume=20, max_volume=300)
+        with Worklist() as wl:
+            all_dst_wells = set(dst.wells.flatten("F"))
+            skip_wells = set("C04,C07,E09,F06,B11".split(","))
+            dst_wells = list(all_dst_wells.difference(skip_wells))
+            wl.distribute(
+                src,
+                0,
+                dst,
+                dst_wells,
+                volume=100,
+                liquid_class="Water",
+                diti_reuse=1,
+                multi_disp=6,
+                direction="left_to_right",
+                src_rack_type="Trough 100ml",
+                dst_rack_type="96 Well Microplate",
+                label="Test Label",
+            )
+        assert wl[0] == "C;Test Label"
+        assert (
+            wl[1] == "R;T3;;Trough 100ml;1;8;MTP-96-3;;96 Well Microplate;1;96;100;Water;1;6;0;27;46;51;69;82"
+        )
+        assert src.volumes[0 == 0], 100 * 1000 - 91 * 100
+        dst_exp = np.ones_like(dst.volumes) * 100
+        dst_exp[dst.indices["C04"]] = 0
+        dst_exp[dst.indices["C07"]] = 0
+        dst_exp[dst.indices["E09"]] = 0
+        dst_exp[dst.indices["F06"]] = 0
+        dst_exp[dst.indices["B11"]] = 0
+        np.testing.assert_array_equal(dst.volumes, dst_exp)
+        return
+
+    def test_oo_example_2(self) -> None:
+        src = Trough(
+            "T2",
+            8,
+            1,
+            min_volume=20,
+            max_volume=100 * 1000,
+            initial_volumes=100 * 1000,
+        )
+        dst = Labware("MTP-96-2", 8, 12, min_volume=20, max_volume=300)
+        with Worklist() as wl:
+            wl.distribute(
+                src,
+                0,
+                dst,
+                dst.wells,
+                volume=100,
+                liquid_class="Water",
+                diti_reuse=2,
+                multi_disp=5,
+                direction="left_to_right",
+                src_rack_type="Trough 100ml",
+                dst_rack_type="96 Well Microplate",
+                label="Test Label",
+            )
+        assert wl[0] == "C;Test Label"
+        assert wl[1] == "R;T2;;Trough 100ml;1;8;MTP-96-2;;96 Well Microplate;1;96;100;Water;2;5;0"
+        assert src.volumes[0 == 0], 100 * 1000 - 96 * 100
+        np.testing.assert_array_equal(dst.volumes, np.ones_like(dst.volumes) * 100)
+        return
+
+    def test_oo_block_from_right(self) -> None:
+        src = Trough(
+            "Water",
+            8,
+            1,
+            min_volume=20,
+            max_volume=100 * 1000,
+            initial_volumes=100 * 1000,
+        )
+        dst = Labware("96mtp", 8, 12, min_volume=20, max_volume=300)
+        with Worklist() as wl:
+            wl.distribute(
+                src,
+                0,
+                dst,
+                dst.wells[1:4, 2:7],
+                volume=50,
+                liquid_class="TestLC",
+                diti_reuse=10,
+                multi_disp=5,
+                direction="right_to_left",
+                label="Test Label",
+            )
+        assert wl[0] == "C;Test Label"
+        skip_pos = ";21;22;23;24;25;29;30;31;32;33;37;38;39;40;41;45;46;47;48;49"
+        assert wl[1] == f"R;Water;;;1;8;96mtp;;;18;52;50;TestLC;10;5;1{skip_pos}"
+        assert src.volumes[0 == 0], 100 * 1000 - 15 * 50
+        assert np.all(dst.volumes[1:4, 2:7] == 50)
+        return
+
+
+class TestFunctions:
+    def test_automatic_partitioning(self, caplog) -> None:
+        S = Labware("S", 8, 2, min_volume=5000, max_volume=250 * 1000)
+        D = Labware("D", 8, 2, min_volume=5000, max_volume=250 * 1000)
+        ST = Trough("ST", 8, 2, min_volume=5000, max_volume=250 * 1000)
+        DT = Trough("DT", 8, 2, min_volume=5000, max_volume=250 * 1000)
+
+        # Expected behaviors:
+        # + always keep settings other than 'auto'
+        # + warn user about inefficient configuration (when user selects to partition by the trough)
+
+        # automatic
+        assert "source" == _optimize_partition_by(S, D, "auto", "No troughs at all")
+        assert "source" == _optimize_partition_by(S, DT, "auto", "Trough destination")
+        assert "destination" == _optimize_partition_by(ST, D, "auto", "Trough source")
+        _optimize_partition_by(ST, DT, "auto", "Trough source and destination") == "source"
+
+        # fixed to source
+        assert "source" == _optimize_partition_by(S, D, "source", "No troughs at all")
+        assert "source" == _optimize_partition_by(S, DT, "source", "Trough destination")
+        with caplog.at_level(logging.WARNING, logger="evotools"):
+            assert "source" == _optimize_partition_by(ST, D, "source", "Trough source")
+        assert 'Consider using partition_by="destination"' in caplog.records[0].message
+        _optimize_partition_by(ST, DT, "auto", "Trough source and destination") == "source"
+
+        # fixed to destination
+        _optimize_partition_by(S, D, "destination", "No troughs at all") == "destination"
+        caplog.clear()
+        with caplog.at_level(logging.WARNING, logger="evotools"):
+            assert _optimize_partition_by(S, DT, "destination", "Trough destination") == "destination"
+        assert 'Consider using partition_by="source"' in caplog.records[0].message
+        assert _optimize_partition_by(ST, D, "destination", "Trough source") == "destination"
+        assert _optimize_partition_by(ST, DT, "destination", "Trough source and destination") == "destination"
+        return
diff --git a/robotools/liquidhandling/test_composition.py b/robotools/liquidhandling/test_composition.py
new file mode 100644
index 0000000..aaa83c5
--- /dev/null
+++ b/robotools/liquidhandling/test_composition.py
@@ -0,0 +1,325 @@
+import numpy as np
+import pytest
+
+from robotools.evotools import Worklist
+from robotools.liquidhandling.composition import (
+    combine_composition,
+    get_initial_composition,
+    get_trough_component_names,
+)
+from robotools.liquidhandling.labware import Labware, Trough
+
+
+class TestCompositionTracking:
+    def test_get_initial_composition(self) -> None:
+        wells2x3 = np.array(
+            [
+                ["A01", "A02", "A03"],
+                ["B01", "B02", "B03"],
+            ]
+        )
+
+        # Raise errors on invalid component well ids
+        with pytest.raises(ValueError, match=r"Invalid component name keys: \{'G02'\}"):
+            get_initial_composition("eppis", wells2x3, dict(G02="beer"), np.zeros((2, 3)))
+
+        # Raise errors on attempts to name empty wells
+        with pytest.raises(ValueError, match=r"name 'beer' was specified for eppis.A02, but"):
+            get_initial_composition("eppis", wells2x3, dict(A02="beer"), np.zeros((2, 3)))
+
+        # No components if all wells are empty
+        result = get_initial_composition("eppis", wells2x3, {}, np.zeros((2, 3)))
+        assert result == {}
+
+        # Default to labware name for one-well labwares
+        result = get_initial_composition("media", [["A01"]], {}, np.atleast_2d(100))
+        assert "media" in result
+        assert len(result) == 1
+
+        # Assigning default component names to all wells
+        result = get_initial_composition("samples", wells2x3, {}, np.ones((2, 3)))
+        assert isinstance(result, dict)
+        # Non-empty wells default to unique component names
+        assert "samples.A01" in result
+        assert "samples.B03" in result
+        # Only the well with the component has 100 % of it
+        np.testing.assert_array_equal(
+            result["samples.B02"],
+            np.array(
+                [
+                    [0, 0, 0],
+                    [0, 1, 0],
+                ]
+            ),
+        )
+
+        # Mix of user-defined and default component names
+        result = get_initial_composition("samples", wells2x3, {"B01": "water"}, np.ones((2, 3)))
+        assert isinstance(result, dict)
+        # Non-empty wells default to unique component names
+        assert "samples.A01" in result
+        assert "water" in result
+        assert "samples.B03" in result
+        return
+
+    def test_get_trough_component_names(self) -> None:
+        # The function requies the correct number of column names and initial volumes
+        # and should raise informative errors otherwise.
+        with pytest.raises(ValueError, match=r"column names \['A', 'B', 'C'\] don't match"):
+            get_trough_component_names("water", 2, ["A", "B", "C"], [20, 10])
+        with pytest.raises(ValueError, match=r"initial volumes \[20, 10\] don't match"):
+            get_trough_component_names("water", 3, ["A", "B", "C"], [20, 10])
+        with pytest.raises(ValueError, match=r"initial volumes \[\[20], \[10\]\] don't match"):
+            get_trough_component_names("water", 2, ["A", "B"], [[20], [10]])
+
+        # It should also check that no names are given for empty columns
+        with pytest.raises(ValueError, match="Empty columns must be unnamed"):
+            get_trough_component_names("water", 2, ["A", "B"], [20, 0])
+
+        # It explicitly sets names of empty wells to None
+        result = get_trough_component_names("water", 1, [None], [0])
+        assert result == {"A01": None}
+
+        # And defaults to the labware name of single-column troughs
+        result = get_trough_component_names("water", 1, [None], [100])
+        assert result == {"A01": "water"}
+
+        # But includes the 1-based column number in the default name for non-empty wells
+        result = get_trough_component_names("stocks", 2, [None, None], [0, 50])
+        assert result == {"A01": None, "A02": "stocks.column_02"}
+
+        # User-provided names, default naming and empty-well all in one:
+        result = get_trough_component_names("stocks", 4, ["acid", "base", None, None], [100, 100, 50, 0])
+        assert result == {"A01": "acid", "A02": "base", "A03": "stocks.column_03", "A04": None}
+        return
+
+    def test_combine_composition(self) -> None:
+        A = dict(water=1)
+        B = dict(water=0.5, glucose=0.5)
+        expected = {"water": (1 * 10 + 0.5 * 15) / (10 + 15), "glucose": 0.5 * 15 / (10 + 15)}
+        actual = combine_composition(10, A, 15, B)
+        assert actual == expected
+        return
+
+    def test_combine_unknown_composition(self) -> None:
+        A = dict(water=1)
+        B = None
+        expected = None
+        actual = combine_composition(10, A, 15, B)
+        assert actual == expected
+        return
+
+    def test_labware_init(self) -> None:
+        minmax = dict(min_volume=0, max_volume=4000)
+        # without initial volume, there's no composition tracking
+        A = Labware("glc", 6, 8, **minmax)
+        assert isinstance(A.composition, dict)
+        assert len(A.composition) == 0
+        assert A.get_well_composition("A01") == {}
+
+        # Single-well Labware defaults to the labware name for components
+        A = Labware("x", 1, 1, **minmax, initial_volumes=300)
+        assert set(A.composition) == {"x"}
+        A = Labware("x", 1, 1, virtual_rows=3, **minmax, initial_volumes=300)
+        assert set(A.composition) == {"x"}
+
+        # by setting an initial volume, the well-wise liquids take part in composition tracking
+        A = Labware("glc", 6, 8, **minmax, initial_volumes=100)
+        assert isinstance(A.composition, dict)
+        assert len(A.composition) == 48
+        assert "glc.A01" in A.composition
+        assert "glc.F08" in A.composition
+        assert A.get_well_composition("A01") == {"glc.A01": 1}
+
+        # Only wells with initial volumes take part
+        A = Labware("test", 1, 3, **minmax, initial_volumes=[10, 0, 0], component_names=dict(A01="water"))
+        assert set(A.composition) == {"water"}
+        return
+
+    def test_get_well_composition(self) -> None:
+        A = Labware("glc", 6, 8, min_volume=0, max_volume=4000)
+        A._composition = {
+            "glc": 0.25 * np.ones_like(A.volumes),
+            "water": 0.75 * np.ones_like(A.volumes),
+        }
+        expected = {
+            "glc": 0.25,
+            "water": 0.75,
+        }
+        assert A.get_well_composition("A01") == expected
+        return
+
+    def test_labware_add(self) -> None:
+        A = Labware(
+            "water",
+            6,
+            8,
+            min_volume=0,
+            max_volume=4000,
+            initial_volumes=10,
+            component_names={
+                "A01": "water",
+                "B01": "water",
+            },
+        )
+        water_comp = np.array(
+            [
+                [1, 0, 0, 0, 0, 0, 0, 0],
+                [1, 0, 0, 0, 0, 0, 0, 0],
+                [0, 0, 0, 0, 0, 0, 0, 0],
+                [0, 0, 0, 0, 0, 0, 0, 0],
+                [0, 0, 0, 0, 0, 0, 0, 0],
+                [0, 0, 0, 0, 0, 0, 0, 0],
+            ]
+        )
+        np.testing.assert_array_equal(A.composition["water"], water_comp)
+
+        A.add(
+            wells=["A01", "B01"],
+            volumes=[10, 20],
+            compositions=[
+                dict(glc=0.5, water=0.5),
+                dict(glc=1),
+            ],
+        )
+
+        assert "water" in A.composition
+        assert "glc" in A.composition
+        assert A.get_well_composition("A01") == dict(water=0.75, glc=0.25)
+        assert A.get_well_composition("B01") == dict(water=1 / 3, glc=2 / 3)
+        return
+
+    def test_dilution_series(self) -> None:
+        A = Labware("dilutions", 1, 3, min_volume=0, max_volume=100)
+        # 100 % in 1st column
+        A.add(wells="A01", volumes=100, compositions=[dict(glucose=1)])
+        # 10x dilution to 2nd
+        A.add(wells="A02", volumes=10, compositions=[A.get_well_composition("A01")])
+        A.add(wells="A02", volumes=90, compositions=[dict(water=1)])
+        # 4x dilution to 3rd
+        A.add(wells="A03", volumes=2.5, compositions=[A.get_well_composition("A02")])
+        A.add(wells="A03", volumes=7.5, compositions=[dict(water=1)])
+
+        np.testing.assert_array_equal(A.volumes, [[100, 100, 10]])
+        np.testing.assert_array_equal(A.composition["water"], [[0, 0.9, 0.975]])
+        np.testing.assert_array_equal(A.composition["glucose"], [[1, 0.1, 0.025]])
+        assert A.get_well_composition("A01") == dict(glucose=1)
+        assert A.get_well_composition("A02") == dict(glucose=0.1, water=0.9)
+        assert A.get_well_composition("A03") == dict(glucose=0.025, water=0.975)
+        return
+
+    def test_trough_init(self) -> None:
+        minmax = dict(min_volume=0, max_volume=100_000)
+        # Single-column troughs use the labware name for the composition
+        W = Trough("water", 2, 1, **minmax, initial_volumes=10000)
+        assert set(W.composition) == {"water"}
+
+        # Multi-column troughs get component names automatically
+        A = Trough(name="water", virtual_rows=2, columns=3, **minmax, initial_volumes=[0, 200, 200])
+        assert set(A.composition) == {"water.column_02", "water.column_03"}
+        assert "water.column_01" not in A.composition
+        assert "water.column_02" in A.composition
+        assert "water.column_03" in A.composition
+
+        # Components in troughs are named via a list, because the dict would
+        # require well names and columns could lead to 0/1-based confusion.
+        T = Trough(
+            "stocks",
+            6,
+            3,
+            min_volume=0,
+            max_volume=10_000,
+            initial_volumes=[100, 200, 0],
+            column_names=["rich", "complex", None],
+        )
+        assert set(T.composition.keys()) == {"rich", "complex"}
+
+        # Naming just some of them works too
+        A = Trough(
+            name="alice",
+            virtual_rows=2,
+            columns=3,
+            **minmax,
+            initial_volumes=[0, 200, 200],
+            column_names=[None, "NaCl", None],
+        )
+        assert set(A.composition) == {"NaCl", "alice.column_03"}
+        np.testing.assert_array_equal(A.composition["NaCl"], [[0, 1, 0]])
+
+    def test_trough_composition(self) -> None:
+        T = Trough("media", 8, 1, min_volume=1000, max_volume=25000)
+        T.add(wells=T.wells, volumes=100, compositions=[dict(glucose=1)] * 8)
+        T.add(wells=T.wells, volumes=900, compositions=[dict(water=1)] * 8)
+        np.testing.assert_array_equal(T.composition["water"], [[0.9]])
+        np.testing.assert_array_equal(T.composition["glucose"], [[0.1]])
+        assert T.get_well_composition("B01") == dict(water=0.9, glucose=0.1)
+        return
+
+    def test_worklist_dilution(self) -> None:
+        W = Trough("water", 4, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
+        G = Trough("glucose", 4, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
+        D = Labware("dilutions", 4, 2, min_volume=0, max_volume=10000)
+
+        with Worklist() as wl:
+            # 100 % in first column
+            wl.transfer(G, G.wells, D, D.wells[:, 0], volumes=[1000, 800, 600, 550])
+            wl.transfer(W, W.wells, D, D.wells[:, 0], volumes=[0, 200, 400, 450])
+            np.testing.assert_array_equal(D.volumes[:, 0], [1000, 1000, 1000, 1000])
+            np.testing.assert_array_equal(D.composition["glucose"][:, 0], [1, 0.8, 0.6, 0.55])
+            np.testing.assert_array_equal(D.composition["water"][:, 0], [0, 0.2, 0.4, 0.45])
+            # 10x dilution to the 2nd column
+            wl.transfer(D, D.wells[:, 0], D, D.wells[:, 1], volumes=100)
+            wl.transfer(W, W.wells, D, D.wells[:, 1], volumes=900)
+            np.testing.assert_array_equal(D.volumes[:, 1], [1000, 1000, 1000, 1000])
+            np.testing.assert_allclose(D.composition["glucose"][:, 1], [0.1, 0.08, 0.06, 0.055])
+            np.testing.assert_allclose(D.composition["water"][:, 1], [0.9, 0.92, 0.94, 0.945])
+
+        return
+
+    def test_worklist_distribution(self) -> None:
+        W = Trough("water", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
+        G = Trough("glucose", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
+        D = Labware("dilutions", 2, 4, min_volume=0, max_volume=10000)
+
+        with Worklist() as wl:
+            # transfer some glucose
+            wl.transfer(
+                G,
+                G.wells[:, [0] * 4],
+                D,
+                D.wells,
+                volumes=np.array(
+                    [
+                        [100, 80, 60, 55],
+                        [55, 60, 80, 100],
+                    ]
+                ),
+            )
+            # fill up to 100
+            wl.transfer(W, W.wells[:, [0] * 4], D, D.wells, volumes=100 - D.volumes)
+            np.testing.assert_allclose(D.volumes, 100)
+            np.testing.assert_array_equal(D.composition["glucose"][0, :], [1, 0.8, 0.6, 0.55])
+            np.testing.assert_array_equal(D.composition["glucose"][1, :], [0.55, 0.6, 0.8, 1])
+            np.testing.assert_array_equal(D.composition["water"][0, :], [0, 0.2, 0.4, 0.45])
+            np.testing.assert_array_equal(D.composition["water"][1, :], [0.45, 0.4, 0.2, 0])
+            # dilute 2x with water
+            wl.distribute(source=W, source_column=0, destination=D, destination_wells=D.wells, volume=100)
+            np.testing.assert_allclose(D.volumes, 200)
+            np.testing.assert_array_equal(D.composition["glucose"][0, :], [0.5, 0.4, 0.3, 0.275])
+            np.testing.assert_array_equal(D.composition["glucose"][1, :], [0.275, 0.3, 0.4, 0.5])
+            np.testing.assert_array_equal(D.composition["water"][0, :], [0.5, 0.6, 0.7, 0.725])
+            np.testing.assert_array_equal(D.composition["water"][1, :], [0.725, 0.7, 0.6, 0.5])
+
+        return
+
+    def test_worklist_mix_no_composition_change(self) -> None:
+        A = Labware("solution", 2, 3, min_volume=0, max_volume=1000)
+        A._composition["water"] = 0.25 * np.ones_like(A.volumes)
+        A._composition["salt"] = 0.75 * np.ones_like(A.volumes)
+        A._volumes = np.ones_like(A.volumes) * 500
+        with Worklist() as wl:
+            wl.transfer(A, A.wells, A, A.wells, volumes=300)
+        # make sure that the composition of the liquid is not changed
+        np.testing.assert_array_equal(A.composition["water"], 0.25 * np.ones_like(A.volumes))
+        np.testing.assert_array_equal(A.composition["salt"], 0.75 * np.ones_like(A.volumes))
+        return
diff --git a/robotools/liquidhandling/test_labware.py b/robotools/liquidhandling/test_labware.py
new file mode 100644
index 0000000..1e56d89
--- /dev/null
+++ b/robotools/liquidhandling/test_labware.py
@@ -0,0 +1,293 @@
+import numpy as np
+import pytest
+
+from robotools.liquidhandling.exceptions import (
+    VolumeOverflowError,
+    VolumeUnderflowError,
+)
+from robotools.liquidhandling.labware import Labware, Trough
+
+
+class TestStandardLabware:
+    def test_init(self) -> None:
+        plate = Labware("TestPlate", 2, 3, min_volume=50, max_volume=250, initial_volumes=30)
+        assert plate.name == "TestPlate"
+        assert plate.is_trough == False
+        assert plate.row_ids == tuple("AB")
+        assert plate.column_ids == [1, 2, 3]
+        assert plate.n_rows == 2
+        assert plate.n_columns == 3
+        assert plate.min_volume == 50
+        assert plate.max_volume == 250
+        assert len(plate.history) == 1
+        np.testing.assert_array_equal(plate.volumes, np.array([[30, 30, 30], [30, 30, 30]]))
+        exp = {
+            "A01": (0, 0),
+            "A02": (0, 1),
+            "A03": (0, 2),
+            "B01": (1, 0),
+            "B02": (1, 1),
+            "B03": (1, 2),
+        }
+        assert plate.indices == exp
+        assert plate.positions == {
+            "A01": 1,
+            "A02": 3,
+            "A03": 5,
+            "B01": 2,
+            "B02": 4,
+            "B03": 6,
+        }
+        return
+
+    def test_invalid_init(self) -> None:
+        with pytest.raises(ValueError):
+            Labware("A", 0, 3, min_volume=10, max_volume=250)
+        with pytest.raises(ValueError):
+            Labware("A", 3, 0, min_volume=10, max_volume=250)
+        with pytest.raises(ValueError):
+            Labware("A", 3, 4, min_volume=10, max_volume=250, virtual_rows=2)
+        with pytest.raises(ValueError):
+            Labware("A", 1, 4, min_volume=10, max_volume=250, virtual_rows=0)
+        return
+
+    def test_volume_limits(self) -> None:
+        with pytest.raises(ValueError):
+            Labware("A", 3, 4, min_volume=-30, max_volume=100)
+        with pytest.raises(ValueError):
+            Labware("A", 3, 4, min_volume=100, max_volume=70)
+        with pytest.raises(ValueError):
+            Labware("A", 3, 4, min_volume=10, max_volume=70, initial_volumes=100)
+        with pytest.raises(ValueError):
+            Labware("A", 3, 4, min_volume=10, max_volume=70, initial_volumes=-10)
+        Labware("A", 3, 4, min_volume=10, max_volume=70, initial_volumes=50)
+        return
+
+    def test_initial_volumes(self) -> None:
+        plate = Labware("TestPlate", 1, 3, min_volume=50, max_volume=250, initial_volumes=[20, 30, 40])
+        np.testing.assert_array_equal(
+            plate.volumes,
+            np.array(
+                [
+                    [20, 30, 40],
+                ]
+            ),
+        )
+        return
+
+    def test_logging(self) -> None:
+        plate = Labware("TestPlate", 2, 3, min_volume=50, max_volume=250)
+        plate.add(plate.wells, 25)
+        plate.add(plate.wells, 25)
+        plate.add(plate.wells, 25)
+        plate.add(plate.wells, 25)
+        assert len(plate.history) == 5
+        return
+
+    def test_log_condensation_first(self) -> None:
+        plate = Labware("TestPlate", 2, 3, min_volume=50, max_volume=250)
+        plate.add(plate.wells, 25, label="A")
+        plate.add(plate.wells, 25, label="B")
+        plate.add(plate.wells, 25, label="C")
+        plate.add(plate.wells, 25, label="D")
+        assert len(plate.history) == 5
+
+        # condense the last two as 'D'
+        plate.condense_log(2, label="last")
+        assert len(plate.history) == 4
+        assert plate.history[-1][0] == "D"
+        np.testing.assert_array_equal(
+            plate.history[-1][1],
+            np.array(
+                [
+                    [100, 100, 100],
+                    [100, 100, 100],
+                ]
+            ),
+        )
+
+        # condense the last three as 'A'
+        plate.condense_log(3, label="first")
+        assert len(plate.history) == 2
+        assert plate.history[-1][0] == "A"
+        np.testing.assert_array_equal(
+            plate.history[-1][1],
+            np.array(
+                [
+                    [100, 100, 100],
+                    [100, 100, 100],
+                ]
+            ),
+        )
+
+        # condense the remaining two as 'prepared'
+        plate.condense_log(3, label="prepared")
+        assert len(plate.history) == 1
+        assert plate.history[-1][0] == "prepared"
+        np.testing.assert_array_equal(
+            plate.history[-1][1],
+            np.array(
+                [
+                    [100, 100, 100],
+                    [100, 100, 100],
+                ]
+            ),
+        )
+        return
+
+    def test_add_valid(self) -> None:
+        plate = Labware("TestPlate", 4, 6, min_volume=100, max_volume=250)
+        wells = ["A01", "A02", "B04"]
+        plate.add(wells, 150)
+        plate.add(wells, 3.5)
+        assert len(plate.history) == 3
+        for well in wells:
+            assert plate.volumes[plate.indices[well]] == 153.5
+        return
+
+    def test_add_too_much(self) -> None:
+        plate = Labware("TestPlate", 4, 6, min_volume=100, max_volume=250)
+        wells = ["A01", "A02", "B04"]
+        with pytest.raises(VolumeOverflowError):
+            plate.add(wells, 500)
+        return
+
+    def test_remove_valid(self) -> None:
+        plate = Labware("TestPlate", 2, 3, min_volume=50, max_volume=250, initial_volumes=200)
+        wells = ["A01", "A02", "B03"]
+        plate.remove(wells, 50)
+        assert len(plate.history) == 2
+        np.testing.assert_array_equal(plate.volumes, np.array([[150, 150, 200], [200, 200, 150]]))
+        return
+
+    def test_remove_too_much(self) -> None:
+        plate = Labware("TestPlate", 4, 6, min_volume=100, max_volume=250)
+        wells = ["A01", "A02", "B04"]
+        with pytest.raises(VolumeUnderflowError):
+            plate.remove(wells, 500)
+        assert len(plate.history) == 1
+        return
+
+
+class TestTroughLabware:
+    def test_warns_on_api(self) -> None:
+        with pytest.warns(UserWarning, match="Troughs should be created with"):
+            Labware("test", rows=1, columns=2, min_volume=100, max_volume=3000, virtual_rows=4)
+
+        with pytest.warns(None) as record:
+            Trough("test", virtual_rows=6, columns=2, min_volume=100, max_volume=3000)
+        assert len(record) == 0
+        return
+
+    def test_init_trough(self) -> None:
+        trough = Trough("TestTrough", 5, 4, min_volume=1000, max_volume=50 * 1000, initial_volumes=30 * 1000)
+        assert trough.name == "TestTrough"
+        assert trough.is_trough
+        assert trough.row_ids == tuple("ABCDE")
+        assert trough.column_ids == [1, 2, 3, 4]
+        assert trough.min_volume == 1000
+        assert trough.max_volume == 50 * 1000
+        assert len(trough.history) == 1
+        np.testing.assert_array_equal(
+            trough.volumes, np.array([[30 * 1000, 30 * 1000, 30 * 1000, 30 * 1000]])
+        )
+        assert trough.indices == {
+            "A01": (0, 0),
+            "A02": (0, 1),
+            "A03": (0, 2),
+            "A04": (0, 3),
+            "B01": (0, 0),
+            "B02": (0, 1),
+            "B03": (0, 2),
+            "B04": (0, 3),
+            "C01": (0, 0),
+            "C02": (0, 1),
+            "C03": (0, 2),
+            "C04": (0, 3),
+            "D01": (0, 0),
+            "D02": (0, 1),
+            "D03": (0, 2),
+            "D04": (0, 3),
+            "E01": (0, 0),
+            "E02": (0, 1),
+            "E03": (0, 2),
+            "E04": (0, 3),
+        }
+        assert trough.positions == {
+            "A01": 1,
+            "A02": 6,
+            "A03": 11,
+            "A04": 16,
+            "B01": 2,
+            "B02": 7,
+            "B03": 12,
+            "B04": 17,
+            "C01": 3,
+            "C02": 8,
+            "C03": 13,
+            "C04": 18,
+            "D01": 4,
+            "D02": 9,
+            "D03": 14,
+            "D04": 19,
+            "E01": 5,
+            "E02": 10,
+            "E03": 15,
+            "E04": 20,
+        }
+        return
+
+    def test_initial_volumes(self) -> None:
+        trough = Trough(
+            "TestTrough",
+            5,
+            4,
+            min_volume=1000,
+            max_volume=50 * 1000,
+            initial_volumes=[30 * 1000, 20 * 1000, 20 * 1000, 20 * 1000],
+        )
+        np.testing.assert_array_equal(
+            trough.volumes,
+            np.array(
+                [
+                    [30 * 1000, 20 * 1000, 20 * 1000, 20 * 1000],
+                ]
+            ),
+        )
+        return
+
+    def test_trough_add_valid(self) -> None:
+        trough = Trough("TestTrough", 3, 4, min_volume=100, max_volume=250)
+        # adding into the first column (which is actually one well)
+        trough.add(["A01", "B01"], 50)
+        np.testing.assert_array_equal(trough.volumes, np.array([[100, 0, 0, 0]]))
+        # adding to the last row (separate wells)
+        trough.add(["C01", "C02", "C03"], 50)
+        np.testing.assert_array_equal(trough.volumes, np.array([[150, 50, 50, 0]]))
+        assert len(trough.history) == 3
+        return
+
+    def test_trough_add_too_much(self) -> None:
+        trough = Trough("TestTrough", 3, 4, min_volume=100, max_volume=1000)
+        # adding into the first column (which is actually one well)
+        with pytest.raises(VolumeOverflowError):
+            trough.add(["A01", "B01"], 600)
+        return
+
+    def test_trough_remove_valid(self) -> None:
+        trough = Trough("TestTrough", 3, 4, min_volume=1000, max_volume=30000, initial_volumes=3000)
+        # adding into the first column (which is actually one well)
+        trough.remove(["A01", "B01"], 50)
+        np.testing.assert_array_equal(trough.volumes, np.array([[2900, 3000, 3000, 3000]]))
+        # adding to the last row (separate wells)
+        trough.remove(["C01", "C02", "C03"], 50)
+        np.testing.assert_array_equal(trough.volumes, np.array([[2850, 2950, 2950, 3000]]))
+        assert len(trough.history) == 3
+        return
+
+    def test_trough_remove_too_much(self) -> None:
+        trough = Trough("TestTrough", 3, 4, min_volume=1000, max_volume=30 * 1000, initial_volumes=3000)
+        # adding into the first column (which is actually one well)
+        with pytest.raises(VolumeUnderflowError):
+            trough.remove(["A01", "B01"], 2000)
+        return
diff --git a/robotools/test_transform.py b/robotools/test_transform.py
new file mode 100644
index 0000000..0716310
--- /dev/null
+++ b/robotools/test_transform.py
@@ -0,0 +1,148 @@
+import numpy as np
+import pytest
+
+from robotools.transform import WellRandomizer, WellRotator, WellShifter
+
+
+class TestWellShifter:
+    def test_identity_transform(self) -> None:
+        A = (6, 8)
+        B = (8, 12)
+        shifter = WellShifter(A, B, shifted_A01="A01")
+
+        original = ["A01", "C03", "D06", "F08"]
+        expected = ["A01", "C03", "D06", "F08"]
+        shifted = shifter.shift(original)
+        np.testing.assert_array_equal(expected, shifter.shift(original))
+        np.testing.assert_array_equal(shifter.unshift(shifted), original)
+        return
+
+    def test_center_shift(self) -> None:
+        A = (6, 8)
+        B = (8, 12)
+        shifter = WellShifter(A, B, shifted_A01="B03")
+
+        original = ["A01", "C03", "D06", "F08"]
+        expected = ["B03", "D05", "E08", "G10"]
+        shifted = shifter.shift(original)
+        np.testing.assert_array_equal(expected, shifter.shift(original))
+        np.testing.assert_array_equal(shifter.unshift(shifted), original)
+        return
+
+    def test_boundcheck(self) -> None:
+        A = (6, 8)
+        B = (8, 12)
+
+        with pytest.raises(ValueError):
+            WellShifter(A, B, shifted_A01="E03")
+
+        with pytest.raises(ValueError):
+            WellShifter(A, B, shifted_A01="B06")
+        return
+
+
+class TestWellRotator:
+    def test_init(self) -> None:
+        rotator = WellRotator(original_shape=(7, 3))
+        assert rotator.original_shape == (7, 3)
+        assert rotator.rotated_shape == (3, 7)
+        return
+
+    def test_clockwise(self) -> None:
+        A = (6, 8)
+        rotator = WellRotator(A)
+
+        original = ["A01", "C03", "D06", "F08", "B04"]
+        expected = ["A06", "C04", "F03", "H01", "D05"]
+        rotated = rotator.rotate_cw(original)
+        np.testing.assert_array_equal(expected, rotated)
+        return
+
+    def test_counterclockwise(self) -> None:
+        A = (6, 8)
+        rotator = WellRotator(A)
+
+        original = ["A01", "C03", "D06", "F08", "B04"]
+        expected = ["H01", "F03", "C04", "A06", "E02"]
+        rotated = rotator.rotate_ccw(original)
+        np.testing.assert_array_equal(expected, rotated)
+        return
+
+
+class TestWellRandomizer:
+    def test_init(self):
+        randomizer = WellRandomizer(original_shape=(1, 4), random_seed=13)
+        assert randomizer.original_shape == (1, 4)
+        assert randomizer.random_seed == 13
+        np.testing.assert_array_equal(randomizer.randomized_wells, ["A02", "A04", "A01", "A03"])
+        return
+
+    def test_randomize_wells(self) -> None:
+        A = (6, 8)
+        S = 13
+        randomizer = WellRandomizer(A, S)
+        original = ["A01", "A02", "A03", "A04", "A05", "A06"]
+        expected = ["A01", "F02", "A05", "A07", "B07", "D06"]
+        randomized = randomizer.randomize_wells(original)
+        np.testing.assert_array_equal(expected, randomized)
+        return
+
+    def test_derandomize_wells(self) -> None:
+        A = (6, 8)
+        S = 13
+        randomizer = WellRandomizer(A, S)
+        original = ["A01", "F02", "A05", "A07", "B07", "D06"]
+        expected = ["A01", "A02", "A03", "A04", "A05", "A06"]
+        derandomized = randomizer.derandomize_wells(original)
+        np.testing.assert_array_equal(expected, derandomized)
+        return
+
+    def test_derandomize_wells_bug_29(self) -> None:
+        A = (6, 8)
+        S = 13
+        randomizer = WellRandomizer(A, S)
+        original = ["A01", "F02", "A05", "A07", "B07", "D06"][::-1]
+        expected = ["A01", "A02", "A03", "A04", "A05", "A06"][::-1]
+        derandomized = randomizer.derandomize_wells(original)
+        np.testing.assert_array_equal(expected, derandomized)
+        return
+
+    def test_randomize_wells_in_row(self) -> None:
+        A = (6, 8)
+        S = 13
+        randomizer = WellRandomizer(A, S, mode="row")
+        original = ["A01", "A02", "A03", "B01", "C02", "B04"]
+        expected = ["A02", "A05", "A04", "B08", "C05", "B06"]
+        randomized = randomizer.randomize_wells(original)
+        np.testing.assert_array_equal(expected, randomized)
+        return
+
+    def test_derandomize_wells_in_row(self) -> None:
+        A = (6, 8)
+        S = 13
+        randomizer = WellRandomizer(A, S, mode="row")
+        original = ["A02", "A05", "A04", "B08", "C05", "B06"]
+        expected = ["A01", "A02", "A03", "B01", "C02", "B04"]
+        randomized = randomizer.derandomize_wells(original)
+        np.testing.assert_array_equal(expected, randomized)
+        return
+
+    def test_randomize_wells_in_column(self) -> None:
+        A = (6, 8)
+        S = 13
+        randomizer = WellRandomizer(A, S, mode="column")
+        original = ["A01", "A02", "A03", "B01", "C02", "B04"]
+        expected = ["B01", "D02", "B03", "D01", "A02", "E04"]
+        randomized = randomizer.randomize_wells(original)
+        np.testing.assert_array_equal(expected, randomized)
+        return
+
+    def test_derandomize_wells_in_column(self) -> None:
+        A = (6, 8)
+        S = 13
+        randomizer = WellRandomizer(A, S, mode="column")
+        original = ["B01", "D02", "B03", "D01", "A02", "E04"]
+        expected = ["A01", "A02", "A03", "B01", "C02", "B04"]
+        randomized = randomizer.derandomize_wells(original)
+        np.testing.assert_array_equal(expected, randomized)
+        return
diff --git a/robotools/test_utils.py b/robotools/test_utils.py
new file mode 100644
index 0000000..fa70549
--- /dev/null
+++ b/robotools/test_utils.py
@@ -0,0 +1,238 @@
+import numpy as np
+import pytest
+
+from robotools.evotools import Worklist
+from robotools.liquidhandling import Labware, Trough
+from robotools.utils import DilutionPlan, get_trough_wells
+
+
+class TestDilutionPlan:
+    def test_argchecking(self) -> None:
+        with pytest.raises(ValueError):
+            DilutionPlan(xmin=0.001, xmax=30, R=8, C=12, stock=20, mode="log", vmax=1000, min_transfer=20)
+
+        with pytest.raises(ValueError):
+            DilutionPlan(xmin=0.001, xmax=30, R=8, C=12, stock=30, mode="invalid", vmax=1000, min_transfer=20)
+
+        with pytest.raises(ValueError):
+            DilutionPlan(xmin=0.001, xmax=30, R=6, C=4, stock=30, mode="linear", vmax=1000, min_transfer=20)
+
+        with pytest.raises(ValueError):
+            DilutionPlan(
+                xmin=0.001,
+                xmax=30,
+                R=6,
+                C=4,
+                stock=30,
+                mode="linear",
+                vmax=[1000, 1000, 1000],
+                min_transfer=20,
+            )
+
+        return
+
+    def test_repr(self) -> None:
+        plan = DilutionPlan(xmin=0.001, xmax=30, R=8, C=12, stock=30, mode="log", vmax=1000, min_transfer=20)
+
+        out = plan.__repr__()
+
+        assert out is not None
+        assert isinstance(out, str)
+        return
+
+    def test_linear_plan(self) -> None:
+        plan = DilutionPlan(xmin=1, xmax=10, R=10, C=1, stock=20, mode="linear", vmax=1000, min_transfer=20)
+
+        np.testing.assert_array_equal(plan.x, plan.ideal_x)
+        assert plan.max_steps == 0
+        assert plan.v_stock == 2750
+        assert plan.instructions[0][0] == 0
+        assert plan.instructions[0][1] == 0
+        assert plan.instructions[0][2] == "stock"
+        np.testing.assert_array_equal(
+            plan.instructions[0][3],
+            [
+                500,
+                450,
+                400,
+                350,
+                300,
+                250,
+                200,
+                150,
+                100,
+                50,
+            ],
+        )
+        return
+
+    def test_log_plan(self) -> None:
+        plan = DilutionPlan(xmin=0.01, xmax=10, R=4, C=3, stock=20, mode="log", vmax=1000, min_transfer=20)
+
+        assert np.allclose(plan.x, plan.ideal_x, rtol=0.05)
+        assert plan.max_steps == 2
+        assert plan.v_stock == 985
+        assert plan.instructions[0][0] == 0
+        assert plan.instructions[0][1] == 0
+        assert plan.instructions[0][2] == "stock"
+        np.testing.assert_array_equal(plan.instructions[0][3], [500, 267, 142, 76])
+        np.testing.assert_array_equal(plan.instructions[1][3], [82, 82, 82, 82])
+        np.testing.assert_array_equal(plan.instructions[2][3], [81, 81, 81, 81])
+        return
+
+    def test_vector_vmax(self) -> None:
+        plan = DilutionPlan(
+            xmin=0.01, xmax=10, R=4, C=3, stock=20, mode="log", vmax=[1000, 500, 1500], min_transfer=20
+        )
+
+        assert np.allclose(plan.x, plan.ideal_x, rtol=0.05)
+        assert plan.max_steps == 2
+        assert plan.v_stock == 985
+        assert plan.instructions[0][0] == 0
+        assert plan.instructions[0][1] == 0
+        assert plan.instructions[0][2] == "stock"
+        np.testing.assert_array_equal(plan.instructions[0][3], [500, 267, 142, 76])
+        np.testing.assert_array_equal(plan.instructions[1][3], [41, 41, 41, 41])
+        np.testing.assert_array_equal(plan.instructions[2][3], [121, 121, 121, 121])
+        return
+
+    def test_to_worklist(self) -> None:
+        # this test case tries to make it as hard as possible for the `to_worklist` method:
+        # + vmax is different in every column
+        # + stock has 2 rows, but dilution plan has 3
+        # + diluent has 4 rows but dilution plan has 3
+        # + diluent is not in the first column of a multi-column trough
+        # + dilution plate is bigger than the plan
+        # + destination plate is bigger than the plan
+        stock_concentration = 20
+        plan = DilutionPlan(
+            xmin=0.01,
+            xmax=10,
+            R=3,
+            C=4,
+            stock=stock_concentration,
+            mode="log",
+            vmax=[1000, 1900, 980, 500],
+            min_transfer=50,
+        )
+        stock = Trough("Stock", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
+        diluent = Trough("Diluent", 4, 2, min_volume=0, max_volume=20_000, initial_volumes=[0, 20_000])
+        dilution = Labware("Dilution", 6, 8, min_volume=0, max_volume=2000)
+        destination = Labware("Destination", 7, 10, min_volume=0, max_volume=1000)
+        with Worklist() as wl:
+            plan.to_worklist(
+                worklist=wl,
+                stock=stock,
+                stock_column=0,
+                diluent=diluent,
+                diluent_column=1,
+                dilution_plate=dilution,
+                destination_plate=destination,
+                v_destination=200,
+                mix_volume=0.75,
+            )
+        # assert the achieved concentrations in the destination
+        np.testing.assert_array_almost_equal(
+            plan.x, destination.composition["Stock"][: plan.R, : plan.C] * stock_concentration
+        )
+        assert "Mix column 0 with 75 % of its volume" in dilution.report
+        assert "Mix column 1 with 50 % of its volume" in dilution.report
+        return
+
+    def test_to_worklist_hooks(self) -> None:
+        stock_concentration = 123
+        plan = DilutionPlan(
+            xmin=1,
+            xmax=123,
+            R=3,
+            C=4,
+            stock=stock_concentration,
+            mode="log",
+            vmax=1000,
+            min_transfer=50,
+        )
+        stock = Trough("Stock", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
+        diluent = Trough("Diluent", 4, 2, min_volume=0, max_volume=10000, initial_volumes=[0, 10000])
+        dilution = Labware("Dilution", 3, 4, min_volume=0, max_volume=2000)
+
+        # Multiple destinations; transferred to via a hook
+        destinations = [
+            Labware("DestinationOne", 3, 2, min_volume=0, max_volume=1000),
+            Labware("DestinationTwo", 3, 2, min_volume=0, max_volume=1000),
+        ]
+
+        # Split the work across two worklists (also via the hook)
+        wl_one = Worklist()
+        wl_two = Worklist()
+
+        def pre_mix(col, wl):
+            wl.comment(f"Pre-mix on column {col}")
+            if col == 1:
+                return wl_two
+
+        def post_mix(col, wl):
+            wl.comment(f"Post-mix on column {col}")
+            if col == 1:
+                wl.transfer(
+                    dilution,
+                    dilution.wells[:, 0:2],
+                    destinations[0],
+                    destinations[0].wells[:, :],
+                    volumes=100,
+                )
+            elif col == 3:
+                wl.transfer(
+                    dilution,
+                    dilution.wells[:, 2:4],
+                    destinations[1],
+                    destinations[1].wells[:, :],
+                    volumes=100,
+                )
+
+        plan.to_worklist(
+            worklist=wl_one,
+            stock=stock,
+            stock_column=0,
+            diluent=diluent,
+            diluent_column=1,
+            dilution_plate=dilution,
+            pre_mix_hook=pre_mix,
+            post_mix_hook=post_mix,
+        )
+
+        assert len(wl_two) > 0
+        assert "C;Pre-mix on column 0" in wl_one
+        assert "C;Pre-mix on column 1" in wl_one
+        assert "C;Pre-mix on column 2" in wl_two
+        assert "C;Pre-mix on column 3" in wl_two
+
+        assert "C;Post-mix on column 0" in wl_one
+        assert "C;Post-mix on column 1" in wl_two  # worklist switched in the mix hook!
+        assert "C;Post-mix on column 2" in wl_two
+        assert "C;Post-mix on column 3" in wl_two
+
+        np.testing.assert_almost_equal(
+            destinations[0].composition["Stock"] * stock_concentration, plan.x[:, [0, 1]]
+        )
+        np.testing.assert_almost_equal(
+            destinations[1].composition["Stock"] * stock_concentration, plan.x[:, [2, 3]]
+        )
+        return
+
+
+class TestUtils:
+    def test_get_trough_wells(self) -> None:
+        with pytest.raises(ValueError):
+            get_trough_wells(n=-1, trough_wells=list("ABC"))
+        with pytest.raises(ValueError):
+            get_trough_wells(n=3, trough_wells=[])
+        with pytest.raises(TypeError):
+            get_trough_wells(n=0.5, trough_wells=list("ABC"))
+        with pytest.raises(TypeError):
+            get_trough_wells(n=2, trough_wells="ABC")
+        np.testing.assert_array_equal(get_trough_wells(n=0, trough_wells=list("ABC")), list())
+        np.testing.assert_array_equal(get_trough_wells(n=1, trough_wells=list("ABC")), list("A"))
+        np.testing.assert_array_equal(get_trough_wells(n=3, trough_wells=list("ABC")), list("ABC"))
+        np.testing.assert_array_equal(get_trough_wells(n=4, trough_wells=list("ABC")), list("ABCA"))
+        np.testing.assert_array_equal(get_trough_wells(n=7, trough_wells=list("ABC")), list("ABCABCA"))
+        return
diff --git a/robotools/tests.py b/robotools/tests.py
deleted file mode 100644
index f5dc7dd..0000000
--- a/robotools/tests.py
+++ /dev/null
@@ -1,3501 +0,0 @@
-import logging
-import os
-import tempfile
-import unittest
-
-import numpy
-import pytest
-
-import robotools
-from robotools import evotools, liquidhandling, transform
-
-
-class TestStandardLabware(unittest.TestCase):
-    def test_init(self) -> None:
-        plate = liquidhandling.Labware("TestPlate", 2, 3, min_volume=50, max_volume=250, initial_volumes=30)
-        self.assertEqual(plate.name, "TestPlate")
-        self.assertFalse(plate.is_trough)
-        self.assertEqual(plate.row_ids, tuple("AB"))
-        self.assertEqual(plate.column_ids, [1, 2, 3])
-        self.assertEqual(plate.n_rows, 2)
-        self.assertEqual(plate.n_columns, 3)
-        self.assertEqual(plate.min_volume, 50)
-        self.assertEqual(plate.max_volume, 250)
-        self.assertEqual(len(plate.history), 1)
-        numpy.testing.assert_array_equal(plate.volumes, numpy.array([[30, 30, 30], [30, 30, 30]]))
-        self.assertDictEqual(
-            plate.indices,
-            {
-                "A01": (0, 0),
-                "A02": (0, 1),
-                "A03": (0, 2),
-                "B01": (1, 0),
-                "B02": (1, 1),
-                "B03": (1, 2),
-            },
-        )
-        self.assertDictEqual(
-            plate.positions,
-            {
-                "A01": 1,
-                "A02": 3,
-                "A03": 5,
-                "B01": 2,
-                "B02": 4,
-                "B03": 6,
-            },
-        )
-        return
-
-    def test_invalid_init(self) -> None:
-        with self.assertRaises(ValueError):
-            liquidhandling.Labware("A", 0, 3, min_volume=10, max_volume=250)
-        with self.assertRaises(ValueError):
-            liquidhandling.Labware("A", 3, 0, min_volume=10, max_volume=250)
-        with self.assertRaises(ValueError):
-            liquidhandling.Labware("A", 3, 4, min_volume=10, max_volume=250, virtual_rows=2)
-        with self.assertRaises(ValueError):
-            liquidhandling.Labware("A", 1, 4, min_volume=10, max_volume=250, virtual_rows=0)
-        return
-
-    def test_volume_limits(self) -> None:
-        with self.assertRaises(ValueError):
-            liquidhandling.Labware("A", 3, 4, min_volume=-30, max_volume=100)
-        with self.assertRaises(ValueError):
-            liquidhandling.Labware("A", 3, 4, min_volume=100, max_volume=70)
-        with self.assertRaises(ValueError):
-            liquidhandling.Labware("A", 3, 4, min_volume=10, max_volume=70, initial_volumes=100)
-        with self.assertRaises(ValueError):
-            liquidhandling.Labware("A", 3, 4, min_volume=10, max_volume=70, initial_volumes=-10)
-        liquidhandling.Labware("A", 3, 4, min_volume=10, max_volume=70, initial_volumes=50)
-        return
-
-    def test_initial_volumes(self) -> None:
-        plate = liquidhandling.Labware(
-            "TestPlate", 1, 3, min_volume=50, max_volume=250, initial_volumes=[20, 30, 40]
-        )
-        numpy.testing.assert_array_equal(
-            plate.volumes,
-            numpy.array(
-                [
-                    [20, 30, 40],
-                ]
-            ),
-        )
-        return
-
-    def test_logging(self) -> None:
-        plate = liquidhandling.Labware("TestPlate", 2, 3, min_volume=50, max_volume=250)
-        plate.add(plate.wells, 25)
-        plate.add(plate.wells, 25)
-        plate.add(plate.wells, 25)
-        plate.add(plate.wells, 25)
-        self.assertEqual(len(plate.history), 5)
-        return
-
-    def test_log_condensation_first(self) -> None:
-        plate = liquidhandling.Labware("TestPlate", 2, 3, min_volume=50, max_volume=250)
-        plate.add(plate.wells, 25, label="A")
-        plate.add(plate.wells, 25, label="B")
-        plate.add(plate.wells, 25, label="C")
-        plate.add(plate.wells, 25, label="D")
-        self.assertEqual(len(plate.history), 5)
-
-        # condense the last two as 'D'
-        plate.condense_log(2, label="last")
-        self.assertEqual(len(plate.history), 4)
-        self.assertEqual(plate.history[-1][0], "D")
-        numpy.testing.assert_array_equal(
-            plate.history[-1][1],
-            numpy.array(
-                [
-                    [100, 100, 100],
-                    [100, 100, 100],
-                ]
-            ),
-        )
-
-        # condense the last three as 'A'
-        plate.condense_log(3, label="first")
-        self.assertEqual(len(plate.history), 2)
-        self.assertEqual(plate.history[-1][0], "A")
-        numpy.testing.assert_array_equal(
-            plate.history[-1][1],
-            numpy.array(
-                [
-                    [100, 100, 100],
-                    [100, 100, 100],
-                ]
-            ),
-        )
-
-        # condense the remaining two as 'prepared'
-        plate.condense_log(3, label="prepared")
-        self.assertEqual(len(plate.history), 1)
-        self.assertEqual(plate.history[-1][0], "prepared")
-        numpy.testing.assert_array_equal(
-            plate.history[-1][1],
-            numpy.array(
-                [
-                    [100, 100, 100],
-                    [100, 100, 100],
-                ]
-            ),
-        )
-        return
-
-    def test_add_valid(self) -> None:
-        plate = liquidhandling.Labware("TestPlate", 4, 6, min_volume=100, max_volume=250)
-        wells = ["A01", "A02", "B04"]
-        plate.add(wells, 150)
-        plate.add(wells, 3.5)
-        self.assertEqual(len(plate.history), 3)
-        for well in wells:
-            assert plate.volumes[plate.indices[well]] == 153.5
-        return
-
-    def test_add_too_much(self) -> None:
-        plate = liquidhandling.Labware("TestPlate", 4, 6, min_volume=100, max_volume=250)
-        wells = ["A01", "A02", "B04"]
-        with self.assertRaises(liquidhandling.VolumeOverflowError):
-            plate.add(wells, 500)
-        return
-
-    def test_remove_valid(self) -> None:
-        plate = liquidhandling.Labware("TestPlate", 2, 3, min_volume=50, max_volume=250, initial_volumes=200)
-        wells = ["A01", "A02", "B03"]
-        plate.remove(wells, 50)
-        self.assertEqual(len(plate.history), 2)
-        numpy.testing.assert_array_equal(plate.volumes, numpy.array([[150, 150, 200], [200, 200, 150]]))
-        return
-
-    def test_remove_too_much(self) -> None:
-        plate = liquidhandling.Labware("TestPlate", 4, 6, min_volume=100, max_volume=250)
-        wells = ["A01", "A02", "B04"]
-        with self.assertRaises(liquidhandling.VolumeUnderflowError):
-            plate.remove(wells, 500)
-        self.assertEqual(len(plate.history), 1)
-        return
-
-
-class TestTroughLabware(unittest.TestCase):
-    def test_warns_on_api(self) -> None:
-        with pytest.warns(UserWarning, match="Troughs should be created with"):
-            robotools.Labware("test", rows=1, columns=2, min_volume=100, max_volume=3000, virtual_rows=4)
-
-        with pytest.warns(None) as record:
-            robotools.Trough("test", virtual_rows=6, columns=2, min_volume=100, max_volume=3000)
-        assert len(record) == 0
-        return
-
-    def test_init_trough(self) -> None:
-        trough = liquidhandling.Trough(
-            "TestTrough", 5, 4, min_volume=1000, max_volume=50 * 1000, initial_volumes=30 * 1000
-        )
-        self.assertEqual(trough.name, "TestTrough")
-        self.assertTrue(trough.is_trough)
-        self.assertEqual(trough.row_ids, tuple("ABCDE"))
-        self.assertEqual(trough.column_ids, [1, 2, 3, 4])
-        self.assertEqual(trough.min_volume, 1000)
-        self.assertEqual(trough.max_volume, 50 * 1000)
-        self.assertEqual(len(trough.history), 1)
-        numpy.testing.assert_array_equal(
-            trough.volumes, numpy.array([[30 * 1000, 30 * 1000, 30 * 1000, 30 * 1000]])
-        )
-        self.assertDictEqual(
-            trough.indices,
-            {
-                "A01": (0, 0),
-                "A02": (0, 1),
-                "A03": (0, 2),
-                "A04": (0, 3),
-                "B01": (0, 0),
-                "B02": (0, 1),
-                "B03": (0, 2),
-                "B04": (0, 3),
-                "C01": (0, 0),
-                "C02": (0, 1),
-                "C03": (0, 2),
-                "C04": (0, 3),
-                "D01": (0, 0),
-                "D02": (0, 1),
-                "D03": (0, 2),
-                "D04": (0, 3),
-                "E01": (0, 0),
-                "E02": (0, 1),
-                "E03": (0, 2),
-                "E04": (0, 3),
-            },
-        )
-        self.assertDictEqual(
-            trough.positions,
-            {
-                "A01": 1,
-                "A02": 6,
-                "A03": 11,
-                "A04": 16,
-                "B01": 2,
-                "B02": 7,
-                "B03": 12,
-                "B04": 17,
-                "C01": 3,
-                "C02": 8,
-                "C03": 13,
-                "C04": 18,
-                "D01": 4,
-                "D02": 9,
-                "D03": 14,
-                "D04": 19,
-                "E01": 5,
-                "E02": 10,
-                "E03": 15,
-                "E04": 20,
-            },
-        )
-        return
-
-    def test_initial_volumes(self) -> None:
-        trough = liquidhandling.Trough(
-            "TestTrough",
-            5,
-            4,
-            min_volume=1000,
-            max_volume=50 * 1000,
-            initial_volumes=[30 * 1000, 20 * 1000, 20 * 1000, 20 * 1000],
-        )
-        numpy.testing.assert_array_equal(
-            trough.volumes,
-            numpy.array(
-                [
-                    [30 * 1000, 20 * 1000, 20 * 1000, 20 * 1000],
-                ]
-            ),
-        )
-        return
-
-    def test_trough_add_valid(self) -> None:
-        trough = liquidhandling.Trough("TestTrough", 3, 4, min_volume=100, max_volume=250)
-        # adding into the first column (which is actually one well)
-        trough.add(["A01", "B01"], 50)
-        numpy.testing.assert_array_equal(trough.volumes, numpy.array([[100, 0, 0, 0]]))
-        # adding to the last row (separate wells)
-        trough.add(["C01", "C02", "C03"], 50)
-        numpy.testing.assert_array_equal(trough.volumes, numpy.array([[150, 50, 50, 0]]))
-        self.assertEqual(len(trough.history), 3)
-        return
-
-    def test_trough_add_too_much(self) -> None:
-        trough = liquidhandling.Trough("TestTrough", 3, 4, min_volume=100, max_volume=1000)
-        # adding into the first column (which is actually one well)
-        with self.assertRaises(liquidhandling.VolumeOverflowError):
-            trough.add(["A01", "B01"], 600)
-        return
-
-    def test_trough_remove_valid(self) -> None:
-        trough = liquidhandling.Trough(
-            "TestTrough", 3, 4, min_volume=1000, max_volume=30000, initial_volumes=3000
-        )
-        # adding into the first column (which is actually one well)
-        trough.remove(["A01", "B01"], 50)
-        numpy.testing.assert_array_equal(trough.volumes, numpy.array([[2900, 3000, 3000, 3000]]))
-        # adding to the last row (separate wells)
-        trough.remove(["C01", "C02", "C03"], 50)
-        numpy.testing.assert_array_equal(trough.volumes, numpy.array([[2850, 2950, 2950, 3000]]))
-        self.assertEqual(len(trough.history), 3)
-        return
-
-    def test_trough_remove_too_much(self) -> None:
-        trough = liquidhandling.Trough(
-            "TestTrough", 3, 4, min_volume=1000, max_volume=30 * 1000, initial_volumes=3000
-        )
-        # adding into the first column (which is actually one well)
-        with self.assertRaises(liquidhandling.VolumeUnderflowError):
-            trough.remove(["A01", "B01"], 2000)
-        return
-
-
-class TestWorklist(unittest.TestCase):
-    def test_context(self) -> None:
-        with evotools.Worklist() as worklist:
-            self.assertIsNotNone(worklist)
-        return
-
-    def test_parameter_validation(self) -> None:
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(rack_label=None, position=1, volume=15)
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(rack_label=15, position=1, volume=15)
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="thisisaveryverylongracklabelthatexceedsthemaximumlength", position=1, volume=15
-            )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="rack label; with semicolon", position=1, volume=15
-            )
-        evotools._prepare_aspirate_dispense_parameters(rack_label="valid rack label", position=1, volume=15)
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=None, volume=15)
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position="3", volume=15)
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=-1, volume=15)
-        evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=15)
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=None)
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume="nan")
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=float("nan")
-            )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=-15.4)
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume="bla")
-        evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume="15")
-        evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=20)
-        evotools._prepare_aspirate_dispense_parameters(rack_label="WaterTrough", position=1, volume=23.78)
-        evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=numpy.array(23.4)
-        )
-
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, liquid_class=None
-            )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, liquid_class="liquid;class"
-            )
-        evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, liquid_class="valid liquid class"
-        )
-
-        _, _, _, _, tip, _, _, _, _ = evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, tip=4
-        )
-        assert tip == 8
-        _, _, _, _, tip, _, _, _, _ = evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, tip=evotools.Tip.T5
-        )
-        assert tip == 16
-        _, _, _, _, tip, _, _, _, _ = evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, tip=(evotools.Tip.T4, 4)
-        )
-        assert tip == 8
-        _, _, _, _, tip, _, _, _, _ = evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, tip=[evotools.Tip.T1, 4]
-        )
-        assert tip == 9
-        _, _, _, _, tip, _, _, _, _ = evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, tip=[1, 4]
-        )
-        assert tip == 9
-        _, _, _, _, tip, _, _, _, _ = evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, tip=[1, evotools.Tip.T4]
-        )
-        assert tip == 9
-        _, _, _, _, tip, _, _, _, _ = evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, tip=evotools.Tip.Any
-        )
-        assert tip == ""
-
-        with pytest.raises(ValueError, match="no Tip.Any elements are allowed"):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, tip=(evotools.Tip.T1, evotools.Tip.Any)
-            )
-        with pytest.raises(ValueError, match="tip must be an int between 1 and 8, Tip or Iterable"):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, tip=None
-            )
-        with pytest.raises(ValueError, match="it may only contain int or Tip values"):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, tip=[1, 2.6]
-            )
-        with pytest.raises(ValueError, match="should be an int between 1 and 8 for _int_to_tip"):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, tip=12
-            )
-
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, rack_id=None
-            )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, rack_id="invalid;rack"
-            )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough",
-                position=1,
-                volume=15,
-                rack_id="thisisaveryverylongrackthatexceedsthemaximumlength",
-            )
-        evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, rack_id="1235464"
-        )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, rack_type=None
-            )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, rack_type="invalid;rack type"
-            )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough",
-                position=1,
-                volume=15,
-                rack_type="thisisaveryverylongracktypethatexceedsthemaximumlength",
-            )
-        evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, rack_type="valid rack type"
-        )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, forced_rack_type=None
-            )
-        with self.assertRaises(ValueError):
-            evotools._prepare_aspirate_dispense_parameters(
-                rack_label="WaterTrough", position=1, volume=15, forced_rack_type="invalid;forced rack type"
-            )
-        evotools._prepare_aspirate_dispense_parameters(
-            rack_label="WaterTrough", position=1, volume=15, forced_rack_type="valid forced rack type"
-        )
-
-        # test _prepare_evo_aspirate_dispense_parameters
-        # define a labware correctly for testing purposes
-        plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
-        # test labware argument checks
-        with pytest.raises(ValueError, match="Invalid labware:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware="wrong_labware_type",
-                wells=["A01", "B01"],
-                labware_position=(38, 2),
-                volume=15,
-                liquid_class="Water_DispZmax-1_AspZmax-1",
-                tips=[1, 2],
-            )
-        # test wells argument checks
-        with pytest.raises(ValueError, match="Invalid wells:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware=plate,
-                wells="A01",
-                labware_position=(38, 2),
-                volume=15,
-                liquid_class="Water_DispZmax-1_AspZmax-1",
-                tips=[1, 2],
-            )
-        # test labware_position argument checks
-        with pytest.raises(ValueError, match="Invalid position:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware=plate,
-                wells=["A01", "B01"],
-                labware_position=(38, -1),
-                volume=15,
-                liquid_class="Water_DispZmax-1_AspZmax-1",
-                tips=[1, 2],
-            )
-        with pytest.raises(ValueError, match="Invalid position:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware=plate,
-                wells=["A01", "B01"],
-                labware_position=("a", 2),
-                volume=15,
-                liquid_class="Water_DispZmax-1_AspZmax-1",
-                tips=[1, 2],
-            )
-        # test liquid_class argument checks
-        with pytest.raises(ValueError, match="Invalid liquid_class:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware=plate,
-                wells=["A01", "B01"],
-                labware_position=(38, 2),
-                volume=15,
-                liquid_class=["Water_DispZmax-1_AspZmax-1"],
-                tips=[1, 2],
-            )
-        with pytest.raises(ValueError, match="Invalid liquid_class:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware=plate,
-                wells=["A01", "B01"],
-                labware_position=(38, 2),
-                volume=15,
-                liquid_class="Water;DispZmax-1;AspZmax-1",
-                tips=[1, 2],
-            )
-        # test tips argument checks
-        with pytest.raises(ValueError, match="Invalid type of tips:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware=plate,
-                wells=["A01", "B01"],
-                labware_position=(38, 2),
-                volume=15,
-                liquid_class="Water_DispZmax-1_AspZmax-1",
-                tips=[1, "2"],
-            )
-        _, _, _, _, _, tips = evotools._prepare_evo_aspirate_dispense_parameters(
-            labware=plate,
-            wells=["A01", "B01"],
-            labware_position=(38, 2),
-            volume=15,
-            liquid_class="Water_DispZmax-1_AspZmax-1",
-            tips=[1, 2],
-        )
-        if not all(isinstance(n, evotools.Tip) for n in tips):
-            raise TypeError(
-                f"Even after completing the _prepare_evo_aspirate_dispense_parameters method, not all tips are type Tip."
-            )
-        # test volume argument checks
-        with pytest.raises(ValueError, match="Invalid volume:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware=plate,
-                wells=["A01", "B01"],
-                labware_position=(38, 2),
-                volume="volume",
-                liquid_class="Water_DispZmax-1_AspZmax-1",
-                tips=[1, 2],
-            )
-        with pytest.raises(ValueError, match="Invalid volume:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware=plate,
-                wells=["A01", "B01"],
-                labware_position=(38, 2),
-                volume=-10,
-                liquid_class="Water_DispZmax-1_AspZmax-1",
-                tips=[1, 2],
-            )
-        with pytest.raises(ValueError, match="Invalid volume:"):
-            evotools._prepare_evo_aspirate_dispense_parameters(
-                labware=plate,
-                wells=["A01", "B01"],
-                labware_position=(38, 2),
-                volume=7158279,
-                liquid_class="Water_DispZmax-1_AspZmax-1",
-                tips=[1, 2],
-            )
-
-        # test complete _prepare_evo_aspirate_dispense_parameters() command
-        (
-            labware,
-            wells,
-            labware_position,
-            volume,
-            liquid_class,
-            tips,
-        ) = evotools._prepare_evo_aspirate_dispense_parameters(
-            labware=plate,
-            wells=["E01", "F01", "G01"],
-            labware_position=(38, 2),
-            volume=750,
-            liquid_class="Water_DispZmax_AspZmax",
-            tips=[5, 6, 7],
-        )
-        self.assertEqual(
-            [labware, wells, labware_position, volume, liquid_class, tips],
-            [
-                plate,
-                ["E01", "F01", "G01"],
-                (38, 2),
-                [750.0, 750.0, 750.0],
-                "Water_DispZmax_AspZmax",
-                [evotools.Tip.T5, evotools.Tip.T6, evotools.Tip.T7],
-            ],
-        )
-
-        # test _prepare_evo_wash_parameters
-        # test tips argument checks
-        tips, _, _, _, _, _, _, _, _, _, _, _, _ = evotools._prepare_evo_wash_parameters(
-            tips=[1, 2],
-            waste_location=(52, 1),
-            cleaner_location=(52, 0),
-        )
-        if not all(isinstance(n, evotools.Tip) for n in tips):
-            raise TypeError(
-                f"Even after completing the _prepare_evo_aspirate_dispense_parameters method, not all tips are type Tip."
-            )
-
-        # test waste_location argument checks
-        with pytest.raises(ValueError, match="Grid \\(first number in waste_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(68, 1),
-                cleaner_location=(52, 0),
-            )
-        with pytest.raises(ValueError, match="Grid \\(first number in waste_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(0, 1),
-                cleaner_location=(52, 0),
-            )
-        with pytest.raises(ValueError, match="Grid \\(first number in waste_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(1.7, 1),
-                cleaner_location=(52, 0),
-            )
-        with pytest.raises(ValueError, match="Site \\(second number in waste_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, -1),
-                cleaner_location=(52, 0),
-            )
-        with pytest.raises(ValueError, match="Site \\(second number in waste_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 128),
-                cleaner_location=(52, 0),
-            )
-        with pytest.raises(ValueError, match="Site \\(second number in waste_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1.7),
-                cleaner_location=(52, 0),
-            )
-
-        # test cleaner_location argument checks
-        with pytest.raises(ValueError, match="Grid \\(first number in cleaner_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(68, 1),
-            )
-        with pytest.raises(ValueError, match="Grid \\(first number in cleaner_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(0, 1),
-            )
-        with pytest.raises(ValueError, match="Grid \\(first number in cleaner_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(1.7, 1),
-            )
-        with pytest.raises(ValueError, match="Site \\(second number in cleaner_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, -1),
-            )
-        with pytest.raises(ValueError, match="Site \\(second number in cleaner_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 128),
-            )
-        with pytest.raises(ValueError, match="Site \\(second number in cleaner_location tuple\\)"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 1.7),
-            )
-
-        # test arm argument check
-        with pytest.raises(ValueError, match="Parameter arm"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                arm=2,
-            )
-
-        # test waste_vol argument check
-        with pytest.raises(ValueError, match="waste_vol has to be a float"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                waste_vol=-1.0,
-            )
-        with pytest.raises(ValueError, match="waste_vol has to be a float"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                waste_vol=101.0,
-            )
-        with pytest.raises(ValueError, match="waste_vol has to be a float"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                waste_vol=1,
-            )
-
-        # test waste_delay argument check
-        with pytest.raises(ValueError, match="waste_delay has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                waste_delay=-1,
-            )
-        with pytest.raises(ValueError, match="waste_delay has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                waste_delay=1001,
-            )
-        with pytest.raises(ValueError, match="waste_delay has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                waste_delay=10.0,
-            )
-
-        # test cleaner_vol argument check
-        with pytest.raises(ValueError, match="cleaner_vol has to be a float"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                cleaner_vol=-1.0,
-            )
-        with pytest.raises(ValueError, match="cleaner_vol has to be a float"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                cleaner_vol=101.0,
-            )
-        with pytest.raises(ValueError, match="cleaner_vol has to be a float"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                cleaner_vol=1,
-            )
-
-        # test cleaner_delay argument check
-        with pytest.raises(ValueError, match="cleaner_delay has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                cleaner_delay=-1,
-            )
-        with pytest.raises(ValueError, match="cleaner_delay has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                cleaner_delay=1001,
-            )
-        with pytest.raises(ValueError, match="cleaner_delay has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                cleaner_delay=10.0,
-            )
-
-        # test airgap argument check
-        with pytest.raises(ValueError, match="airgap has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                airgap=-1,
-            )
-        with pytest.raises(ValueError, match="airgap has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                airgap=101,
-            )
-        with pytest.raises(ValueError, match="airgap has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                airgap=10.0,
-            )
-
-        # test airgap_speed argument check
-        with pytest.raises(ValueError, match="airgap_speed has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                airgap_speed=0,
-            )
-        with pytest.raises(ValueError, match="airgap_speed has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                airgap_speed=1001,
-            )
-        with pytest.raises(ValueError, match="airgap_speed has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                airgap_speed=10.0,
-            )
-
-        # test retract_speed argument check
-        with pytest.raises(ValueError, match="retract_speed has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                retract_speed=0,
-            )
-        with pytest.raises(ValueError, match="retract_speed has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                retract_speed=101,
-            )
-        with pytest.raises(ValueError, match="retract_speed has to be an int"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                retract_speed=10.0,
-            )
-
-        # test fastwash argument check
-        with pytest.raises(ValueError, match="Parameter fastwash"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                fastwash=2,
-            )
-        with pytest.raises(ValueError, match="Parameter fastwash"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                fastwash=1.0,
-            )
-
-        # test low_volume argument check
-        with pytest.raises(ValueError, match="Parameter low_volume"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                low_volume=2,
-            )
-        with pytest.raises(ValueError, match="Parameter low_volume"):
-            evotools._prepare_evo_wash_parameters(
-                tips=[1, 2],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-                low_volume=1.0,
-            )
-
-        # test complete _prepare_evo_wash_parameters() command
-        (
-            tips,
-            waste_location,
-            cleaner_location,
-            arm,
-            waste_vol,
-            waste_delay,
-            cleaner_vol,
-            cleaner_delay,
-            airgap,
-            airgap_speed,
-            retract_speed,
-            fastwash,
-            low_volume,
-        ) = evotools._prepare_evo_wash_parameters(
-            tips=[1, 2, 3, 4, 5, 6, 7, 8],
-            waste_location=(52, 1),
-            cleaner_location=(52, 0),
-        )
-        self.assertEqual(
-            [
-                tips,
-                waste_location,
-                cleaner_location,
-                arm,
-                waste_vol,
-                waste_delay,
-                cleaner_vol,
-                cleaner_delay,
-                airgap,
-                airgap_speed,
-                retract_speed,
-                fastwash,
-                low_volume,
-            ],
-            [
-                [
-                    evotools.Tip.T1,
-                    evotools.Tip.T2,
-                    evotools.Tip.T3,
-                    evotools.Tip.T4,
-                    evotools.Tip.T5,
-                    evotools.Tip.T6,
-                    evotools.Tip.T7,
-                    evotools.Tip.T8,
-                ],
-                (52, 1),
-                (52, 0),
-                0,
-                3.0,
-                500,
-                4.0,
-                500,
-                10,
-                70,
-                30,
-                1,
-                0,
-            ],
-        )
-
-        return
-
-    def test_evo_aspirate1(self) -> None:
-        plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
-        with evotools.Worklist() as wl:
-            wl.evo_aspirate(
-                labware=plate,
-                wells=["E01", "F01", "G01"],
-                labware_position=(38, 2),
-                tips=[5, 6, 7],
-                volumes=750,
-                liquid_class="Water_DispZmax_AspZmax",
-            )
-            self.assertEqual(
-                wl[0],
-                'B;Aspirate(112,"Water_DispZmax_AspZmax",0,0,0,0,"750.0","750.0","750.0",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);',
-            )
-        return
-
-    def test_evo_aspirate2(self) -> None:
-        plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
-        with evotools.Worklist() as wl:
-            wl.evo_aspirate(
-                labware=plate,
-                wells=["E01", "F01", "G01"],
-                labware_position=(38, 2),
-                tips=[5, 6, 7],
-                volumes=[750, 730, 710],
-                liquid_class="Water_DispZmax_AspZmax",
-            )
-            self.assertEqual(
-                wl[0],
-                'B;Aspirate(112,"Water_DispZmax_AspZmax",0,0,0,0,"750","730","710",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);',
-            )
-        return
-
-    def test_evo_dispense1(self) -> None:
-        plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
-        with evotools.Worklist() as wl:
-            wl.evo_dispense(
-                labware=plate,
-                wells=["E01", "F01", "G01"],
-                labware_position=(38, 2),
-                tips=[5, 6, 7],
-                volumes=750,
-                liquid_class="Water_DispZmax_AspZmax",
-            )
-            self.assertEqual(
-                wl[0],
-                'B;Dispense(112,"Water_DispZmax_AspZmax",0,0,0,0,"750.0","750.0","750.0",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);',
-            )
-        return
-
-    def test_evo_dispense2(self) -> None:
-        plate = liquidhandling.Labware("DWP", 8, 12, min_volume=0, max_volume=2000, initial_volumes=1000)
-        with evotools.Worklist() as wl:
-            wl.evo_dispense(
-                labware=plate,
-                wells=["E01", "F01", "G01"],
-                labware_position=(38, 2),
-                tips=[5, 6, 7],
-                volumes=[750, 730, 710],
-                liquid_class="Water_DispZmax_AspZmax",
-            )
-            self.assertEqual(
-                wl[0],
-                'B;Dispense(112,"Water_DispZmax_AspZmax",0,0,0,0,"750","730","710",0,0,0,0,0,38,2,1,"0C08\xa00000000000000",0,0);',
-            )
-        return
-
-    def test_evo_wash(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.evo_wash(
-                tips=[1, 2, 3, 4, 5, 6, 7, 8],
-                waste_location=(52, 1),
-                cleaner_location=(52, 0),
-            )
-            self.assertEqual(wl[0], 'B;Wash(255,52,1,52,0,"3.0",500,"4.0",500,10,70,30,1,0,1000,0);')
-        return
-
-    def test_comment(self) -> None:
-        with evotools.Worklist() as wl:
-            # empty and None comments should be ignored
-            wl.comment("")
-            wl.comment(None)
-            # this will be the first actual comment
-            wl.comment("This is a simple comment")
-            with self.assertRaises(ValueError):
-                wl.comment("It must not contain ; semicolons")
-            wl.comment(
-                """
-            But it may very well be
-            a multiline comment
-            """
-            )
-            self.assertEqual(
-                wl, ["C;This is a simple comment", "C;But it may very well be", "C;a multiline comment"]
-            )
-        return
-
-    def test_wash(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.wash()
-            with self.assertRaises(ValueError):
-                wl.wash(scheme=15)
-            with self.assertRaises(ValueError):
-                wl.wash(scheme="2")
-            wl.wash(scheme=1)
-            wl.wash(scheme=2)
-            wl.wash(scheme=3)
-            wl.wash(scheme=4)
-            self.assertEqual(
-                wl,
-                [
-                    "W1;",
-                    "W1;",
-                    "W2;",
-                    "W3;",
-                    "W4;",
-                ],
-            )
-        return
-
-    def test_decontaminate(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.decontaminate()
-            self.assertEqual(
-                wl,
-                [
-                    "WD;",
-                ],
-            )
-        return
-
-    def test_flush(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.flush()
-            self.assertEqual(
-                wl,
-                [
-                    "F;",
-                ],
-            )
-        return
-
-    def test_commit(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.commit()
-            self.assertEqual(
-                wl,
-                [
-                    "B;",
-                ],
-            )
-        return
-
-    def test_set_diti(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.set_diti(diti_index=1)
-            with self.assertRaises(evotools.InvalidOperationError):
-                wl.set_diti(diti_index=2)
-            wl.commit()
-            wl.set_diti(diti_index=2)
-            self.assertEqual(
-                wl,
-                [
-                    "S;1",
-                    "B;",
-                    "S;2",
-                ],
-            )
-        return
-
-    def test_aspirate_single(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.aspirate_well("WaterTrough", 1, 200)
-            self.assertEqual(wl[-1], "A;WaterTrough;;;1;;200.00;;;;")
-            wl.aspirate_well(
-                "WaterTrough", 1, 200, rack_id="12345", rack_type="my_rack_id", tube_id="my_tube_id"
-            )
-            self.assertEqual(wl[-1], "A;WaterTrough;12345;my_rack_id;1;my_tube_id;200.00;;;;")
-            wl.aspirate_well(
-                "WaterTrough", 1, 200, liquid_class="my_liquid_class", tip=8, forced_rack_type="forced_rack"
-            )
-            self.assertEqual(wl[-1], "A;WaterTrough;;;1;;200.00;my_liquid_class;;128;forced_rack")
-        return
-
-    def test_dispense_single(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.dispense_well("WaterTrough", 1, 200)
-            self.assertEqual(wl[-1], "D;WaterTrough;;;1;;200.00;;;;")
-            wl.dispense_well(
-                "WaterTrough", 1, 200, rack_id="12345", rack_type="my_rack_id", tube_id="my_tube_id"
-            )
-            self.assertEqual(wl[-1], "D;WaterTrough;12345;my_rack_id;1;my_tube_id;200.00;;;;")
-            wl.dispense_well(
-                "WaterTrough", 1, 200, liquid_class="my_liquid_class", tip=8, forced_rack_type="forced_rack"
-            )
-            self.assertEqual(wl[-1], "D;WaterTrough;;;1;;200.00;my_liquid_class;;128;forced_rack")
-        return
-
-    def test_aspirate_systemliquid(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.aspirate_well(evotools.Labwares.SystemLiquid.value, 1, 200)
-            self.assertEqual(wl[-1], "A;Systemliquid;;;1;;200.00;;;;")
-        return
-
-    def test_save(self) -> None:
-        tf = tempfile.mktemp() + ".gwl"
-        error = None
-        try:
-            with evotools.Worklist() as worklist:
-                worklist.flush()
-                worklist.save(tf)
-                self.assertTrue(os.path.exists(tf))
-                # also check that the file can be overwritten if it exists already
-                worklist.save(tf)
-            self.assertTrue(os.path.exists(tf))
-            with open(tf) as file:
-                lines = file.readlines()
-                self.assertEqual(lines, ["F;"])
-        except Exception as ex:
-            error = ex
-        finally:
-            os.remove(tf)
-        self.assertFalse(os.path.exists(tf))
-        if error:
-            raise error
-        return
-
-    def test_autosave(self) -> None:
-        tf = tempfile.mktemp() + ".gwl"
-        error = None
-        try:
-            with evotools.Worklist(tf) as worklist:
-                worklist.flush()
-            self.assertTrue(os.path.exists(tf))
-            with open(tf) as file:
-                lines = file.readlines()
-                self.assertEqual(lines, ["F;"])
-        except Exception as ex:
-            error = ex
-        finally:
-            os.remove(tf)
-        self.assertFalse(os.path.exists(tf))
-        if error:
-            raise error
-        return
-
-
-class TestStandardLabwareWorklist(unittest.TestCase):
-    def test_aspirate(self) -> None:
-        source = liquidhandling.Labware(
-            "SourceLW", rows=3, columns=3, min_volume=10, max_volume=200, initial_volumes=200
-        )
-        with evotools.Worklist() as wl:
-            wl.aspirate(source, ["A01", "A02", "C02"], 50, label=None)
-            wl.aspirate(source, ["A03", "B03", "C03"], [10, 20, 30.5], label="second aspirate")
-            self.assertEqual(
-                wl,
-                [
-                    "A;SourceLW;;;1;;50.00;;;;",
-                    "A;SourceLW;;;4;;50.00;;;;",
-                    "A;SourceLW;;;6;;50.00;;;;",
-                    "C;second aspirate",
-                    "A;SourceLW;;;7;;10.00;;;;",
-                    "A;SourceLW;;;8;;20.00;;;;",
-                    "A;SourceLW;;;9;;30.50;;;;",
-                ],
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    source.volumes,
-                    [
-                        [150, 150, 190],
-                        [200, 200, 180],
-                        [200, 150, 169.5],
-                    ],
-                )
-            )
-            self.assertEqual(len(source.history), 3)
-        return
-
-    def test_aspirate_2d_volumes(self) -> None:
-        source = liquidhandling.Labware(
-            "SourceLW", rows=2, columns=3, min_volume=10, max_volume=200, initial_volumes=200
-        )
-        with evotools.Worklist() as wl:
-            wl.aspirate(
-                source,
-                source.wells[:, :2],
-                volumes=numpy.array(
-                    [
-                        [20, 30],
-                        [15.3, 17.53],
-                    ]
-                ),
-            )
-            self.assertEqual(
-                wl,
-                [
-                    "A;SourceLW;;;1;;20.00;;;;",
-                    "A;SourceLW;;;2;;15.30;;;;",
-                    "A;SourceLW;;;3;;30.00;;;;",
-                    "A;SourceLW;;;4;;17.53;;;;",
-                ],
-            )
-            self.assertTrue(
-                numpy.array_equal(source.volumes, [[180, 170, 200], [200 - 15.3, 200 - 17.53, 200]])
-            )
-            self.assertEqual(len(source.history), 2)
-        return
-
-    def test_dispense(self) -> None:
-        destination = liquidhandling.Labware(
-            "DestinationLW", rows=2, columns=3, min_volume=10, max_volume=200
-        )
-        with evotools.Worklist() as wl:
-            wl.dispense(destination, ["A01", "A02", "A03"], 150, label=None)
-            wl.dispense(destination, ["B01", "B02", "B03"], [10, 20, 30.5], label="second dispense")
-            self.assertEqual(
-                wl,
-                [
-                    "D;DestinationLW;;;1;;150.00;;;;",
-                    "D;DestinationLW;;;3;;150.00;;;;",
-                    "D;DestinationLW;;;5;;150.00;;;;",
-                    "C;second dispense",
-                    "D;DestinationLW;;;2;;10.00;;;;",
-                    "D;DestinationLW;;;4;;20.00;;;;",
-                    "D;DestinationLW;;;6;;30.50;;;;",
-                ],
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    destination.volumes,
-                    [
-                        [150, 150, 150],
-                        [10, 20, 30.5],
-                    ],
-                )
-            )
-            self.assertEqual(len(destination.history), 3)
-        return
-
-    def test_dispense_2d_volumes(self) -> None:
-        destination = liquidhandling.Labware(
-            "DestinationLW", rows=2, columns=3, min_volume=10, max_volume=200
-        )
-        with evotools.Worklist() as wl:
-            wl.dispense(
-                destination,
-                destination.wells[:, :2],
-                volumes=numpy.array(
-                    [
-                        [20, 30],
-                        [15.3, 17.53],
-                    ]
-                ),
-            )
-            self.assertEqual(
-                wl,
-                [
-                    "D;DestinationLW;;;1;;20.00;;;;",
-                    "D;DestinationLW;;;2;;15.30;;;;",
-                    "D;DestinationLW;;;3;;30.00;;;;",
-                    "D;DestinationLW;;;4;;17.53;;;;",
-                ],
-            )
-            numpy.testing.assert_array_equal(destination.volumes, [[20, 30, 0], [15.3, 17.53, 0]])
-            self.assertEqual(len(destination.history), 2)
-        return
-
-    def test_skip_zero_volumes(self) -> None:
-        source = liquidhandling.Labware(
-            "SourceLW", rows=3, columns=3, min_volume=10, max_volume=200, initial_volumes=200
-        )
-        destination = liquidhandling.Labware(
-            "DestinationLW", rows=2, columns=3, min_volume=10, max_volume=200
-        )
-        with evotools.Worklist() as wl:
-            wl.aspirate(source, ["A03", "B03", "C03"], [10, 0, 30.5])
-            wl.dispense(destination, ["B01", "B02", "B03"], [10, 0, 30.5])
-            self.assertEqual(
-                wl,
-                [
-                    "A;SourceLW;;;7;;10.00;;;;",
-                    "A;SourceLW;;;9;;30.50;;;;",
-                    "D;DestinationLW;;;2;;10.00;;;;",
-                    "D;DestinationLW;;;6;;30.50;;;;",
-                ],
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    source.volumes,
-                    [
-                        [200, 200, 190],
-                        [200, 200, 200],
-                        [200, 200, 169.5],
-                    ],
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    destination.volumes,
-                    [
-                        [0, 0, 0],
-                        [10, 0, 30.5],
-                    ],
-                )
-            )
-            self.assertEqual(len(destination.history), 2)
-        return
-
-    def test_transfer_2d_volumes(self) -> None:
-        A = liquidhandling.Labware("A", 2, 4, min_volume=50, max_volume=250, initial_volumes=200)
-        B = liquidhandling.Labware("B", 2, 4, min_volume=50, max_volume=250)
-        with evotools.Worklist() as wl:
-            wl.transfer(
-                A,
-                A.wells[:, :2],
-                B,
-                B.wells[:, :2],
-                volumes=numpy.array(
-                    [
-                        [20, 30],
-                        [15.3, 17.53],
-                    ]
-                ),
-            )
-            self.assertEqual(
-                wl,
-                [
-                    "A;A;;;1;;20.00;;;;",
-                    "D;B;;;1;;20.00;;;;",
-                    "W1;",
-                    "A;A;;;2;;15.30;;;;",
-                    "D;B;;;2;;15.30;;;;",
-                    "W1;",
-                    "A;A;;;3;;30.00;;;;",
-                    "D;B;;;3;;30.00;;;;",
-                    "W1;",
-                    "A;A;;;4;;17.53;;;;",
-                    "D;B;;;4;;17.53;;;;",
-                    "W1;",
-                ],
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [180, 170, 200, 200],
-                            [200 - 15.3, 200 - 17.53, 200, 200],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [20, 30, 0, 0],
-                            [15.3, 17.53, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(len(A.history), 2)
-            self.assertEqual(len(B.history), 2)
-        return
-
-    def test_transfer_2d_volumes_no_wash(self) -> None:
-        A = liquidhandling.Labware("A", 2, 4, min_volume=50, max_volume=250, initial_volumes=200)
-        B = liquidhandling.Labware("B", 2, 4, min_volume=50, max_volume=250)
-        with evotools.Worklist() as wl:
-            wl.transfer(
-                A,
-                A.wells[:, :2],
-                B,
-                B.wells[:, :2],
-                volumes=numpy.array(
-                    [
-                        [20, 30],
-                        [15.3, 17.53],
-                    ]
-                ),
-                wash_scheme=None,
-            )
-            self.assertEqual(
-                wl,
-                [
-                    "A;A;;;1;;20.00;;;;",
-                    "D;B;;;1;;20.00;;;;",
-                    "A;A;;;2;;15.30;;;;",
-                    "D;B;;;2;;15.30;;;;",
-                    "A;A;;;3;;30.00;;;;",
-                    "D;B;;;3;;30.00;;;;",
-                    "A;A;;;4;;17.53;;;;",
-                    "D;B;;;4;;17.53;;;;",
-                ],
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [180, 170, 200, 200],
-                            [200 - 15.3, 200 - 17.53, 200, 200],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [20, 30, 0, 0],
-                            [15.3, 17.53, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(len(A.history), 2)
-            self.assertEqual(len(B.history), 2)
-        return
-
-    def test_transfer_many_many(self) -> None:
-        A = liquidhandling.Labware("A", 3, 4, min_volume=50, max_volume=250, initial_volumes=200)
-        B = liquidhandling.Labware("B", 3, 4, min_volume=50, max_volume=250)
-        wells = ["A01", "B01"]
-        with evotools.Worklist() as worklist:
-            worklist.transfer(A, wells, B, wells, 50, label="first transfer")
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [150, 200, 200, 200],
-                            [150, 200, 200, 200],
-                            [200, 200, 200, 200],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [50, 0, 0, 0],
-                            [50, 0, 0, 0],
-                            [0, 0, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            worklist.transfer(A, ["A03", "B04"], B, ["A04", "B04"], 50, label="second transfer")
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [150, 200, 150, 200],
-                            [150, 200, 200, 150],
-                            [200, 200, 200, 200],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [50, 0, 0, 50],
-                            [50, 0, 0, 50],
-                            [0, 0, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(
-                worklist,
-                [
-                    "C;first transfer",
-                    "A;A;;;1;;50.00;;;;",
-                    "D;B;;;1;;50.00;;;;",
-                    "W1;",
-                    "A;A;;;2;;50.00;;;;",
-                    "D;B;;;2;;50.00;;;;",
-                    "W1;",
-                    "C;second transfer",
-                    "A;A;;;7;;50.00;;;;",
-                    "D;B;;;10;;50.00;;;;",
-                    "W1;",
-                    "A;A;;;11;;50.00;;;;",
-                    "D;B;;;11;;50.00;;;;",
-                    "W1;",
-                ],
-            )
-            self.assertEqual(len(A.history), 3)
-            self.assertEqual(len(B.history), 3)
-        return
-
-    def test_transfer_many_many_2d(self) -> None:
-        A = liquidhandling.Labware("A", 3, 4, min_volume=50, max_volume=250, initial_volumes=200)
-        B = liquidhandling.Labware("B", 3, 4, min_volume=50, max_volume=250)
-        wells = A.wells[:, :2]
-        with evotools.Worklist() as worklist:
-            worklist.transfer(A, wells, B, wells, 50)
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [150, 150, 200, 200],
-                            [150, 150, 200, 200],
-                            [150, 150, 200, 200],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [50, 50, 0, 0],
-                            [50, 50, 0, 0],
-                            [50, 50, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(
-                worklist,
-                [
-                    # first transfer
-                    "A;A;;;1;;50.00;;;;",
-                    "D;B;;;1;;50.00;;;;",
-                    "W1;",
-                    "A;A;;;2;;50.00;;;;",
-                    "D;B;;;2;;50.00;;;;",
-                    "W1;",
-                    "A;A;;;3;;50.00;;;;",
-                    "D;B;;;3;;50.00;;;;",
-                    "W1;",
-                    "A;A;;;4;;50.00;;;;",
-                    "D;B;;;4;;50.00;;;;",
-                    "W1;",
-                    "A;A;;;5;;50.00;;;;",
-                    "D;B;;;5;;50.00;;;;",
-                    "W1;",
-                    "A;A;;;6;;50.00;;;;",
-                    "D;B;;;6;;50.00;;;;",
-                    "W1;",
-                ],
-            )
-            self.assertEqual(len(A.history), 2)
-            self.assertEqual(len(B.history), 2)
-        return
-
-    def test_transfer_one_many(self) -> None:
-        A = liquidhandling.Labware("A", 3, 4, min_volume=50, max_volume=250, initial_volumes=200)
-        B = liquidhandling.Labware("B", 3, 4, min_volume=50, max_volume=250)
-        with evotools.Worklist() as worklist:
-            worklist.transfer(A, "A01", B, ["B01", "B02", "B03"], 25)
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [125, 200, 200, 200],
-                            [200, 200, 200, 200],
-                            [200, 200, 200, 200],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [0, 0, 0, 0],
-                            [25, 25, 25, 0],
-                            [0, 0, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            worklist.transfer(A, ["A01"], B, ["B01", "B02", "B03"], 25)
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [50, 200, 200, 200],
-                            [200, 200, 200, 200],
-                            [200, 200, 200, 200],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [0, 0, 0, 0],
-                            [50, 50, 50, 0],
-                            [0, 0, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(
-                worklist,
-                [
-                    # first transfer
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;5;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;8;;25.00;;;;",
-                    "W1;",
-                    # second transfer
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;5;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;8;;25.00;;;;",
-                    "W1;",
-                ],
-            )
-            self.assertEqual(len(A.history), 3)
-            self.assertEqual(len(B.history), 3)
-        return
-
-    def test_transfer_many_one(self) -> None:
-        A = liquidhandling.Labware("A", 3, 4, min_volume=50, max_volume=250, initial_volumes=200)
-        B = liquidhandling.Labware("B", 3, 4, min_volume=50, max_volume=250)
-        with evotools.Worklist() as worklist:
-            worklist.transfer(A, ["A01", "A02", "A03"], B, "B01", 25)
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [175, 175, 175, 200],
-                            [200, 200, 200, 200],
-                            [200, 200, 200, 200],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [0, 0, 0, 0],
-                            [75, 0, 0, 0],
-                            [0, 0, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(
-                worklist,
-                [
-                    # first transfer
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;4;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;7;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                ],
-            )
-            self.assertEqual(len(A.history), 2)
-            self.assertEqual(len(B.history), 2)
-        return
-
-    def test_tip_selection(self) -> None:
-        A = liquidhandling.Labware("A", 3, 4, min_volume=10, max_volume=250, initial_volumes=100)
-        with evotools.Worklist() as wl:
-            wl.aspirate(A, "A01", 10, tip=1)
-            wl.aspirate(A, "A01", 10, tip=2)
-            wl.aspirate(A, "A01", 10, tip=3)
-            wl.aspirate(A, "A01", 10, tip=4)
-            wl.aspirate(A, "A01", 10, tip=5)
-            wl.aspirate(A, "A01", 10, tip=6)
-            wl.aspirate(A, "A01", 10, tip=7)
-            wl.aspirate(A, "A01", 10, tip=8)
-            wl.dispense(A, "B01", 10, tip=evotools.Tip.T1)
-            wl.dispense(A, "B02", 10, tip=evotools.Tip.T2)
-            wl.dispense(A, "B03", 10, tip=evotools.Tip.T3)
-            wl.dispense(A, "B04", 10, tip=evotools.Tip.T4)
-            wl.dispense(A, "B04", 10, tip=evotools.Tip.T5)
-            wl.dispense(A, "B04", 10, tip=evotools.Tip.T6)
-            wl.dispense(A, "B04", 10, tip=evotools.Tip.T7)
-            wl.dispense(A, "B04", 10, tip=evotools.Tip.T8)
-            self.assertEqual(
-                wl,
-                [
-                    "A;A;;;1;;10.00;;;1;",
-                    "A;A;;;1;;10.00;;;2;",
-                    "A;A;;;1;;10.00;;;4;",
-                    "A;A;;;1;;10.00;;;8;",
-                    "A;A;;;1;;10.00;;;16;",
-                    "A;A;;;1;;10.00;;;32;",
-                    "A;A;;;1;;10.00;;;64;",
-                    "A;A;;;1;;10.00;;;128;",
-                    "D;A;;;2;;10.00;;;1;",
-                    "D;A;;;5;;10.00;;;2;",
-                    "D;A;;;8;;10.00;;;4;",
-                    "D;A;;;11;;10.00;;;8;",
-                    "D;A;;;11;;10.00;;;16;",
-                    "D;A;;;11;;10.00;;;32;",
-                    "D;A;;;11;;10.00;;;64;",
-                    "D;A;;;11;;10.00;;;128;",
-                ],
-            )
-        return
-
-    def test_tip_mask(self) -> None:
-        A = liquidhandling.Labware("A", 3, 4, min_volume=10, max_volume=250)
-
-        # Only allow three specific tips to be used...
-        tips = [
-            evotools.Tip.T1,  # 1 +
-            evotools.Tip.T4,  # 8 +
-            evotools.Tip.T7,  # 64
-            # The sum of tips is = 73
-        ]
-        with evotools.Worklist() as wl:
-            wl.dispense(A, "A01", 10, tip=tips)
-        assert wl[-1] == "D;A;;;1;;10.00;;;73;"
-        pass
-
-    def test_history_condensation(self) -> None:
-        A = liquidhandling.Labware("A", 3, 2, min_volume=300, max_volume=4600, initial_volumes=1500)
-        B = liquidhandling.Labware("B", 3, 2, min_volume=300, max_volume=4600, initial_volumes=1500)
-
-        with evotools.Worklist() as wl:
-            wl.transfer(A, ["A01", "B01", "C02"], B, ["A01", "B02", "C01"], [900, 100, 900], label="transfer")
-
-        self.assertEqual(len(A.history), 2)
-        self.assertEqual(A.history[-1][0], "transfer")
-        numpy.testing.assert_array_equal(
-            A.history[-1][1],
-            numpy.array(
-                [
-                    [1500 - 900, 1500],
-                    [1500 - 100, 1500],
-                    [1500, 1500 - 900],
-                ]
-            ),
-        )
-
-        self.assertEqual(len(B.history), 2)
-        self.assertEqual(B.history[-1][0], "transfer")
-        numpy.testing.assert_array_equal(
-            B.history[-1][1],
-            numpy.array(
-                [
-                    [1500 + 900, 1500],
-                    [1500, 1500 + 100],
-                    [1500 + 900, 1500],
-                ]
-            ),
-        )
-        return
-
-    def test_history_condensation_within_labware(self) -> None:
-        A = liquidhandling.Labware("A", 3, 2, min_volume=300, max_volume=4600, initial_volumes=1500)
-
-        with evotools.Worklist() as wl:
-            wl.transfer(A, ["A01", "B01", "C02"], A, ["A01", "B02", "C01"], [900, 100, 900], label="mix")
-
-        self.assertEqual(len(A.history), 2)
-        self.assertEqual(A.history[-1][0], "mix")
-        numpy.testing.assert_array_equal(
-            A.history[-1][1],
-            numpy.array(
-                [
-                    [1500 - 900 + 900, 1500],
-                    [1500 - 100, 1500 + 100],
-                    [1500 + 900, 1500 - 900],
-                ]
-            ),
-        )
-        return
-
-
-class TestTroughLabwareWorklist(unittest.TestCase):
-    def test_aspirate(self) -> None:
-        source = liquidhandling.Trough(
-            "SourceLW", virtual_rows=3, columns=3, min_volume=10, max_volume=200, initial_volumes=200
-        )
-        with evotools.Worklist() as wl:
-            wl.aspirate(source, ["A01", "A02", "C02"], 50)
-            wl.aspirate(source, ["A01", "A02", "C02"], [1, 2, 3])
-            self.assertEqual(
-                wl,
-                [
-                    "A;SourceLW;;;1;;50.00;;;;",
-                    "A;SourceLW;;;4;;50.00;;;;",
-                    "A;SourceLW;;;6;;50.00;;;;",
-                    "A;SourceLW;;;1;;1.00;;;;",
-                    "A;SourceLW;;;4;;2.00;;;;",
-                    "A;SourceLW;;;6;;3.00;;;;",
-                ],
-            )
-            numpy.testing.assert_array_equal(source.volumes, [[149, 95, 200]])
-            self.assertEqual(len(source.history), 3)
-        return
-
-    def test_dispense(self) -> None:
-        destination = liquidhandling.Trough(
-            "DestinationLW", virtual_rows=3, columns=3, min_volume=10, max_volume=200
-        )
-        with evotools.Worklist() as wl:
-            wl.dispense(destination, ["A01", "A02", "A03", "B01"], 50)
-            wl.dispense(destination, ["A01", "A02", "C02"], [1, 2, 3])
-            self.assertEqual(
-                wl,
-                [
-                    "D;DestinationLW;;;1;;50.00;;;;",
-                    "D;DestinationLW;;;4;;50.00;;;;",
-                    "D;DestinationLW;;;7;;50.00;;;;",
-                    "D;DestinationLW;;;2;;50.00;;;;",
-                    "D;DestinationLW;;;1;;1.00;;;;",
-                    "D;DestinationLW;;;4;;2.00;;;;",
-                    "D;DestinationLW;;;6;;3.00;;;;",
-                ],
-            )
-            numpy.testing.assert_array_equal(destination.volumes, [[101, 55, 50]])
-            self.assertEqual(len(destination.history), 3)
-        return
-
-    def test_transfer_many_many(self) -> None:
-        A = liquidhandling.Trough("A", 3, 4, min_volume=50, max_volume=2500, initial_volumes=2000)
-        B = liquidhandling.Labware("B", 3, 4, min_volume=50, max_volume=250)
-        with evotools.Worklist() as worklist:
-            worklist.transfer(A, ["A01", "B01"], B, ["A01", "B01"], 50)
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [1900, 2000, 2000, 2000],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [50, 0, 0, 0],
-                            [50, 0, 0, 0],
-                            [0, 0, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            worklist.transfer(A, ["A03", "B04"], B, ["A04", "B04"], [50, 75])
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [1900, 2000, 1950, 1925],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [50, 0, 0, 50],
-                            [50, 0, 0, 75],
-                            [0, 0, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(
-                worklist,
-                [
-                    # first transfer
-                    "A;A;;;1;;50.00;;;;",
-                    "D;B;;;1;;50.00;;;;",
-                    "W1;",
-                    "A;A;;;2;;50.00;;;;",
-                    "D;B;;;2;;50.00;;;;",
-                    "W1;",
-                    # second transfer
-                    "A;A;;;7;;50.00;;;;",
-                    "D;B;;;10;;50.00;;;;",
-                    "W1;",
-                    "A;A;;;11;;75.00;;;;",
-                    "D;B;;;11;;75.00;;;;",
-                    "W1;",
-                ],
-            )
-            self.assertEqual(len(A.history), 3)
-            self.assertEqual(len(B.history), 3)
-        return
-
-    def test_transfer_one_many(self) -> None:
-        A = liquidhandling.Trough("A", 3, 4, min_volume=50, max_volume=2500, initial_volumes=2000)
-        B = liquidhandling.Labware("B", 3, 4, min_volume=50, max_volume=250)
-        with evotools.Worklist() as worklist:
-            worklist.transfer(A, "A01", B, ["B01", "B02", "B03"], 25)
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [1925, 2000, 2000, 2000],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [0, 0, 0, 0],
-                            [25, 25, 25, 0],
-                            [0, 0, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(
-                worklist,
-                [
-                    # first transfer
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;5;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;8;;25.00;;;;",
-                    "W1;",
-                ],
-            )
-
-            worklist.transfer(A, ["A01"], B, ["B01", "B02", "B03"], [25, 30, 35])
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [1835, 2000, 2000, 2000],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [0, 0, 0, 0],
-                            [50, 55, 60, 0],
-                            [0, 0, 0, 0],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(
-                worklist,
-                [
-                    # first transfer
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;5;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;8;;25.00;;;;",
-                    "W1;",
-                    # second transfer
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;30.00;;;;",
-                    "D;B;;;5;;30.00;;;;",
-                    "W1;",
-                    "A;A;;;1;;35.00;;;;",
-                    "D;B;;;8;;35.00;;;;",
-                    "W1;",
-                ],
-            )
-            self.assertEqual(len(A.history), 3)
-            self.assertEqual(len(B.history), 3)
-        return
-
-    def test_transfer_many_one(self) -> None:
-        A = liquidhandling.Trough(
-            "A", 3, 4, min_volume=50, max_volume=2500, initial_volumes=[2000, 1500, 1000, 500]
-        )
-        B = liquidhandling.Labware("B", 3, 4, min_volume=10, max_volume=250, initial_volumes=100)
-        with evotools.Worklist() as worklist:
-            worklist.transfer(A, ["A01", "A02", "A03"], B, "B01", 25)
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [1975, 1475, 975, 500],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [100, 100, 100, 100],
-                            [175, 100, 100, 100],
-                            [100, 100, 100, 100],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(
-                worklist,
-                [
-                    # first transfer
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;4;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;7;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                ],
-            )
-
-            worklist.transfer(B, B.wells[:, 2], A, A.wells[:, 3], [50, 60, 70])
-            self.assertTrue(
-                numpy.array_equal(
-                    A.volumes,
-                    numpy.array(
-                        [
-                            [1975, 1475, 975, 680],
-                        ]
-                    ),
-                )
-            )
-            self.assertTrue(
-                numpy.array_equal(
-                    B.volumes,
-                    numpy.array(
-                        [
-                            [100, 100, 50, 100],
-                            [175, 100, 40, 100],
-                            [100, 100, 30, 100],
-                        ]
-                    ),
-                )
-            )
-            self.assertEqual(
-                worklist,
-                [
-                    # first transfer
-                    "A;A;;;1;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;4;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    "A;A;;;7;;25.00;;;;",
-                    "D;B;;;2;;25.00;;;;",
-                    "W1;",
-                    # second transfer
-                    "A;B;;;7;;50.00;;;;",
-                    "D;A;;;10;;50.00;;;;",
-                    "W1;",
-                    "A;B;;;8;;60.00;;;;",
-                    "D;A;;;11;;60.00;;;;",
-                    "W1;",
-                    "A;B;;;9;;70.00;;;;",
-                    "D;A;;;12;;70.00;;;;",
-                    "W1;",
-                ],
-            )
-            self.assertEqual(len(A.history), 3)
-            self.assertEqual(len(B.history), 3)
-        return
-
-
-class TestLargeVolumeHandling(unittest.TestCase):
-    def test_partition_volume_helper(self) -> None:
-        self.assertEqual([], evotools._partition_volume(0, max_volume=950))
-        self.assertEqual([550.3], evotools._partition_volume(550.3, max_volume=950))
-        self.assertEqual([500, 500], evotools._partition_volume(1000, max_volume=950))
-        self.assertEqual([500, 499], evotools._partition_volume(999, max_volume=950))
-        self.assertEqual([667, 667, 666], evotools._partition_volume(2000, max_volume=950))
-        return
-
-    def test_worklist_constructor(self) -> None:
-        with self.assertRaises(ValueError):
-            with evotools.Worklist(max_volume=None) as wl:
-                pass
-        with evotools.Worklist(max_volume=800, auto_split=True) as wl:
-            self.assertEqual(wl.max_volume, 800)
-            self.assertEqual(wl.auto_split, True)
-        with evotools.Worklist(max_volume=800, auto_split=False) as wl:
-            self.assertEqual(wl.max_volume, 800)
-            self.assertEqual(wl.auto_split, False)
-        return
-
-    def test_max_volume_checking(self) -> None:
-        source = liquidhandling.Trough(
-            "WaterTrough",
-            virtual_rows=3,
-            columns=3,
-            min_volume=1000,
-            max_volume=100 * 1000,
-            initial_volumes=50 * 1000,
-        )
-        destination = liquidhandling.Trough(
-            "WaterTrough",
-            virtual_rows=3,
-            columns=3,
-            min_volume=1000,
-            max_volume=100 * 1000,
-            initial_volumes=50 * 1000,
-        )
-        with evotools.Worklist(max_volume=900, auto_split=False) as wl:
-            with self.assertRaises(evotools.InvalidOperationError):
-                wl.aspirate_well("WaterTrough", 1, 1000)
-            with self.assertRaises(evotools.InvalidOperationError):
-                wl.dispense_well("WaterTrough", 1, 1000)
-            with self.assertRaises(evotools.InvalidOperationError):
-                wl.aspirate(source, ["A01", "A02", "C02"], 1000)
-            with self.assertRaises(evotools.InvalidOperationError):
-                wl.dispense(source, ["A01", "A02", "C02"], 1000)
-            with self.assertRaises(evotools.InvalidOperationError):
-                wl.transfer(source, ["A01", "B01"], destination, ["A01", "B01"], 1000)
-
-        source = liquidhandling.Trough(
-            "WaterTrough",
-            virtual_rows=3,
-            columns=3,
-            min_volume=1000,
-            max_volume=100 * 1000,
-            initial_volumes=50 * 1000,
-        )
-        destination = liquidhandling.Trough(
-            "WaterTrough",
-            virtual_rows=3,
-            columns=3,
-            min_volume=1000,
-            max_volume=100 * 1000,
-            initial_volumes=50 * 1000,
-        )
-        with evotools.Worklist(max_volume=1200) as wl:
-            wl.aspirate_well("WaterTrough", 1, 1000)
-            wl.dispense_well("WaterTrough", 1, 1000)
-            wl.aspirate(source, ["A01", "A02", "C02"], 1000)
-            wl.dispense(source, ["A01", "A02", "C02"], 1000)
-            wl.transfer(source, ["A01", "B01"], destination, ["A01", "B01"], 1000)
-        return
-
-    def test_partition_by_columns_source(self) -> None:
-        column_groups = evotools._partition_by_column(
-            ["A01", "B01", "A03", "B03", "C02"],
-            ["A01", "B01", "C01", "D01", "E01"],
-            [2500, 3500, 1000, 500, 2000],
-            partition_by="source",
-        )
-        self.assertEqual(len(column_groups), 3)
-        self.assertEqual(
-            column_groups[0],
-            (
-                ["A01", "B01"],
-                ["A01", "B01"],
-                [2500, 3500],
-            ),
-        )
-        self.assertEqual(
-            column_groups[1],
-            (
-                ["C02"],
-                ["E01"],
-                [2000],
-            ),
-        )
-        self.assertEqual(
-            column_groups[2],
-            (
-                ["A03", "B03"],
-                ["C01", "D01"],
-                [1000, 500],
-            ),
-        )
-        return
-
-    def test_partition_by_columns_destination(self) -> None:
-        column_groups = evotools._partition_by_column(
-            ["A01", "B01", "A03", "B03", "C02"],
-            ["A01", "B01", "C02", "D01", "E02"],
-            [2500, 3500, 1000, 500, 2000],
-            partition_by="destination",
-        )
-        self.assertEqual(len(column_groups), 2)
-        self.assertEqual(
-            column_groups[0],
-            (
-                ["A01", "B01", "B03"],
-                ["A01", "B01", "D01"],
-                [2500, 3500, 500],
-            ),
-        )
-        self.assertEqual(
-            column_groups[1],
-            (
-                ["A03", "C02"],
-                ["C02", "E02"],
-                [1000, 2000],
-            ),
-        )
-        return
-
-    def test_partition_by_columns_sorting(self) -> None:
-        # within every column, the wells are supposed to be sorted by row
-        # The test source wells are partially sorted (col 1 is in the right order, col 3 in the reverse)
-        # The result is expected to always be sorted by row, either in the source (first case) or destination:
-
-        # by source
-        column_groups = evotools._partition_by_column(
-            ["A01", "B01", "B03", "A03", "C02"],
-            ["B01", "A01", "C01", "D01", "E01"],
-            [2500, 3500, 1000, 500, 2000],
-            partition_by="source",
-        )
-        self.assertEqual(len(column_groups), 3)
-        self.assertEqual(
-            column_groups[0],
-            (
-                ["A01", "B01"],
-                ["B01", "A01"],
-                [2500, 3500],
-            ),
-        )
-        self.assertEqual(
-            column_groups[1],
-            (
-                ["C02"],
-                ["E01"],
-                [2000],
-            ),
-        )
-        self.assertEqual(
-            column_groups[2],
-            (
-                ["A03", "B03"],
-                ["D01", "C01"],
-                [500, 1000],
-            ),
-        )
-
-        # by destination
-        # (destination wells are across 3 columns; reverse order in col 1, forward order in col 3)
-        column_groups = evotools._partition_by_column(
-            ["A01", "B01", "B03", "A03", "C02"],
-            ["B01", "A01", "C03", "D03", "E02"],
-            [2500, 3500, 1000, 500, 2000],
-            partition_by="destination",
-        )
-        self.assertEqual(len(column_groups), 3)
-        self.assertEqual(
-            column_groups[0],
-            (
-                ["B01", "A01"],
-                ["A01", "B01"],
-                [3500, 2500],
-            ),
-        )
-        self.assertEqual(
-            column_groups[1],
-            (
-                ["C02"],
-                ["E02"],
-                [2000],
-            ),
-        )
-        self.assertEqual(
-            column_groups[2],
-            (
-                ["B03", "A03"],
-                ["C03", "D03"],
-                [1000, 500],
-            ),
-        )
-        return
-
-    def test_single_split(self) -> None:
-        src = liquidhandling.Labware("A", 3, 2, min_volume=1000, max_volume=25000, initial_volumes=12000)
-        dst = liquidhandling.Labware("B", 3, 2, min_volume=1000, max_volume=25000)
-        with evotools.Worklist(auto_split=True) as wl:
-            wl.transfer(src, "A01", dst, "A01", 2000, label="Transfer more than 2x the max")
-            self.assertEqual(
-                wl,
-                [
-                    "C;Transfer more than 2x the max",
-                    "A;A;;;1;;667.00;;;;",
-                    "D;B;;;1;;667.00;;;;",
-                    "W1;",
-                    # no breaks when pipetting single wells
-                    "A;A;;;1;;667.00;;;;",
-                    "D;B;;;1;;667.00;;;;",
-                    "W1;",
-                    # no breaks when pipetting single wells
-                    "A;A;;;1;;666.00;;;;",
-                    "D;B;;;1;;666.00;;;;",
-                    "W1;",
-                    "B;",  # always break after partitioning
-                ],
-            )
-        # Two extra steps were necessary because of LVH
-        assert "Transfer more than 2x the max (2 LVH steps)" in src.report
-        assert "Transfer more than 2x the max (2 LVH steps)" in dst.report
-        numpy.testing.assert_array_equal(
-            src.volumes,
-            [
-                [12000 - 2000, 12000],
-                [12000, 12000],
-                [12000, 12000],
-            ],
-        )
-        numpy.testing.assert_array_equal(
-            dst.volumes,
-            [
-                [2000, 0],
-                [0, 0],
-                [0, 0],
-            ],
-        )
-        return
-
-    def test_column_split(self) -> None:
-        src = liquidhandling.Labware("A", 4, 2, min_volume=1000, max_volume=25000, initial_volumes=12000)
-        dst = liquidhandling.Labware("B", 4, 2, min_volume=1000, max_volume=25000)
-        with evotools.Worklist(auto_split=True) as wl:
-            wl.transfer(
-                src, ["A01", "B01", "D01", "C01"], dst, ["A01", "B01", "D01", "C01"], [1500, 250, 0, 1200]
-            )
-            self.assertEqual(
-                wl,
-                [
-                    "A;A;;;1;;750.00;;;;",
-                    "D;B;;;1;;750.00;;;;",
-                    "W1;",
-                    "A;A;;;2;;250.00;;;;",
-                    "D;B;;;2;;250.00;;;;",
-                    "W1;",
-                    # D01 is ignored because the volume is 0
-                    "A;A;;;3;;600.00;;;;",
-                    "D;B;;;3;;600.00;;;;",
-                    "W1;",
-                    "B;",  # within-column break
-                    "A;A;;;1;;750.00;;;;",
-                    "D;B;;;1;;750.00;;;;",
-                    "W1;",
-                    "A;A;;;3;;600.00;;;;",
-                    "D;B;;;3;;600.00;;;;",
-                    "W1;",
-                    "B;",  # tailing break after partitioning
-                ],
-            )
-        numpy.testing.assert_array_equal(
-            src.volumes,
-            [
-                [12000 - 1500, 12000],
-                [12000 - 250, 12000],
-                [12000 - 1200, 12000],
-                [12000, 12000],
-            ],
-        )
-        numpy.testing.assert_array_equal(
-            dst.volumes,
-            [
-                [1500, 0],
-                [250, 0],
-                [1200, 0],
-                [0, 0],
-            ],
-        )
-        return
-
-    def test_block_split(self) -> None:
-        src = liquidhandling.Labware("A", 3, 2, min_volume=1000, max_volume=25000, initial_volumes=12000)
-        dst = liquidhandling.Labware("B", 3, 2, min_volume=1000, max_volume=25000)
-        with evotools.Worklist(auto_split=True) as wl:
-            wl.transfer(
-                # A01, B01, A02, B02
-                src,
-                src.wells[:2, :],
-                dst,
-                ["A01", "B01", "C01", "A02"],
-                [1500, 250, 1200, 3000],
-            )
-            self.assertEqual(
-                wl,
-                [
-                    "A;A;;;1;;750.00;;;;",
-                    "D;B;;;1;;750.00;;;;",
-                    "W1;",
-                    "A;A;;;2;;250.00;;;;",
-                    "D;B;;;2;;250.00;;;;",
-                    "W1;",
-                    "B;",  # within-column 1 break
-                    "A;A;;;1;;750.00;;;;",
-                    "D;B;;;1;;750.00;;;;",
-                    "W1;",
-                    "B;",  # between-column 1/2 break
-                    "A;A;;;4;;600.00;;;;",
-                    "D;B;;;3;;600.00;;;;",
-                    "W1;",
-                    "A;A;;;5;;750.00;;;;",
-                    "D;B;;;4;;750.00;;;;",
-                    "W1;",
-                    "B;",  # within-column 2 break
-                    "A;A;;;4;;600.00;;;;",
-                    "D;B;;;3;;600.00;;;;",
-                    "W1;",
-                    "A;A;;;5;;750.00;;;;",
-                    "D;B;;;4;;750.00;;;;",
-                    "W1;",
-                    "B;",  # within-column 2 break
-                    "A;A;;;5;;750.00;;;;",
-                    "D;B;;;4;;750.00;;;;",
-                    "W1;",
-                    # no break because only one well is accessed in this partition
-                    "A;A;;;5;;750.00;;;;",
-                    "D;B;;;4;;750.00;;;;",
-                    "W1;",
-                    "B;",  # tailing break after partitioning
-                ],
-            )
-
-        # How the number of splits is calculated:
-        # 1500 is split 2x → 1 extra
-        # 250 is not split
-        # 1200 is split 2x → 1 extra
-        # 3000 is split 4x → 3 extra
-        # Sum of extra steps: 5
-        assert "5 LVH steps" in src.report
-        assert "5 LVH steps" in dst.report
-        numpy.testing.assert_array_equal(
-            src.volumes,
-            [
-                [12000 - 1500, 12000 - 1200],
-                [12000 - 250, 12000 - 3000],
-                [12000, 12000],
-            ],
-        )
-        numpy.testing.assert_array_equal(
-            dst.volumes,
-            [
-                [1500, 3000],
-                [250, 0],
-                [1200, 0],
-            ],
-        )
-        return
-
-
-class TestReagentDistribution(unittest.TestCase):
-    def test_parameter_validation(self) -> None:
-        with evotools.Worklist() as wl:
-            with self.assertRaises(ValueError):
-                wl.reagent_distribution("S1", 1, 8, "D1", 1, 20, volume=50, direction="invalid")
-            with self.assertRaises(ValueError):
-                # one excluded well not in the destination range
-                wl.reagent_distribution("S1", 1, 8, "D1", 1, 20, volume=50, exclude_wells=[18, 19, 23])
-        with self.assertRaises(evotools.InvalidOperationError):
-            evo_logger = logging.getLogger("evotools")
-            with self.assertLogs(logger=evo_logger, level=logging.WARNING):
-                with evotools.Worklist(max_volume=950) as wl:
-                    # dispense more than diluter volume
-                    wl.reagent_distribution("S1", 1, 8, "D1", 1, 20, volume=1200)
-        return
-
-    def test_default_parameterization(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.reagent_distribution("S1", 1, 20, "D1", 2, 21, volume=50)
-        self.assertEqual(wl[0], "R;S1;;;1;20;D1;;;2;21;50;;1;1;0")
-        return
-
-    def test_full_parameterization(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.reagent_distribution(
-                "S1",
-                1,
-                20,
-                "D1",
-                2,
-                21,
-                volume=50,
-                diti_reuse=2,
-                multi_disp=3,
-                exclude_wells=[2, 4, 8],
-                liquid_class="TestLC",
-                direction="right_to_left",
-                src_rack_type="MP3Pos",
-                dst_rack_type="MP4Pos",
-                src_rack_id="S1234",
-                dst_rack_id="D1234",
-            )
-        self.assertEqual(wl[0], "R;S1;S1234;MP3Pos;1;20;D1;D1234;MP4Pos;2;21;50;TestLC;2;3;1;2;4;8")
-        return
-
-    def test_large_volume_multi_disp_adaption(self) -> None:
-        with evotools.Worklist() as wl:
-            wl.reagent_distribution(
-                "S1",
-                1,
-                8,
-                "D1",
-                1,
-                96,
-                volume=400,
-                multi_disp=6,
-            )
-        self.assertEqual(wl[0], "R;S1;;;1;8;D1;;;1;96;400;;1;2;0")
-        return
-
-    def test_oo_parameter_validation(self) -> None:
-        with evotools.Worklist() as wl:
-            src = liquidhandling.Labware("NotATrough", 6, 2, min_volume=20, max_volume=1000)
-            dst = liquidhandling.Labware("48deep", 6, 8, min_volume=50, max_volume=4000)
-            with self.assertRaises(ValueError):
-                wl.distribute(src, 0, dst, dst.wells[:, :3], volume=50)
-        with evotools.Worklist(max_volume=950) as wl:
-            src = liquidhandling.Trough("Water", 8, 2, min_volume=20, max_volume=1000)
-            dst = liquidhandling.Labware("48deep", 6, 8, min_volume=50, max_volume=4000)
-            with self.assertRaises(evotools.InvalidOperationError):
-                wl.distribute(src, 0, dst, dst.wells[:, :3], volume=1200)
-        return
-
-    def test_oo_example_1(self) -> None:
-        src = liquidhandling.Trough(
-            "T3",
-            8,
-            1,
-            min_volume=20,
-            max_volume=100 * 1000,
-            initial_volumes=100 * 1000,
-        )
-        dst = liquidhandling.Labware("MTP-96-3", 8, 12, min_volume=20, max_volume=300)
-        with evotools.Worklist() as wl:
-            all_dst_wells = set(dst.wells.flatten("F"))
-            skip_wells = set("C04,C07,E09,F06,B11".split(","))
-            dst_wells = list(all_dst_wells.difference(skip_wells))
-            wl.distribute(
-                src,
-                0,
-                dst,
-                dst_wells,
-                volume=100,
-                liquid_class="Water",
-                diti_reuse=1,
-                multi_disp=6,
-                direction="left_to_right",
-                src_rack_type="Trough 100ml",
-                dst_rack_type="96 Well Microplate",
-                label="Test Label",
-            )
-        self.assertEqual(wl[0], "C;Test Label")
-        self.assertEqual(
-            wl[1], "R;T3;;Trough 100ml;1;8;MTP-96-3;;96 Well Microplate;1;96;100;Water;1;6;0;27;46;51;69;82"
-        )
-        self.assertEqual(src.volumes[0, 0], 100 * 1000 - 91 * 100)
-        dst_exp = numpy.ones_like(dst.volumes) * 100
-        dst_exp[dst.indices["C04"]] = 0
-        dst_exp[dst.indices["C07"]] = 0
-        dst_exp[dst.indices["E09"]] = 0
-        dst_exp[dst.indices["F06"]] = 0
-        dst_exp[dst.indices["B11"]] = 0
-        numpy.testing.assert_array_equal(dst.volumes, dst_exp)
-        return
-
-    def test_oo_example_2(self) -> None:
-        src = liquidhandling.Trough(
-            "T2",
-            8,
-            1,
-            min_volume=20,
-            max_volume=100 * 1000,
-            initial_volumes=100 * 1000,
-        )
-        dst = liquidhandling.Labware("MTP-96-2", 8, 12, min_volume=20, max_volume=300)
-        with evotools.Worklist() as wl:
-            wl.distribute(
-                src,
-                0,
-                dst,
-                dst.wells,
-                volume=100,
-                liquid_class="Water",
-                diti_reuse=2,
-                multi_disp=5,
-                direction="left_to_right",
-                src_rack_type="Trough 100ml",
-                dst_rack_type="96 Well Microplate",
-                label="Test Label",
-            )
-        self.assertEqual(wl[0], "C;Test Label")
-        self.assertEqual(wl[1], "R;T2;;Trough 100ml;1;8;MTP-96-2;;96 Well Microplate;1;96;100;Water;2;5;0")
-        self.assertEqual(src.volumes[0, 0], 100 * 1000 - 96 * 100)
-        numpy.testing.assert_array_equal(dst.volumes, numpy.ones_like(dst.volumes) * 100)
-        return
-
-    def test_oo_block_from_right(self) -> None:
-        src = liquidhandling.Trough(
-            "Water",
-            8,
-            1,
-            min_volume=20,
-            max_volume=100 * 1000,
-            initial_volumes=100 * 1000,
-        )
-        dst = liquidhandling.Labware("96mtp", 8, 12, min_volume=20, max_volume=300)
-        with evotools.Worklist() as wl:
-            wl.distribute(
-                src,
-                0,
-                dst,
-                dst.wells[1:4, 2:7],
-                volume=50,
-                liquid_class="TestLC",
-                diti_reuse=10,
-                multi_disp=5,
-                direction="right_to_left",
-                label="Test Label",
-            )
-        self.assertEqual(wl[0], "C;Test Label")
-        skip_pos = ";21;22;23;24;25;29;30;31;32;33;37;38;39;40;41;45;46;47;48;49"
-        self.assertEqual(wl[1], f"R;Water;;;1;8;96mtp;;;18;52;50;TestLC;10;5;1{skip_pos}")
-        self.assertEqual(src.volumes[0, 0], 100 * 1000 - 15 * 50)
-        self.assertTrue(numpy.all(dst.volumes[1:4, 2:7] == 50))
-        return
-
-
-class TestCompositionTracking(unittest.TestCase):
-    def test_get_initial_composition(self) -> None:
-        wells2x3 = numpy.array(
-            [
-                ["A01", "A02", "A03"],
-                ["B01", "B02", "B03"],
-            ]
-        )
-
-        # Raise errors on invalid component well ids
-        with pytest.raises(ValueError, match=r"Invalid component name keys: \{'G02'\}"):
-            liquidhandling._get_initial_composition("eppis", wells2x3, dict(G02="beer"), numpy.zeros((2, 3)))
-
-        # Raise errors on attempts to name empty wells
-        with pytest.raises(ValueError, match=r"name 'beer' was specified for eppis.A02, but"):
-            liquidhandling._get_initial_composition("eppis", wells2x3, dict(A02="beer"), numpy.zeros((2, 3)))
-
-        # No components if all wells are empty
-        result = liquidhandling._get_initial_composition("eppis", wells2x3, {}, numpy.zeros((2, 3)))
-        assert result == {}
-
-        # Default to labware name for one-well labwares
-        result = liquidhandling._get_initial_composition("media", [["A01"]], {}, numpy.atleast_2d(100))
-        assert "media" in result
-        assert len(result) == 1
-
-        # Assigning default component names to all wells
-        result = liquidhandling._get_initial_composition("samples", wells2x3, {}, numpy.ones((2, 3)))
-        assert isinstance(result, dict)
-        # Non-empty wells default to unique component names
-        assert "samples.A01" in result
-        assert "samples.B03" in result
-        # Only the well with the component has 100 % of it
-        numpy.testing.assert_array_equal(
-            result["samples.B02"],
-            numpy.array(
-                [
-                    [0, 0, 0],
-                    [0, 1, 0],
-                ]
-            ),
-        )
-
-        # Mix of user-defined and default component names
-        result = liquidhandling._get_initial_composition(
-            "samples", wells2x3, {"B01": "water"}, numpy.ones((2, 3))
-        )
-        assert isinstance(result, dict)
-        # Non-empty wells default to unique component names
-        assert "samples.A01" in result
-        assert "water" in result
-        assert "samples.B03" in result
-        return
-
-    def test_get_trough_component_names(self) -> None:
-        # The function requies the correct number of column names and initial volumes
-        # and should raise informative errors otherwise.
-        with pytest.raises(ValueError, match=r"column names \['A', 'B', 'C'\] don't match"):
-            liquidhandling._get_trough_component_names("water", 2, ["A", "B", "C"], [20, 10])
-        with pytest.raises(ValueError, match=r"initial volumes \[20, 10\] don't match"):
-            liquidhandling._get_trough_component_names("water", 3, ["A", "B", "C"], [20, 10])
-        with pytest.raises(ValueError, match=r"initial volumes \[\[20], \[10\]\] don't match"):
-            liquidhandling._get_trough_component_names("water", 2, ["A", "B"], [[20], [10]])
-
-        # It should also check that no names are given for empty columns
-        with pytest.raises(ValueError, match="Empty columns must be unnamed"):
-            liquidhandling._get_trough_component_names("water", 2, ["A", "B"], [20, 0])
-
-        # It explicitly sets names of empty wells to None
-        result = liquidhandling._get_trough_component_names("water", 1, [None], [0])
-        assert result == {"A01": None}
-
-        # And defaults to the labware name of single-column troughs
-        result = liquidhandling._get_trough_component_names("water", 1, [None], [100])
-        assert result == {"A01": "water"}
-
-        # But includes the 1-based column number in the default name for non-empty wells
-        result = liquidhandling._get_trough_component_names("stocks", 2, [None, None], [0, 50])
-        assert result == {"A01": None, "A02": "stocks.column_02"}
-
-        # User-provided names, default naming and empty-well all in one:
-        result = liquidhandling._get_trough_component_names(
-            "stocks", 4, ["acid", "base", None, None], [100, 100, 50, 0]
-        )
-        assert result == {"A01": "acid", "A02": "base", "A03": "stocks.column_03", "A04": None}
-        return
-
-    def test_combine_composition(self) -> None:
-        A = dict(water=1)
-        B = dict(water=0.5, glucose=0.5)
-        expected = {"water": (1 * 10 + 0.5 * 15) / (10 + 15), "glucose": 0.5 * 15 / (10 + 15)}
-        actual = liquidhandling._combine_composition(10, A, 15, B)
-        self.assertDictEqual(actual, expected)
-        return
-
-    def test_combine_unknown_composition(self) -> None:
-        A = dict(water=1)
-        B = None
-        expected = None
-        actual = liquidhandling._combine_composition(10, A, 15, B)
-        self.assertEqual(actual, expected)
-        return
-
-    def test_labware_init(self) -> None:
-        minmax = dict(min_volume=0, max_volume=4000)
-        # without initial volume, there's no composition tracking
-        A = liquidhandling.Labware("glc", 6, 8, **minmax)
-        self.assertIsInstance(A.composition, dict)
-        self.assertEqual(len(A.composition), 0)
-        self.assertDictEqual(A.get_well_composition("A01"), {})
-
-        # Single-well Labware defaults to the labware name for components
-        A = liquidhandling.Labware("x", 1, 1, **minmax, initial_volumes=300)
-        assert set(A.composition) == {"x"}
-        A = liquidhandling.Labware("x", 1, 1, virtual_rows=3, **minmax, initial_volumes=300)
-        assert set(A.composition) == {"x"}
-
-        # by setting an initial volume, the well-wise liquids take part in composition tracking
-        A = liquidhandling.Labware("glc", 6, 8, **minmax, initial_volumes=100)
-        self.assertIsInstance(A.composition, dict)
-        self.assertEqual(len(A.composition), 48)
-        self.assertIn("glc.A01", A.composition)
-        self.assertIn("glc.F08", A.composition)
-        self.assertDictEqual(A.get_well_composition("A01"), {"glc.A01": 1})
-
-        # Only wells with initial volumes take part
-        A = liquidhandling.Labware(
-            "test", 1, 3, **minmax, initial_volumes=[10, 0, 0], component_names=dict(A01="water")
-        )
-        assert set(A.composition) == {"water"}
-        return
-
-    def test_get_well_composition(self) -> None:
-        A = liquidhandling.Labware("glc", 6, 8, min_volume=0, max_volume=4000)
-        A._composition = {
-            "glc": 0.25 * numpy.ones_like(A.volumes),
-            "water": 0.75 * numpy.ones_like(A.volumes),
-        }
-        self.assertDictEqual(
-            A.get_well_composition("A01"),
-            {
-                "glc": 0.25,
-                "water": 0.75,
-            },
-        )
-        return
-
-    def test_labware_add(self) -> None:
-        A = liquidhandling.Labware(
-            "water",
-            6,
-            8,
-            min_volume=0,
-            max_volume=4000,
-            initial_volumes=10,
-            component_names={
-                "A01": "water",
-                "B01": "water",
-            },
-        )
-        water_comp = numpy.array(
-            [
-                [1, 0, 0, 0, 0, 0, 0, 0],
-                [1, 0, 0, 0, 0, 0, 0, 0],
-                [0, 0, 0, 0, 0, 0, 0, 0],
-                [0, 0, 0, 0, 0, 0, 0, 0],
-                [0, 0, 0, 0, 0, 0, 0, 0],
-                [0, 0, 0, 0, 0, 0, 0, 0],
-            ]
-        )
-        numpy.testing.assert_array_equal(A.composition["water"], water_comp)
-
-        A.add(
-            wells=["A01", "B01"],
-            volumes=[10, 20],
-            compositions=[
-                dict(glc=0.5, water=0.5),
-                dict(glc=1),
-            ],
-        )
-
-        self.assertIn("water", A.composition)
-        self.assertIn("glc", A.composition)
-        self.assertDictEqual(A.get_well_composition("A01"), dict(water=0.75, glc=0.25))
-        self.assertDictEqual(A.get_well_composition("B01"), dict(water=1 / 3, glc=2 / 3))
-        return
-
-    def test_dilution_series(self) -> None:
-        A = liquidhandling.Labware("dilutions", 1, 3, min_volume=0, max_volume=100)
-        # 100 % in 1st column
-        A.add(wells="A01", volumes=100, compositions=[dict(glucose=1)])
-        # 10x dilution to 2nd
-        A.add(wells="A02", volumes=10, compositions=[A.get_well_composition("A01")])
-        A.add(wells="A02", volumes=90, compositions=[dict(water=1)])
-        # 4x dilution to 3rd
-        A.add(wells="A03", volumes=2.5, compositions=[A.get_well_composition("A02")])
-        A.add(wells="A03", volumes=7.5, compositions=[dict(water=1)])
-
-        numpy.testing.assert_array_equal(A.volumes, [[100, 100, 10]])
-        numpy.testing.assert_array_equal(A.composition["water"], [[0, 0.9, 0.975]])
-        numpy.testing.assert_array_equal(A.composition["glucose"], [[1, 0.1, 0.025]])
-        self.assertDictEqual(A.get_well_composition("A01"), dict(glucose=1))
-        self.assertDictEqual(A.get_well_composition("A02"), dict(glucose=0.1, water=0.9))
-        self.assertDictEqual(A.get_well_composition("A03"), dict(glucose=0.025, water=0.975))
-        return
-
-    def test_trough_init(self) -> None:
-        minmax = dict(min_volume=0, max_volume=100_000)
-        # Single-column troughs use the labware name for the composition
-        W = liquidhandling.Trough("water", 2, 1, **minmax, initial_volumes=10000)
-        assert set(W.composition) == {"water"}
-
-        # Multi-column troughs get component names automatically
-        A = liquidhandling.Trough(
-            name="water", virtual_rows=2, columns=3, **minmax, initial_volumes=[0, 200, 200]
-        )
-        assert set(A.composition) == {"water.column_02", "water.column_03"}
-        assert "water.column_01" not in A.composition
-        assert "water.column_02" in A.composition
-        assert "water.column_03" in A.composition
-
-        # Components in troughs are named via a list, because the dict would
-        # require well names and columns could lead to 0/1-based confusion.
-        T = liquidhandling.Trough(
-            "stocks",
-            6,
-            3,
-            min_volume=0,
-            max_volume=10_000,
-            initial_volumes=[100, 200, 0],
-            column_names=["rich", "complex", None],
-        )
-        assert set(T.composition.keys()) == {"rich", "complex"}
-
-        # Naming just some of them works too
-        A = liquidhandling.Trough(
-            name="alice",
-            virtual_rows=2,
-            columns=3,
-            **minmax,
-            initial_volumes=[0, 200, 200],
-            column_names=[None, "NaCl", None],
-        )
-        assert set(A.composition) == {"NaCl", "alice.column_03"}
-        numpy.testing.assert_array_equal(A.composition["NaCl"], [[0, 1, 0]])
-
-    def test_trough_composition(self) -> None:
-        T = liquidhandling.Trough("media", 8, 1, min_volume=1000, max_volume=25000)
-        T.add(wells=T.wells, volumes=100, compositions=[dict(glucose=1)] * 8)
-        T.add(wells=T.wells, volumes=900, compositions=[dict(water=1)] * 8)
-        numpy.testing.assert_array_equal(T.composition["water"], [[0.9]])
-        numpy.testing.assert_array_equal(T.composition["glucose"], [[0.1]])
-        self.assertDictEqual(T.get_well_composition("B01"), dict(water=0.9, glucose=0.1))
-        return
-
-    def test_worklist_dilution(self) -> None:
-        W = liquidhandling.Trough("water", 4, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
-        G = liquidhandling.Trough("glucose", 4, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
-        D = liquidhandling.Labware("dilutions", 4, 2, min_volume=0, max_volume=10000)
-
-        with evotools.Worklist() as wl:
-            # 100 % in first column
-            wl.transfer(G, G.wells, D, D.wells[:, 0], volumes=[1000, 800, 600, 550])
-            wl.transfer(W, W.wells, D, D.wells[:, 0], volumes=[0, 200, 400, 450])
-            numpy.testing.assert_array_equal(D.volumes[:, 0], [1000, 1000, 1000, 1000])
-            numpy.testing.assert_array_equal(D.composition["glucose"][:, 0], [1, 0.8, 0.6, 0.55])
-            numpy.testing.assert_array_equal(D.composition["water"][:, 0], [0, 0.2, 0.4, 0.45])
-            # 10x dilution to the 2nd column
-            wl.transfer(D, D.wells[:, 0], D, D.wells[:, 1], volumes=100)
-            wl.transfer(W, W.wells, D, D.wells[:, 1], volumes=900)
-            numpy.testing.assert_array_equal(D.volumes[:, 1], [1000, 1000, 1000, 1000])
-            numpy.testing.assert_allclose(D.composition["glucose"][:, 1], [0.1, 0.08, 0.06, 0.055])
-            numpy.testing.assert_allclose(D.composition["water"][:, 1], [0.9, 0.92, 0.94, 0.945])
-
-        return
-
-    def test_worklist_distribution(self) -> None:
-        W = liquidhandling.Trough("water", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
-        G = liquidhandling.Trough("glucose", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
-        D = liquidhandling.Labware("dilutions", 2, 4, min_volume=0, max_volume=10000)
-
-        with evotools.Worklist() as wl:
-            # transfer some glucose
-            wl.transfer(
-                G,
-                G.wells[:, [0] * 4],
-                D,
-                D.wells,
-                volumes=numpy.array(
-                    [
-                        [100, 80, 60, 55],
-                        [55, 60, 80, 100],
-                    ]
-                ),
-            )
-            # fill up to 100
-            wl.transfer(W, W.wells[:, [0] * 4], D, D.wells, volumes=100 - D.volumes)
-            numpy.testing.assert_allclose(D.volumes, 100)
-            numpy.testing.assert_array_equal(D.composition["glucose"][0, :], [1, 0.8, 0.6, 0.55])
-            numpy.testing.assert_array_equal(D.composition["glucose"][1, :], [0.55, 0.6, 0.8, 1])
-            numpy.testing.assert_array_equal(D.composition["water"][0, :], [0, 0.2, 0.4, 0.45])
-            numpy.testing.assert_array_equal(D.composition["water"][1, :], [0.45, 0.4, 0.2, 0])
-            # dilute 2x with water
-            wl.distribute(source=W, source_column=0, destination=D, destination_wells=D.wells, volume=100)
-            numpy.testing.assert_allclose(D.volumes, 200)
-            numpy.testing.assert_array_equal(D.composition["glucose"][0, :], [0.5, 0.4, 0.3, 0.275])
-            numpy.testing.assert_array_equal(D.composition["glucose"][1, :], [0.275, 0.3, 0.4, 0.5])
-            numpy.testing.assert_array_equal(D.composition["water"][0, :], [0.5, 0.6, 0.7, 0.725])
-            numpy.testing.assert_array_equal(D.composition["water"][1, :], [0.725, 0.7, 0.6, 0.5])
-
-        return
-
-    def test_worklist_mix_no_composition_change(self) -> None:
-        A = liquidhandling.Labware("solution", 2, 3, min_volume=0, max_volume=1000)
-        A._composition["water"] = 0.25 * numpy.ones_like(A.volumes)
-        A._composition["salt"] = 0.75 * numpy.ones_like(A.volumes)
-        A._volumes = numpy.ones_like(A.volumes) * 500
-        with evotools.Worklist() as wl:
-            wl.transfer(A, A.wells, A, A.wells, volumes=300)
-        # make sure that the composition of the liquid is not changed
-        numpy.testing.assert_array_equal(A.composition["water"], 0.25 * numpy.ones_like(A.volumes))
-        numpy.testing.assert_array_equal(A.composition["salt"], 0.75 * numpy.ones_like(A.volumes))
-        return
-
-
-class TestFunctions(unittest.TestCase):
-    def test_automatic_partitioning(self) -> None:
-        evo_logger = logging.getLogger("evotools")
-        S = liquidhandling.Labware("S", 8, 2, min_volume=5000, max_volume=250 * 1000)
-        D = liquidhandling.Labware("D", 8, 2, min_volume=5000, max_volume=250 * 1000)
-        ST = liquidhandling.Trough("ST", 8, 2, min_volume=5000, max_volume=250 * 1000)
-        DT = liquidhandling.Trough("DT", 8, 2, min_volume=5000, max_volume=250 * 1000)
-
-        # Expected behaviors:
-        # + always keep settings other than 'auto'
-        # + warn user about inefficient configuration (when user selects to partition by the trough)
-
-        # automatic
-        self.assertEqual("source", evotools._optimize_partition_by(S, D, "auto", "No troughs at all"))
-        self.assertEqual("source", evotools._optimize_partition_by(S, DT, "auto", "Trough destination"))
-        self.assertEqual("destination", evotools._optimize_partition_by(ST, D, "auto", "Trough source"))
-        self.assertEqual(
-            "source", evotools._optimize_partition_by(ST, DT, "auto", "Trough source and destination")
-        )
-
-        # fixed to source
-        self.assertEqual("source", evotools._optimize_partition_by(S, D, "source", "No troughs at all"))
-        self.assertEqual("source", evotools._optimize_partition_by(S, DT, "source", "Trough destination"))
-        with self.assertLogs(logger=evo_logger, level=logging.WARNING):
-            self.assertEqual("source", evotools._optimize_partition_by(ST, D, "source", "Trough source"))
-        self.assertEqual(
-            "source", evotools._optimize_partition_by(ST, DT, "auto", "Trough source and destination")
-        )
-
-        # fixed to destination
-        self.assertEqual(
-            "destination", evotools._optimize_partition_by(S, D, "destination", "No troughs at all")
-        )
-        with self.assertLogs(logger=evo_logger, level=logging.WARNING):
-            self.assertEqual(
-                "destination", evotools._optimize_partition_by(S, DT, "destination", "Trough destination")
-            )
-        self.assertEqual(
-            "destination", evotools._optimize_partition_by(ST, D, "destination", "Trough source")
-        )
-        self.assertEqual(
-            "destination",
-            evotools._optimize_partition_by(ST, DT, "destination", "Trough source and destination"),
-        )
-        return
-
-
-class TestDilutionPlan(unittest.TestCase):
-    def test_argchecking(self) -> None:
-        with self.assertRaises(ValueError):
-            robotools.DilutionPlan(
-                xmin=0.001, xmax=30, R=8, C=12, stock=20, mode="log", vmax=1000, min_transfer=20
-            )
-
-        with self.assertRaises(ValueError):
-            robotools.DilutionPlan(
-                xmin=0.001, xmax=30, R=8, C=12, stock=30, mode="invalid", vmax=1000, min_transfer=20
-            )
-
-        with self.assertRaises(ValueError):
-            robotools.DilutionPlan(
-                xmin=0.001, xmax=30, R=6, C=4, stock=30, mode="linear", vmax=1000, min_transfer=20
-            )
-
-        with self.assertRaises(ValueError):
-            robotools.DilutionPlan(
-                xmin=0.001,
-                xmax=30,
-                R=6,
-                C=4,
-                stock=30,
-                mode="linear",
-                vmax=[1000, 1000, 1000],
-                min_transfer=20,
-            )
-
-        return
-
-    def test_repr(self) -> None:
-        plan = robotools.DilutionPlan(
-            xmin=0.001, xmax=30, R=8, C=12, stock=30, mode="log", vmax=1000, min_transfer=20
-        )
-
-        out = plan.__repr__()
-
-        self.assertIsNotNone(out)
-        self.assertIsInstance(out, str)
-        return
-
-    def test_linear_plan(self) -> None:
-        plan = robotools.DilutionPlan(
-            xmin=1, xmax=10, R=10, C=1, stock=20, mode="linear", vmax=1000, min_transfer=20
-        )
-
-        numpy.testing.assert_array_equal(plan.x, plan.ideal_x)
-        self.assertEqual(plan.max_steps, 0)
-        self.assertEqual(plan.v_stock, 2750)
-        self.assertEqual(plan.instructions[0][0], 0)
-        self.assertEqual(plan.instructions[0][1], 0)
-        self.assertEqual(plan.instructions[0][2], "stock")
-        numpy.testing.assert_array_equal(
-            plan.instructions[0][3],
-            [
-                500,
-                450,
-                400,
-                350,
-                300,
-                250,
-                200,
-                150,
-                100,
-                50,
-            ],
-        )
-        return
-
-    def test_log_plan(self) -> None:
-        plan = robotools.DilutionPlan(
-            xmin=0.01, xmax=10, R=4, C=3, stock=20, mode="log", vmax=1000, min_transfer=20
-        )
-
-        self.assertTrue(numpy.allclose(plan.x, plan.ideal_x, rtol=0.05))
-        self.assertEqual(plan.max_steps, 2)
-        self.assertEqual(plan.v_stock, 985)
-        self.assertEqual(plan.instructions[0][0], 0)
-        self.assertEqual(plan.instructions[0][1], 0)
-        self.assertEqual(plan.instructions[0][2], "stock")
-        numpy.testing.assert_array_equal(plan.instructions[0][3], [500, 267, 142, 76])
-        numpy.testing.assert_array_equal(plan.instructions[1][3], [82, 82, 82, 82])
-        numpy.testing.assert_array_equal(plan.instructions[2][3], [81, 81, 81, 81])
-        return
-
-    def test_vector_vmax(self) -> None:
-        plan = robotools.DilutionPlan(
-            xmin=0.01, xmax=10, R=4, C=3, stock=20, mode="log", vmax=[1000, 500, 1500], min_transfer=20
-        )
-
-        self.assertTrue(numpy.allclose(plan.x, plan.ideal_x, rtol=0.05))
-        self.assertEqual(plan.max_steps, 2)
-        self.assertEqual(plan.v_stock, 985)
-        self.assertEqual(plan.instructions[0][0], 0)
-        self.assertEqual(plan.instructions[0][1], 0)
-        self.assertEqual(plan.instructions[0][2], "stock")
-        numpy.testing.assert_array_equal(plan.instructions[0][3], [500, 267, 142, 76])
-        numpy.testing.assert_array_equal(plan.instructions[1][3], [41, 41, 41, 41])
-        numpy.testing.assert_array_equal(plan.instructions[2][3], [121, 121, 121, 121])
-        return
-
-    def test_to_worklist(self) -> None:
-        # this test case tries to make it as hard as possible for the `to_worklist` method:
-        # + vmax is different in every column
-        # + stock has 2 rows, but dilution plan has 3
-        # + diluent has 4 rows but dilution plan has 3
-        # + diluent is not in the first column of a multi-column trough
-        # + dilution plate is bigger than the plan
-        # + destination plate is bigger than the plan
-        stock_concentration = 20
-        plan = robotools.DilutionPlan(
-            xmin=0.01,
-            xmax=10,
-            R=3,
-            C=4,
-            stock=stock_concentration,
-            mode="log",
-            vmax=[1000, 1900, 980, 500],
-            min_transfer=50,
-        )
-        stock = liquidhandling.Trough("Stock", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
-        diluent = liquidhandling.Trough(
-            "Diluent", 4, 2, min_volume=0, max_volume=20_000, initial_volumes=[0, 20_000]
-        )
-        dilution = liquidhandling.Labware("Dilution", 6, 8, min_volume=0, max_volume=2000)
-        destination = liquidhandling.Labware("Destination", 7, 10, min_volume=0, max_volume=1000)
-        with evotools.Worklist() as wl:
-            plan.to_worklist(
-                worklist=wl,
-                stock=stock,
-                stock_column=0,
-                diluent=diluent,
-                diluent_column=1,
-                dilution_plate=dilution,
-                destination_plate=destination,
-                v_destination=200,
-                mix_volume=0.75,
-            )
-        # assert the achieved concentrations in the destination
-        numpy.testing.assert_array_almost_equal(
-            plan.x, destination.composition["Stock"][: plan.R, : plan.C] * stock_concentration
-        )
-        assert "Mix column 0 with 75 % of its volume" in dilution.report
-        assert "Mix column 1 with 50 % of its volume" in dilution.report
-        return
-
-    def test_to_worklist_hooks(self) -> None:
-        stock_concentration = 123
-        plan = robotools.DilutionPlan(
-            xmin=1,
-            xmax=123,
-            R=3,
-            C=4,
-            stock=stock_concentration,
-            mode="log",
-            vmax=1000,
-            min_transfer=50,
-        )
-        stock = liquidhandling.Trough("Stock", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000)
-        diluent = liquidhandling.Trough(
-            "Diluent", 4, 2, min_volume=0, max_volume=10000, initial_volumes=[0, 10000]
-        )
-        dilution = liquidhandling.Labware("Dilution", 3, 4, min_volume=0, max_volume=2000)
-
-        # Multiple destinations; transferred to via a hook
-        destinations = [
-            robotools.Labware("DestinationOne", 3, 2, min_volume=0, max_volume=1000),
-            robotools.Labware("DestinationTwo", 3, 2, min_volume=0, max_volume=1000),
-        ]
-
-        # Split the work across two worklists (also via the hook)
-        wl_one = robotools.Worklist()
-        wl_two = robotools.Worklist()
-
-        def pre_mix(col, wl):
-            wl.comment(f"Pre-mix on column {col}")
-            if col == 1:
-                return wl_two
-
-        def post_mix(col, wl):
-            wl.comment(f"Post-mix on column {col}")
-            if col == 1:
-                wl.transfer(
-                    dilution,
-                    dilution.wells[:, 0:2],
-                    destinations[0],
-                    destinations[0].wells[:, :],
-                    volumes=100,
-                )
-            elif col == 3:
-                wl.transfer(
-                    dilution,
-                    dilution.wells[:, 2:4],
-                    destinations[1],
-                    destinations[1].wells[:, :],
-                    volumes=100,
-                )
-
-        plan.to_worklist(
-            worklist=wl_one,
-            stock=stock,
-            stock_column=0,
-            diluent=diluent,
-            diluent_column=1,
-            dilution_plate=dilution,
-            pre_mix_hook=pre_mix,
-            post_mix_hook=post_mix,
-        )
-
-        assert len(wl_two) > 0
-        assert "C;Pre-mix on column 0" in wl_one
-        assert "C;Pre-mix on column 1" in wl_one
-        assert "C;Pre-mix on column 2" in wl_two
-        assert "C;Pre-mix on column 3" in wl_two
-
-        assert "C;Post-mix on column 0" in wl_one
-        assert "C;Post-mix on column 1" in wl_two  # worklist switched in the mix hook!
-        assert "C;Post-mix on column 2" in wl_two
-        assert "C;Post-mix on column 3" in wl_two
-
-        numpy.testing.assert_almost_equal(
-            destinations[0].composition["Stock"] * stock_concentration, plan.x[:, [0, 1]]
-        )
-        numpy.testing.assert_almost_equal(
-            destinations[1].composition["Stock"] * stock_concentration, plan.x[:, [2, 3]]
-        )
-        return
-
-
-class TestUtils(unittest.TestCase):
-    def test_get_trough_wells(self) -> None:
-        with self.assertRaises(ValueError):
-            robotools.get_trough_wells(n=-1, trough_wells=list("ABC"))
-        with self.assertRaises(ValueError):
-            robotools.get_trough_wells(n=3, trough_wells=[])
-        with self.assertRaises(TypeError):
-            robotools.get_trough_wells(n=0.5, trough_wells=list("ABC"))
-        with self.assertRaises(TypeError):
-            robotools.get_trough_wells(n=2, trough_wells="ABC")
-        self.assertSequenceEqual(robotools.get_trough_wells(n=0, trough_wells=list("ABC")), list())
-        self.assertSequenceEqual(robotools.get_trough_wells(n=1, trough_wells=list("ABC")), list("A"))
-        self.assertSequenceEqual(robotools.get_trough_wells(n=3, trough_wells=list("ABC")), list("ABC"))
-        self.assertSequenceEqual(robotools.get_trough_wells(n=4, trough_wells=list("ABC")), list("ABCA"))
-        self.assertSequenceEqual(robotools.get_trough_wells(n=7, trough_wells=list("ABC")), list("ABCABCA"))
-        return
-
-
-class TestWellShifter(unittest.TestCase):
-    def test_identity_transform(self) -> None:
-        A = (6, 8)
-        B = (8, 12)
-        shifter = transform.WellShifter(A, B, shifted_A01="A01")
-
-        original = ["A01", "C03", "D06", "F08"]
-        expected = ["A01", "C03", "D06", "F08"]
-        shifted = shifter.shift(original)
-        numpy.testing.assert_array_equal(expected, shifter.shift(original))
-        numpy.testing.assert_array_equal(shifter.unshift(shifted), original)
-        return
-
-    def test_center_shift(self) -> None:
-        A = (6, 8)
-        B = (8, 12)
-        shifter = transform.WellShifter(A, B, shifted_A01="B03")
-
-        original = ["A01", "C03", "D06", "F08"]
-        expected = ["B03", "D05", "E08", "G10"]
-        shifted = shifter.shift(original)
-        numpy.testing.assert_array_equal(expected, shifter.shift(original))
-        numpy.testing.assert_array_equal(shifter.unshift(shifted), original)
-        return
-
-    def test_boundcheck(self) -> None:
-        A = (6, 8)
-        B = (8, 12)
-
-        with self.assertRaises(ValueError):
-            transform.WellShifter(A, B, shifted_A01="E03")
-
-        with self.assertRaises(ValueError):
-            transform.WellShifter(A, B, shifted_A01="B06")
-        return
-
-
-class TestWellRotator(unittest.TestCase):
-    def test_init(self) -> None:
-        rotator = transform.WellRotator(original_shape=(7, 3))
-        self.assertEqual(rotator.original_shape, (7, 3))
-        self.assertEqual(rotator.rotated_shape, (3, 7))
-        return
-
-    def test_clockwise(self) -> None:
-        A = (6, 8)
-        rotator = transform.WellRotator(A)
-
-        original = ["A01", "C03", "D06", "F08", "B04"]
-        expected = ["A06", "C04", "F03", "H01", "D05"]
-        rotated = rotator.rotate_cw(original)
-        numpy.testing.assert_array_equal(expected, rotated)
-        return
-
-    def test_counterclockwise(self) -> None:
-        A = (6, 8)
-        rotator = transform.WellRotator(A)
-
-        original = ["A01", "C03", "D06", "F08", "B04"]
-        expected = ["H01", "F03", "C04", "A06", "E02"]
-        rotated = rotator.rotate_ccw(original)
-        numpy.testing.assert_array_equal(expected, rotated)
-        return
-
-
-class TestWellRandomizer(unittest.TestCase):
-    def test_init(self):
-        randomizer = transform.WellRandomizer(original_shape=(1, 4), random_seed=13)
-        assert randomizer.original_shape == (1, 4)
-        assert randomizer.random_seed == 13
-        numpy.testing.assert_array_equal(randomizer.randomized_wells, ["A02", "A04", "A01", "A03"])
-        return
-
-    def test_randomize_wells(self) -> None:
-        A = (6, 8)
-        S = 13
-        randomizer = transform.WellRandomizer(A, S)
-        original = ["A01", "A02", "A03", "A04", "A05", "A06"]
-        expected = ["A01", "F02", "A05", "A07", "B07", "D06"]
-        randomized = randomizer.randomize_wells(original)
-        numpy.testing.assert_array_equal(expected, randomized)
-        return
-
-    def test_derandomize_wells(self) -> None:
-        A = (6, 8)
-        S = 13
-        randomizer = transform.WellRandomizer(A, S)
-        original = ["A01", "F02", "A05", "A07", "B07", "D06"]
-        expected = ["A01", "A02", "A03", "A04", "A05", "A06"]
-        derandomized = randomizer.derandomize_wells(original)
-        numpy.testing.assert_array_equal(expected, derandomized)
-        return
-
-    def test_derandomize_wells_bug_29(self) -> None:
-        A = (6, 8)
-        S = 13
-        randomizer = transform.WellRandomizer(A, S)
-        original = ["A01", "F02", "A05", "A07", "B07", "D06"][::-1]
-        expected = ["A01", "A02", "A03", "A04", "A05", "A06"][::-1]
-        derandomized = randomizer.derandomize_wells(original)
-        numpy.testing.assert_array_equal(expected, derandomized)
-        return
-
-    def test_randomize_wells_in_row(self) -> None:
-        A = (6, 8)
-        S = 13
-        randomizer = transform.WellRandomizer(A, S, mode="row")
-        original = ["A01", "A02", "A03", "B01", "C02", "B04"]
-        expected = ["A02", "A05", "A04", "B08", "C05", "B06"]
-        randomized = randomizer.randomize_wells(original)
-        numpy.testing.assert_array_equal(expected, randomized)
-        return
-
-    def test_derandomize_wells_in_row(self) -> None:
-        A = (6, 8)
-        S = 13
-        randomizer = transform.WellRandomizer(A, S, mode="row")
-        original = ["A02", "A05", "A04", "B08", "C05", "B06"]
-        expected = ["A01", "A02", "A03", "B01", "C02", "B04"]
-        randomized = randomizer.derandomize_wells(original)
-        numpy.testing.assert_array_equal(expected, randomized)
-        return
-
-    def test_randomize_wells_in_column(self) -> None:
-        A = (6, 8)
-        S = 13
-        randomizer = transform.WellRandomizer(A, S, mode="column")
-        original = ["A01", "A02", "A03", "B01", "C02", "B04"]
-        expected = ["B01", "D02", "B03", "D01", "A02", "E04"]
-        randomized = randomizer.randomize_wells(original)
-        numpy.testing.assert_array_equal(expected, randomized)
-        return
-
-    def test_derandomize_wells_in_column(self) -> None:
-        A = (6, 8)
-        S = 13
-        randomizer = transform.WellRandomizer(A, S, mode="column")
-        original = ["B01", "D02", "B03", "D01", "A02", "E04"]
-        expected = ["A01", "A02", "A03", "B01", "C02", "B04"]
-        randomized = randomizer.derandomize_wells(original)
-        numpy.testing.assert_array_equal(expected, randomized)
-        return
-
-
-if __name__ == "__main__":
-    unittest.main()