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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "robotools"
version = "1.13.0"
version = "1.13.1"
description = "Pythonic in-silico liquid handling and creation of Tecan FreedomEVO worklists."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
8 changes: 5 additions & 3 deletions robotools/evotools/test_worklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,11 +598,11 @@ def test_transfer_on_underflow(self):
with EvoWorklist() as wl:
wl.transfer(A, "A01", A, "A02", 600, on_underflow="debug")
assert A.volumes[0, 0] == 100
assert A.volumes[0, 1] == 1100
assert A.volumes[0, 1] == 900 # only the aspiratable volume is transferred
with pytest.warns(VolumeUnderflowWarning, match="500.0 - 600.0 < 100"):
wl.transfer(A, "B01", A, "B02", 600, on_underflow="warn")
assert A.volumes[1, 0] == 100
assert A.volumes[1, 1] == 1100
assert A.volumes[1, 1] == 900
with pytest.raises(VolumeUnderflowError, match="500.0 - 600.0 < 100"):
wl.transfer(A, "C01", A, "C02", 600, on_underflow="raise")

Expand Down Expand Up @@ -651,7 +651,7 @@ def test_evo_aspirate(self) -> None:
lw = Labware("A", 4, 5, min_volume=10, max_volume=100)
lw.add("A01", 50)
with EvoWorklist() as wl:
wl.evo_aspirate(
vasp = wl.evo_aspirate(
lw,
"A01",
labware_position=(30, 2),
Expand All @@ -660,6 +660,8 @@ def test_evo_aspirate(self) -> None:
liquid_class="PowerSuck",
on_underflow="debug",
)
# only (50 - 10) = 40 µL are considered aspiratable
assert vasp == [40]
assert len(wl) == 1
assert "B;Aspirate" in wl[0]
# Underflow ignored, minimum volume remains
Expand Down
16 changes: 12 additions & 4 deletions robotools/evotools/worklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def evo_aspirate(
arm: int = 0,
label: Optional[str] = None,
on_underflow: Literal["debug", "warn", "raise"] = "raise",
) -> None:
) -> list[float]:
"""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.

Expand Down Expand Up @@ -71,13 +71,18 @@ def evo_aspirate(
- ``"debug"`` mentions the underflowing wells in a log message at DEBUG level.
- ``"warn"`` emits an :class:`~robotools.liquidhandling.exceptions.VolumeUnderflowWarning`. This `can be captured in unit tests <https://docs.pytest.org/en/stable/how-to/capture-warnings.html#additional-use-cases-of-warnings-in-tests>`_.
- ``"raise"`` raises a :class:`~robotools.liquidhandling.exceptions.VolumeUnderflowError` about underflowing wells.

Returns
-------
vaspirated
List of aspirated volumes according to previous filling volume and minima.
"""
# diferentiate between what is needed for volume calculation and for pipetting commands
wells_calc = np.array(wells).flatten("F")
volumes_calc = np.array(volumes).flatten("F")
if len(volumes_calc) == 1:
volumes_calc = np.repeat(volumes_calc, len(wells_calc))
labware.remove(wells_calc, volumes_calc, label, on_underflow=on_underflow)
vasp = labware.remove(wells_calc, volumes_calc, label, on_underflow=on_underflow)
self.comment(label)
cmd = commands.evo_aspirate(
n_rows=labware.n_rows,
Expand All @@ -91,7 +96,7 @@ def evo_aspirate(
max_volume=self.max_volume,
)
self.append(cmd)
return
return vasp

def evo_dispense(
self,
Expand Down Expand Up @@ -326,13 +331,16 @@ def transfer(
if len(vs) > p:
v = vs[p]
if v > 0:
self.aspirate(source, s, v, label=None, on_underflow=on_underflow, **kwargs)
vasp = self.aspirate(
source, s, v, label=None, on_underflow=on_underflow, **kwargs
)
self.dispense(
destination,
d,
v,
label=None,
compositions=[source.get_well_composition(s)],
vtrack=vasp,
**kwargs,
)
nsteps += 1
Expand Down
4 changes: 2 additions & 2 deletions robotools/fluenttools/test_worklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ def test_transfer_on_underflow(self):
with FluentWorklist() as wl:
wl.transfer(A, "A01", A, "A02", 600, on_underflow="debug")
assert A.volumes[0, 0] == 100
assert A.volumes[0, 1] == 1100
assert A.volumes[0, 1] == 900 # only the aspiratable volume is transferred
with pytest.warns(VolumeUnderflowWarning, match="500.0 - 600.0 < 100"):
wl.transfer(A, "B01", A, "B02", 600, on_underflow="warn")
assert A.volumes[1, 0] == 100
assert A.volumes[1, 1] == 1100
assert A.volumes[1, 1] == 900
with pytest.raises(VolumeUnderflowError, match="500.0 - 600.0 < 100"):
wl.transfer(A, "C01", A, "C02", 600, on_underflow="raise")
5 changes: 4 additions & 1 deletion robotools/fluenttools/worklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,16 @@ def transfer(
if len(vs) > p:
v = vs[p]
if v > 0:
self.aspirate(source, s, v, label=None, on_underflow=on_underflow, **kwargs)
vasp = self.aspirate(
source, s, v, label=None, on_underflow=on_underflow, **kwargs
)
self.dispense(
destination,
d,
v,
label=None,
compositions=[source.get_well_composition(s)],
vtrack=vasp,
**kwargs,
)
nsteps += 1
Expand Down
16 changes: 11 additions & 5 deletions robotools/liquidhandling/labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ def remove(
label: Optional[str] = None,
*,
on_underflow: Literal["debug", "warn", "raise"] = "raise",
) -> None:
) -> list[float]:
"""Removes volumes from wells.

Parameters
Expand All @@ -314,13 +314,19 @@ def remove(
- ``"debug"`` mentions the underflowing wells in a log message at DEBUG level.
- ``"warn"`` emits an :class:`~robotools.liquidhandling.exceptions.VolumeUnderflowWarning`. This `can be captured in unit tests <https://docs.pytest.org/en/stable/how-to/capture-warnings.html#additional-use-cases-of-warnings-in-tests>`_.
- ``"raise"`` raises a :class:`~robotools.liquidhandling.exceptions.VolumeUnderflowError` about underflowing wells.

Returns
-------
vaspirated
List of aspirated volumes according to previous filling volume and minima.
"""
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."
vaspirated = []
for well, volume in zip(wells, volumes):
idx = self.indices[well]
v_original = self._volumes[idx]
Expand All @@ -337,11 +343,11 @@ def remove(
raise VolumeUnderflowError(
self.name, well, v_original, volume, self.min_volume, label
)
self._volumes[idx] = self.min_volume
else:
self._volumes[idx] -= volume
volume = v_original - self.min_volume
self._volumes[idx] -= volume
vaspirated.append(volume)
self.log(label)
return
return vaspirated

def log(self, label: Optional[str]) -> None:
"""Logs the current volumes to the history.
Expand Down
13 changes: 10 additions & 3 deletions robotools/liquidhandling/test_labware.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,15 @@ def test_warn_on_underflow(self) -> None:
plate = Labware("TestPlate", 4, 6, min_volume=100, max_volume=250, initial_volumes=150)
wells = ["A01", "A02", "B04"]
with pytest.warns(VolumeUnderflowWarning, match="150.0 - 500 < 100"):
plate.remove(wells, 500, on_underflow="warn")
vaspirated = plate.remove(wells, [500, 500, 30], on_underflow="warn")
# At least the min volume always remains
assert plate.volumes[0, 0] == 100
assert plate.volumes[0, 1] == 100
assert plate.volumes[1, 3] == 120
assert len(plate.history) == 2
# No more than the difference from current to min volume is aspirated
assert isinstance(vaspirated, list)
assert vaspirated == [50.0, 50.0, 30.0]
return

def test_remove_debug_underflows(self, caplog) -> None:
Expand Down Expand Up @@ -308,8 +314,9 @@ def test_trough_add_too_much(self) -> None:

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)
# removing from the first column (which is actually one well)
vasp = trough.remove(["A01", "B01"], 50)
assert vasp == [50, 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)
Expand Down
31 changes: 26 additions & 5 deletions robotools/worklists/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ def aspirate(
label: Optional[str] = None,
on_underflow: Literal["debug", "warn", "raise"] = "raise",
**kwargs,
) -> None:
) -> list[float]:
"""Performs aspiration from the provided labware.

Parameters
Expand All @@ -476,17 +476,22 @@ def aspirate(
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.

Returns
-------
vaspirated
List of aspirated volumes according to previous filling volume and minima.
"""
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, on_underflow=on_underflow)
vaspirated = labware.remove(wells, volumes, label, on_underflow=on_underflow)
self.comment(label)
for well, volume in zip(wells, volumes):
if volume > 0:
self.aspirate_well(labware.name, self._get_well_position(labware, well), volume, **kwargs)
return
return vaspirated

def dispense(
self,
Expand All @@ -496,6 +501,7 @@ def dispense(
*,
label: Optional[str] = None,
compositions: Optional[List[Optional[Dict[str, float]]]] = None,
vtrack: Union[float, Sequence[float], numpy.ndarray] | None = None,
**kwargs,
) -> None:
"""Performs dispensing into the provided labware.
Expand All @@ -507,11 +513,16 @@ def dispense(
wells : str or iterable
List of well ids
volumes : float or iterable
Volume(s) to dispense
Volume(s) to dispense in the command
label : str
Label of the operation to log into labware history
compositions : list
Iterable of liquid compositions
vtrack
Volume to add to the destination in digital volume tracking.
Defaults to ``volume``.
Used by ``transfer`` commands to account for ``on_underflow`` situations
where more volume was aspirated than considered aspiratable based on volume tracking.
kwargs
Additional keyword arguments to pass to `dispense_well`.
Most prominent example: `liquid_class`.
Expand All @@ -521,7 +532,16 @@ def dispense(
volumes = numpy.array(volumes).flatten("F")
if len(volumes) == 1:
volumes = numpy.repeat(volumes, len(wells))
labware.add(wells, volumes, label, compositions=compositions)

# Digital volume transfer overrides may be provided
if vtrack is None:
vtrack = volumes
else:
vtrack = numpy.array(vtrack).flatten("F")
if len(vtrack) == 1:
vtrack = numpy.repeat(vtrack, len(wells))

labware.add(wells, vtrack, label, compositions=compositions)
self.comment(label)
for well, volume in zip(wells, volumes):
if volume > 0:
Expand Down Expand Up @@ -643,6 +663,7 @@ def distribute(
# update volume tracking
n_dst = len(dst_wells)
source.remove(source.wells[0, source_column], volume * n_dst, label=label)
# ℹ No need to capture vasp volumes because underflows are verboten in this operation.
src_composition = source.get_well_composition(source.wells[0, source_column])
destination.add(destination_wells, volume, label=label, compositions=[src_composition] * n_dst)
return
Expand Down
4 changes: 3 additions & 1 deletion robotools/worklists/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,9 @@ class TestStandardLabwareWorklist:
def test_aspirate(self, wl_cls) -> None:
source = Labware("SourceLW", rows=3, columns=3, min_volume=10, max_volume=200, initial_volumes=200)
with wl_cls() as wl:
wl.aspirate(source, ["A01", "A02", "C02"], 50, label=None)
vasp = wl.aspirate(source, ["A01", "A02", "C02"], 50, label=None)
assert isinstance(vasp, list)
assert vasp == [50, 50, 50]
wl.aspirate(source, ["A03", "B03", "C03"], [10, 20, 30.5], label="second aspirate")
assert wl == [
"A;SourceLW;;;1;;50.00;;;;",
Expand Down
Loading